Add hook 5 (loadModelFromFileAsync), combatlog stub, framecrash vtable hooks, marker assets
Hook 5 intercepts loadModelFromFileAsync (0x71d4e0) to synchronously load M2 model data for fake in-memory file contexts. processLoadedModelData returns 1 and entities are created, but file context cleanup is currently skipped (leaks 0x60 bytes per load) due to a crash in the cleanup path, and there's a later EIP=0 crash during the game main loop that needs investigation. Also adds: combatlog module stub, framecrash anchor vtable hooks (GetRelativeTo and GetWidth/GetHeight crash guards), embedded Raid_UI_FX model assets with skins and textures, WU_XYZ debug model.
This commit is contained in:
@@ -13,6 +13,8 @@ pub fn build(b: *std.Build) void {
|
||||
const enable_interact = b.option(bool, "interact", "Enable interact module") orelse true;
|
||||
const enable_outline = b.option(bool, "outline", "Enable outline module") orelse true;
|
||||
const enable_markers = b.option(bool, "markers", "Enable markers module") orelse true;
|
||||
const enable_framecrash = b.option(bool, "framecrash", "Enable framecrash fix") orelse true;
|
||||
const enable_combatlog = b.option(bool, "combatlog", "Enable combat log freshness") orelse true;
|
||||
|
||||
// Create build options module
|
||||
const build_options = b.addOptions();
|
||||
@@ -20,6 +22,8 @@ pub fn build(b: *std.Build) void {
|
||||
build_options.addOption(bool, "enable_interact", enable_interact);
|
||||
build_options.addOption(bool, "enable_outline", enable_outline);
|
||||
build_options.addOption(bool, "enable_markers", enable_markers);
|
||||
build_options.addOption(bool, "enable_framecrash", enable_framecrash);
|
||||
build_options.addOption(bool, "enable_combatlog", enable_combatlog);
|
||||
const build_options_module = build_options.createModule();
|
||||
|
||||
const hook_mod = b.dependency("hook", .{
|
||||
@@ -47,18 +51,22 @@ pub fn build(b: *std.Build) void {
|
||||
const build_all_step = b.step("all-variants", "Build all DLL variants");
|
||||
|
||||
// Helper to create a single-module build
|
||||
inline for (&[_]struct { name: []const u8, screenshot: bool, interact: bool, outline: bool, markers: bool }{
|
||||
.{ .name = "full", .screenshot = true, .interact = true, .outline = true, .markers = true },
|
||||
.{ .name = "screenshot", .screenshot = true, .interact = false, .outline = false, .markers = false },
|
||||
.{ .name = "interact", .screenshot = false, .interact = true, .outline = false, .markers = false },
|
||||
.{ .name = "outline", .screenshot = false, .interact = false, .outline = true, .markers = false },
|
||||
.{ .name = "markers", .screenshot = false, .interact = false, .outline = false, .markers = true },
|
||||
inline for (&[_]struct { name: []const u8, screenshot: bool, interact: bool, outline: bool, markers: bool, framecrash: bool, combatlog: bool }{
|
||||
.{ .name = "full", .screenshot = true, .interact = true, .outline = true, .markers = true, .framecrash = true, .combatlog = true },
|
||||
.{ .name = "screenshot", .screenshot = true, .interact = false, .outline = false, .markers = false, .framecrash = true, .combatlog = true },
|
||||
.{ .name = "interact", .screenshot = false, .interact = true, .outline = false, .markers = false, .framecrash = true, .combatlog = true },
|
||||
.{ .name = "outline", .screenshot = false, .interact = false, .outline = true, .markers = false, .framecrash = true, .combatlog = true },
|
||||
.{ .name = "markers", .screenshot = false, .interact = false, .outline = false, .markers = true, .framecrash = true, .combatlog = true },
|
||||
.{ .name = "framecrash", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = true, .combatlog = false },
|
||||
.{ .name = "combatlog", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = false, .combatlog = true },
|
||||
}) |variant| {
|
||||
const opts = b.addOptions();
|
||||
opts.addOption(bool, "enable_screenshot", variant.screenshot);
|
||||
opts.addOption(bool, "enable_interact", variant.interact);
|
||||
opts.addOption(bool, "enable_outline", variant.outline);
|
||||
opts.addOption(bool, "enable_markers", variant.markers);
|
||||
opts.addOption(bool, "enable_framecrash", variant.framecrash);
|
||||
opts.addOption(bool, "enable_combatlog", variant.combatlog);
|
||||
|
||||
const variant_lib = b.addLibrary(.{
|
||||
.name = variant.name,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Combat log freshness module.
|
||||
//!
|
||||
//! Appends a timestamp to the combat log filename so each client session
|
||||
//! gets a fresh log file (e.g. WoWCombatLog_20260227_123456.txt).
|
||||
//!
|
||||
//! TODO: Find and hook the combat log open/create function.
|
||||
|
||||
const con = @import("../console.zig");
|
||||
|
||||
pub fn installHooks() void {
|
||||
con.print("[combatlog] Module loaded (stub)\n");
|
||||
}
|
||||
|
||||
pub fn removeHooks() void {}
|
||||
@@ -0,0 +1,461 @@
|
||||
# Framecrash Research
|
||||
|
||||
## The Crash
|
||||
|
||||
```
|
||||
ERROR #132 (0x85100084) Fatal Exception
|
||||
Exception: 0xC0000005 (ACCESS_VIOLATION) at 0023:007A2452
|
||||
The instruction at "0x007A2452" referenced memory at "0x16C0FFE8".
|
||||
The memory could not be "read".
|
||||
```
|
||||
|
||||
Crash log: `/media/bigfaststore/games/twmoa_1172/Errors/2026-02-25 19.38.01 Crash.txt`
|
||||
|
||||
### Registers at Crash
|
||||
```
|
||||
EAX=321A2188 EBX=16C0FFE4 ECX=00000000 EDX=25B94A35
|
||||
ESI=03319888 EDI=303A4588 EBP=00F2FADC ESP=00F2FACC
|
||||
```
|
||||
|
||||
### Crash Instruction
|
||||
```
|
||||
0x007A2452: 8B 43 04 MOV EAX, [EBX+4] ; EBX=0x16C0FFE4 → reads 0x16C0FFE8 (decommitted page)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Call Chain
|
||||
|
||||
```
|
||||
processGraphicsFrame (0x764330)
|
||||
→ renderAllFrameLayers (0x765650)
|
||||
→ processFrameUpdates
|
||||
→ DispatchHeartbeatEvent (0x76b2c0) — fires OnUpdate
|
||||
→ ExecuteLuaCallback (0x704f10)
|
||||
→ luaD_pcall (0x6f6960)
|
||||
→ luaCallFunction (0x6f6050)
|
||||
→ luaGetPoint (0x7a2340) ← CRASH
|
||||
```
|
||||
|
||||
An addon's **OnUpdate** handler calls `frame:GetPoint()` on a frame whose anchor
|
||||
references a **destroyed frame** via a dangling pointer.
|
||||
|
||||
Stack string evidence: `"DBG:MinimapButtonFrame"` visible in stack dump.
|
||||
|
||||
---
|
||||
|
||||
## Function: `luaGetPoint` (0x007A2340)
|
||||
|
||||
This is the Lua API `frame:GetPoint(index)`. It returns anchor point info:
|
||||
`point, relativeTo, relativePoint, xOfs, yOfs`.
|
||||
|
||||
### Relevant Disassembly
|
||||
|
||||
```asm
|
||||
; === Anchor iteration loop ===
|
||||
; Frame anchor array starts at frame+0x28, 9 slots (one per POINT enum)
|
||||
0x007a23f5: LEA EAX,[EDI + 0x28] ; anchor array start
|
||||
0x007a23f8: MOV EDI,[EAX] ; EDI = anchor_points[i]
|
||||
0x007a23fa: TEST EDI,EDI ; skip NULL slots
|
||||
0x007a23fc: JZ 0x007a2404
|
||||
0x007a23fe: CMP ECX,[EBP-4] ; compare current_index with requested_index
|
||||
0x007a2401: JGE 0x007a2416 ; found the requested anchor
|
||||
0x007a2403: INC ECX
|
||||
0x007a2404: INC EBX ; (EBX = enum counter here, NOT the crash EBX)
|
||||
0x007a2405: ADD EAX,0x4
|
||||
0x007a2408: CMP EBX,0x9 ; 9 anchor point types max
|
||||
0x007a240b: JL 0x007a23f8
|
||||
|
||||
; === 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
|
||||
0x007a2425: MOV EAX,[EDI]
|
||||
0x007a2429: CALL [EAX + 0xc] ; vtable[3]() again
|
||||
0x007a242c: TEST EAX,EAX
|
||||
0x007a242e: JZ 0x007a2438 ; if NULL → relativeTo = NULL
|
||||
0x007a2430: ADD EAX,-0x24 ; adjust from inner offset to frame base
|
||||
0x007a2433: MOV [EBP-4],EAX ; store relativeTo frame ptr
|
||||
|
||||
; Push anchor point name string onto Lua stack
|
||||
0x007a243f: MOV ECX,EBX ; EBX = enum value
|
||||
0x007a2441: CALL PositionEnumToString ; (0x006f1890)
|
||||
0x007a244a: CALL lua_pushstring ; (0x006f3890)
|
||||
|
||||
; === CRASH SITE ===
|
||||
0x007a244f: MOV EBX,[EBP-4] ; EBX = relativeTo frame ptr (DANGLING!)
|
||||
0x007a2452: MOV EAX,[EBX+4] ; ← ACCESS VIOLATION: reads lua ref from freed frame
|
||||
0x007a2455: TEST EAX,EAX
|
||||
0x007a2457: JNZ 0x007a2462 ; if lua ref exists, skip registration
|
||||
0x007a2459: PUSH 0x0
|
||||
0x007a245b: MOV ECX,EBX
|
||||
0x007a245d: CALL RegisterFrameScriptReference ; (0x00701bd0)
|
||||
0x007a2462: MOV ECX,[EBX+8] ; lua reference index
|
||||
...
|
||||
```
|
||||
|
||||
### Safe "no relativeTo" path (0x007a24d7)
|
||||
When `GetRelativeTo()` returns NULL, execution jumps here and pushes just
|
||||
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`:
|
||||
|
||||
```
|
||||
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!)
|
||||
+0x10 4 relative point enum (uint)
|
||||
```
|
||||
|
||||
**Vtable address**: `0x0081c44c` (in .rdata)
|
||||
**vtable[3]** (at vtable+0xC = `0x0081c458`): GetRelativeTo — simply returns `this+0x0C`
|
||||
|
||||
### Anchor Creation (in SetAnimationOrder)
|
||||
```c
|
||||
// Allocate 0x14 bytes
|
||||
anchor = M2_AllocateModelBuffer(0x14, ...);
|
||||
anchor->xOfs = param_4; // +0x04
|
||||
anchor->yOfs = param_5; // +0x08
|
||||
anchor->relativeTo = param_2; // +0x0C ← RAW POINTER, no ref held
|
||||
anchor->relativePoint = param_3; // +0x10
|
||||
anchor->vtable = &PTR_0081c44c; // +0x00
|
||||
|
||||
// Store in frame's anchor array
|
||||
frame_anchors[pointEnum] = anchor; // frame + enum*4 + 0x28
|
||||
```
|
||||
|
||||
### Anchor Destruction
|
||||
When a frame is destroyed (`DestroyFrame` 0x773240 / `DestroyFrameScriptObject` 0x4c34a0):
|
||||
- The frame's own anchors are cleaned up
|
||||
- But **no notification is sent to OTHER frames whose anchors reference this frame**
|
||||
- Result: dangling pointer at `anchor+0x0C`
|
||||
|
||||
---
|
||||
|
||||
## Root Cause
|
||||
|
||||
**The anchor stores a raw pointer to the relativeTo frame with no weak reference or
|
||||
invalidation mechanism.** When the relativeTo frame is destroyed:
|
||||
|
||||
1. Frame memory is freed (and potentially decommitted by the OS)
|
||||
2. The anchor's `relativeTo` pointer at +0x0C is NOT cleared
|
||||
3. Next time `GetPoint()` is called, `GetRelativeTo()` returns the stale pointer
|
||||
4. The code dereferences it → ACCESS_VIOLATION
|
||||
|
||||
---
|
||||
|
||||
## Relevant WoW Functions & Globals
|
||||
|
||||
### Frame Validation
|
||||
| Address | Name | Purpose |
|
||||
|---------|------|---------|
|
||||
| 0x787910 | `ValidateFrameTypePointer` | Compares value against `g_ParentFrameTypeID`, `g_FrameTypeID` |
|
||||
| 0x789480 | `ValidatePointerType` | Same pattern, different type set |
|
||||
| 0x403f50 | `ValidateObjectPointer` | Calls `IsBadReadPtr` via `[0x007ff2b8]` |
|
||||
| 0x4c38a0 | `FrameScript_ValidateMemory` | Global memory validation |
|
||||
|
||||
### Type ID Globals (runtime values, .bss — not readable from Ghidra)
|
||||
| Address | Name |
|
||||
|---------|------|
|
||||
| 0x00cf0c10 | `g_ParentFrameTypeID` |
|
||||
| 0x00cf0c3c | `g_FrameTypeID` |
|
||||
| 0x00cf4f2c | Frame type ID (specific subclass) |
|
||||
| 0x00cf4f48 | Frame type ID (specific subclass) |
|
||||
|
||||
### Frame Lifecycle
|
||||
| Address | Name |
|
||||
|---------|------|
|
||||
| 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 |
|
||||
|
||||
### 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 |
|
||||
|
||||
### Windows API
|
||||
| IAT Address | API |
|
||||
|-------------|-----|
|
||||
| 0x007ff2b8 | `IsBadReadPtr` (used by `ValidateObjectPointer`) |
|
||||
|
||||
---
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Inline Hook at Crash Site (minimal, symptom fix)
|
||||
Patch 6 bytes at `0x007a244f` (MOV EBX,[EBP-4] + MOV EAX,[EBX+4]):
|
||||
```
|
||||
Original: 8B 5D FC 8B 43 04 → JMP trampoline + NOP
|
||||
```
|
||||
Trampoline:
|
||||
1. `MOV EBX,[EBP-4]` (original)
|
||||
2. `TEST EBX,EBX` / `JZ no_relative` (→ 0x007a24d7)
|
||||
3. Validate EBX via `IsBadReadPtr([0x007ff2b8])` or VirtualQuery
|
||||
4. If invalid → JMP 0x007a24d7 (safe path)
|
||||
5. If valid → `MOV EAX,[EBX+4]` + JMP 0x007a2455
|
||||
|
||||
**Pro**: Minimal patch, only affects GetPoint
|
||||
**Con**: Only fixes this one crash site; other code using GetRelativeTo is still vulnerable
|
||||
|
||||
### Option B: Hook Anchor vtable[3] (GetRelativeTo)
|
||||
Replace function pointer at `0x0081c458` with our own GetRelativeTo:
|
||||
1. Read `this+0x0C` (relativeTo pointer)
|
||||
2. If NULL → return NULL
|
||||
3. Call `IsBadReadPtr(ptr, 0x10)` via `[0x007ff2b8]`
|
||||
4. If bad → clear `this+0x0C` to NULL, return NULL
|
||||
5. If good → return original value
|
||||
|
||||
**Pro**: Protects ALL callers of GetRelativeTo, self-healing (clears stale ptr)
|
||||
**Con**: Modifies vtable in .rdata (need VirtualProtect), slight overhead on every anchor access
|
||||
|
||||
### Option C: Hook Frame Destruction (root cause fix)
|
||||
Hook `DestroyFrameScriptObject` (0x4c34a0) to scan all frames' anchors and
|
||||
clear any that reference the frame being destroyed.
|
||||
|
||||
**Pro**: Fixes the root cause, no runtime overhead on GetPoint
|
||||
**Con**: Complex, needs to iterate all frames, frame list structure must be understood
|
||||
|
||||
### Recommended: Option B ← IMPLEMENTED
|
||||
The vtable hook is the best balance of robustness and simplicity. It:
|
||||
- Uses WoW's own `IsBadReadPtr` import (no new API dependencies)
|
||||
- Self-heals by NULLing the stale pointer on first detection
|
||||
- Protects all code paths, not just GetPoint
|
||||
- The vtable is a single pointer write (after VirtualProtect)
|
||||
|
||||
Implementation: `src/framecrash/framecrash.zig`
|
||||
|
||||
---
|
||||
|
||||
## Second Crash: `luaGetWidth` NULL Layout Pointer (0x007A2FB8)
|
||||
|
||||
**NOT related to the anchor/GetRelativeTo fix above.** This is a separate bug.
|
||||
|
||||
Crash log: `/media/bigfaststore/games/twmoa_1172/Errors/2026-02-27 12.37.03 Crash.txt`
|
||||
|
||||
### Crash Details
|
||||
```
|
||||
Exception: 0xC0000005 (ACCESS_VIOLATION) at 0023:007A2FB8
|
||||
The instruction at "0x007A2FB8" referenced memory at "0x0000003C".
|
||||
The memory could not be "read".
|
||||
```
|
||||
|
||||
### Registers
|
||||
```
|
||||
EAX=3F4F5C29 EBX=0081C3D0 ECX=00000000 EDX=0081C44C
|
||||
ESI=16D046A8 EDI=17077C2C EBP=00F2F7EC ESP=00F2F7D8
|
||||
```
|
||||
|
||||
### Crash Instruction
|
||||
```
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
### Call Chain
|
||||
```
|
||||
GetAnimationSmoothing (0x768d20)
|
||||
→ 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
|
||||
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
|
||||
0x007a2faa: MOV [EBP-8], 0x0
|
||||
0x007a2fb1: MOV [EBP-4], 0x0
|
||||
0x007a2fb8: TEST byte ptr [ECX+0x3C], 0x02 ← CRASH (ECX=NULL)
|
||||
0x007a2fbc: JZ 0x007a2fc5 ; skip if flag not set
|
||||
0x007a2fbe: PUSH 0x0
|
||||
0x007a2fc0: CALL 0x00768060
|
||||
```
|
||||
|
||||
### Caller (0x007673E7)
|
||||
```asm
|
||||
0x007673e7: PUSH 0x81c3d0 ; NOT the anchor vtable (0x81c44c)
|
||||
0x007673ec: MOV ECX, ESI ; frame object
|
||||
0x007673ee: MOV [ESI+0x28], EAX ; store layout result
|
||||
0x007673f1: CALL SetFrameHitTestMode (0x7671a0)
|
||||
```
|
||||
|
||||
### Full Disassembly (luaGetWidth entry → crash)
|
||||
```asm
|
||||
0x007a2f90: PUSH EBP
|
||||
0x007a2f91: MOV EBP, ESP
|
||||
0x007a2f93: SUB ESP, 0x10
|
||||
0x007a2f96: PUSH ESI
|
||||
0x007a2f97: MOV ESI, ECX ; save original this (anchor) to ESI
|
||||
0x007a2f99: MOV ECX, [ESI+0xC] ; ECX = anchor->relativeTo (NULL!)
|
||||
0x007a2f9c: MOV [EBP-0x10], 0x0
|
||||
0x007a2fa3: MOV [EBP-0xC], 0x0
|
||||
0x007a2faa: MOV [EBP-0x8], 0x0
|
||||
0x007a2fb1: MOV [EBP-0x4], 0x0
|
||||
0x007a2fb8: TEST byte ptr [ECX+0x3C], 0x02 ← CRASH (ECX=NULL from ESI+0xC)
|
||||
```
|
||||
|
||||
### SetFrameHitTestMode Call Site
|
||||
```asm
|
||||
0x007671b4: MOV EAX, [EBX+ESI*4] ; load index from array at 0x81c3d0
|
||||
0x007671b7: MOV ECX, [EDI+EAX*4+0x4] ; load anchor from frame[index]
|
||||
0x007671bb: TEST ECX, ECX ; NULL check on ANCHOR (not relativeTo)
|
||||
0x007671bd: JZ skip
|
||||
0x007671bf: MOV EAX, [EDI+0x58] ; load frame->field_0x58
|
||||
0x007671c2: MOV EDX, [ECX] ; EDX = anchor vtable (0x81c44c)
|
||||
0x007671c4: PUSH EAX ; push param
|
||||
0x007671c5: CALL [EDX+0x4] ; call vtable[1] = luaGetWidth
|
||||
0x007671c8: FLD ST0 ; duplicate float return
|
||||
0x007671ca: FCOMP [0x00cf550c] ; compare with sentinel
|
||||
```
|
||||
|
||||
### Root Cause (CONFIRMED)
|
||||
|
||||
`SetFrameHitTestMode` checks that the anchor object is non-NULL, but
|
||||
`luaGetWidth` / `luaGetHeight` read `anchor+0x0C` (the relativeTo pointer)
|
||||
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:
|
||||
`[0, 3, 6]`, used by the width layout pass. `SetFrameHitTestMode` iterates these
|
||||
indices to look up anchors from the frame's anchor array.
|
||||
|
||||
### Anchor Vtable (0x0081C44C) — Full Layout
|
||||
```
|
||||
[0] +0x00 = 0x00767d80 → GetAnimationOrder (destructor)
|
||||
[1] +0x04 = 0x007a2f90 → luaGetWidth ← CRASH FUNCTION
|
||||
[2] +0x08 = 0x007a3070 → luaGetHeight ← SAME VULNERABILITY
|
||||
[3] +0x0C = 0x00767d70 → GetRelativeTo (already hooked)
|
||||
```
|
||||
|
||||
### 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)
|
||||
2. The functions have side effects (IsAnimationDone, GetAnimationTarget calls)
|
||||
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
|
||||
is masking the dangling pointer but exposing it as a NULL pointer to other code.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis: Frame Destruction Missing Anchor Cleanup
|
||||
|
||||
### Dependency Tracking System
|
||||
|
||||
WoW's frame system tracks anchor dependencies via `PauseAnimationGroup` /
|
||||
`ResumeAnimationGroup`:
|
||||
|
||||
**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
|
||||
- If owner already in list, ORs in the new bitmask bits
|
||||
|
||||
**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`
|
||||
- If bitmask reaches 0, unlinks and frees the node
|
||||
|
||||
### Proper Anchor Cleanup (exists but not called on destruction)
|
||||
|
||||
**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
|
||||
if anchor != NULL:
|
||||
relativeTo = anchor->vtable[3]() // GetRelativeTo
|
||||
if relativeTo != NULL:
|
||||
ResumeAnimationGroup(relativeTo, frame, 1 << i) // unregister dependency
|
||||
anchor->vtable[0](1) // destructor
|
||||
*(frame + i*4 + 4) = 0
|
||||
```
|
||||
|
||||
Called from: `SetAnimationOrigin` (0x768e20), `StartAnimationGroup` (0x767db0),
|
||||
`GetAnimationEndDelay` (0x768430) — **never during frame destruction**.
|
||||
|
||||
### Frame Destruction Chain
|
||||
|
||||
```
|
||||
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)
|
||||
^ MISSING: cleanup of OTHER frames' anchors pointing to this frame
|
||||
-> unlinks from various lists
|
||||
-> if (free_flag & 1): FreeMemory(frame)
|
||||
```
|
||||
|
||||
Also called from: `CleanupRegion` (0x76c560), `cleanupGraphicsResources` (0x764390)
|
||||
|
||||
### The Bug
|
||||
|
||||
When frame B is destroyed:
|
||||
1. `cleanup_array_of_objects(B)` cleans up B's own anchors (calls
|
||||
ResumeAnimationGroup on each of B's relativeTo frames) ✓
|
||||
2. **MISSING**: Nobody walks B's dependency list at B+0x30/0x34 to clean up
|
||||
other frames' anchors that reference B ✗
|
||||
3. Frame A's anchor still has `anchor+0xC = B` (now freed/stale)
|
||||
4. Any code that reads the anchor's relativeTo → crash
|
||||
|
||||
### Proposed Root Cause Fix
|
||||
|
||||
Hook `cleanup_linked_list_structures` (0x767720). Before calling the original:
|
||||
1. Walk the dependency list at `dying_frame+0x34`
|
||||
2. For each entry `(owner_frame, bitmask)`:
|
||||
- For each bit `i` in bitmask:
|
||||
- `anchor = *(owner_frame + i*4 + 4)`
|
||||
- If anchor != NULL and `*(anchor+0xC) == dying_frame`:
|
||||
- Call `anchor->vtable[0](1)` to destroy the anchor
|
||||
- `*(owner_frame + i*4 + 4) = 0` to clear the slot
|
||||
- Free the dependency list node
|
||||
3. Clear `dying_frame+0x30/0x34`
|
||||
4. Call original `cleanup_linked_list_structures`
|
||||
|
||||
This uses the game's own anchor destructor and cleans up the dependency list.
|
||||
The existing GetRelativeTo vtable hook can be kept as defense-in-depth.
|
||||
|
||||
### cleanup_linked_list_structures Prologue (for detour)
|
||||
```asm
|
||||
0x767720: 53 PUSH EBX ; 1 byte
|
||||
0x767721: 56 PUSH ESI ; 1 byte
|
||||
0x767722: 8B F1 MOV ESI, ECX ; 2 bytes
|
||||
0x767724: 57 PUSH EDI ; 1 byte
|
||||
0x767725: C7 06 00 C4 81 00 MOV [ESI], 0x81c400 ; 6 bytes (vtable set)
|
||||
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.
|
||||
@@ -1,5 +1,202 @@
|
||||
//! Framecrash fix module.
|
||||
//!
|
||||
//! Reference crash log: /media/bigfaststore/games/twmoa_1172/Errors/2026-02-25 19.38.01 Crash.txt
|
||||
//! Fixes ACCESS_VIOLATION crashes caused by UI frame anchor objects holding raw
|
||||
//! pointers to their "relativeTo" frame (anchor+0x0C). When the relativeTo frame
|
||||
//! is destroyed, the pointer is never cleared, causing crashes in any code that
|
||||
//! dereferences it (luaGetPoint, luaGetWidth, luaGetHeight, etc).
|
||||
//!
|
||||
//! Root cause fix: detour hook on cleanup_linked_list_structures (0x767720),
|
||||
//! the common frame destruction path. Before the original runs, we walk the
|
||||
//! dying frame's PauseAnimationGroup dependency list (frame+0x34) to find all
|
||||
//! other frames whose anchors reference the dying frame. For each, we destroy
|
||||
//! the anchor and NULL the slot, preventing any stale/NULL pointer dereferences.
|
||||
//!
|
||||
//! Defense-in-depth: vtable[3] (GetRelativeTo) hook validates returned pointers
|
||||
//! with IsBadReadPtr, catching any cases the root cause fix misses.
|
||||
//!
|
||||
//! See RESEARCH.md for full reverse engineering notes.
|
||||
|
||||
//We are finally going to solve: The instruction at "0x007A2452" referenced memory at "0x16C0FFE8".
|
||||
const std = @import("std");
|
||||
const hook = @import("hook");
|
||||
const con = @import("../console.zig");
|
||||
|
||||
const WINAPI = std.builtin.CallingConvention.winapi;
|
||||
const THISCALL = std.builtin.CallingConvention{ .x86_thiscall = .{} };
|
||||
|
||||
extern "kernel32" fn IsBadReadPtr(lp: ?*const anyopaque, ucb: usize) callconv(WINAPI) i32;
|
||||
|
||||
// =============================================================================
|
||||
// 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)
|
||||
// +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)
|
||||
// =============================================================================
|
||||
|
||||
const ANCHOR_VTABLE_ADDR: usize = 0x0081c44c;
|
||||
const GET_RELATIVE_TO_SLOT: usize = ANCHOR_VTABLE_ADDR + 0x0C; // vtable[3]
|
||||
|
||||
// =============================================================================
|
||||
// Root cause fix: hook frame destruction to clean up reverse anchor references
|
||||
//
|
||||
// cleanup_linked_list_structures (0x767720) is the common frame cleanup path,
|
||||
// called from destroy_object, CleanupRegion, and cleanupGraphicsResources.
|
||||
// It cleans up the dying frame's OWN anchors (forward direction) but NOT other
|
||||
// frames' anchors that reference the dying frame (reverse direction).
|
||||
//
|
||||
// PauseAnimationGroup maintains a linked list on the relativeTo frame:
|
||||
// frame+0x34 → first node
|
||||
// Each node (0x10 bytes): [link0, next(+4), owner_frame(+8), bitmask(+C)]
|
||||
// bitmask = OR of (1 << anchor_point_enum) for each referencing anchor slot
|
||||
//
|
||||
// Anchor slots in a frame: frame + point_enum*4 + 4 (9 slots, enum 0..8)
|
||||
//
|
||||
// Prologue at 0x767720 (5 bytes, no rel32):
|
||||
// 53 56 8B F1 57 = PUSH EBX; PUSH ESI; MOV ESI,ECX; PUSH EDI
|
||||
// =============================================================================
|
||||
|
||||
const CLEANUP_TARGET: usize = 0x767720;
|
||||
const CLEANUP_PROLOGUE_SIZE: usize = 5;
|
||||
|
||||
/// FreeMemory — __stdcall(ptr, source_string, flags)
|
||||
const FreeMemory = @as(*const fn (u32, u32, u32) callconv(WINAPI) void, @ptrFromInt(0x646430));
|
||||
const CLAYOUT_FRAME_STR: u32 = 0x878540; // "...CLayoutFrame..."
|
||||
|
||||
var cleanup_hook: hook.Hook = .{};
|
||||
|
||||
/// 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 {
|
||||
cleanupReverseDependencies(frame);
|
||||
|
||||
// Call original cleanup_linked_list_structures via trampoline
|
||||
const orig = cleanup_hook.getTrampoline(*const fn (u32) callconv(THISCALL) void);
|
||||
orig(frame);
|
||||
}
|
||||
|
||||
/// Walk the PauseAnimationGroup dependency list on the dying frame and destroy
|
||||
/// all anchors from other frames that reference it.
|
||||
fn cleanupReverseDependencies(dying_frame: u32) void {
|
||||
// Read first node from dying_frame+0x34
|
||||
var node: u32 = readAligned(dying_frame + 0x34);
|
||||
|
||||
// Validate: odd pointer or zero means empty list
|
||||
if (node == 0 or (node & 1) != 0) return;
|
||||
|
||||
var cleaned: u32 = 0;
|
||||
|
||||
while (node != 0 and (node & 1) == 0) {
|
||||
// Save next pointer before we potentially free this node
|
||||
const next: u32 = readAligned(node + 0x04);
|
||||
const owner_frame: u32 = readAligned(node + 0x08);
|
||||
const bitmask: u32 = readAligned(node + 0x0C);
|
||||
|
||||
if (owner_frame != 0) {
|
||||
// For each bit set in bitmask, destroy the corresponding anchor
|
||||
var bit: u5 = 0;
|
||||
while (bit < 9) : (bit += 1) {
|
||||
if ((bitmask & (@as(u32, 1) << bit)) == 0) continue;
|
||||
|
||||
const anchor_slot_addr = owner_frame + @as(u32, bit) * 4 + 4;
|
||||
const anchor: u32 = readAligned(anchor_slot_addr);
|
||||
if (anchor == 0) continue;
|
||||
|
||||
// Verify this anchor actually references the dying frame
|
||||
const relativeTo: u32 = readAligned(anchor + 0x0C);
|
||||
if (relativeTo != dying_frame) continue;
|
||||
|
||||
// Call anchor destructor: vtable[0](1) — thiscall with flag=1 (free)
|
||||
const vtable: u32 = readAligned(anchor);
|
||||
const dtor_addr: u32 = readAligned(vtable);
|
||||
const dtor: *const fn (u32, u32) callconv(THISCALL) void = @ptrFromInt(dtor_addr);
|
||||
dtor(anchor, 1);
|
||||
|
||||
// NULL the anchor slot in the owner frame
|
||||
const slot: *align(1) u32 = @ptrFromInt(anchor_slot_addr);
|
||||
slot.* = 0;
|
||||
|
||||
cleaned += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Free the dependency list node
|
||||
FreeMemory(node, CLAYOUT_FRAME_STR, 0xfffffffe);
|
||||
|
||||
node = next;
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
con.fmt("[framecrash] Cleaned {d} stale anchor(s) referencing dying frame 0x{x:0>8}\n", .{ cleaned, dying_frame });
|
||||
}
|
||||
|
||||
// Clear the list head so the original cleanup doesn't see stale nodes
|
||||
const head: *align(1) u32 = @ptrFromInt(dying_frame + 0x30);
|
||||
head.* = 0;
|
||||
const tail: *align(1) u32 = @ptrFromInt(dying_frame + 0x34);
|
||||
tail.* = 0;
|
||||
}
|
||||
|
||||
fn readAligned(addr: u32) u32 {
|
||||
return @as(*align(1) const u32, @ptrFromInt(addr)).*;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Defense-in-depth: GetRelativeTo vtable hook
|
||||
// =============================================================================
|
||||
|
||||
var orig_get_relative_to: usize = 0;
|
||||
|
||||
/// Replacement for the anchor's GetRelativeTo virtual function.
|
||||
/// Validates the stored relativeTo pointer before returning it.
|
||||
/// If the pointer is stale (freed/decommitted memory), NULLs it out
|
||||
/// and returns 0 so the caller takes the safe "no relativeTo" code path.
|
||||
fn getRelativeToHook(this: u32) callconv(THISCALL) u32 {
|
||||
// Call the original GetRelativeTo to get the stored pointer
|
||||
const orig: *const fn (u32) callconv(THISCALL) u32 = @ptrFromInt(orig_get_relative_to);
|
||||
const result = orig(this);
|
||||
|
||||
if (result == 0) return 0;
|
||||
|
||||
// Validate: the returned pointer is an inner offset into the frame object.
|
||||
// The caller (luaGetPoint) subtracts 0x24 to get the frame base, then reads
|
||||
// at +0x00 (vtable), +0x04 (lua ref), +0x08 (lua ref index).
|
||||
// Check that the frame base region is readable.
|
||||
if (IsBadReadPtr(@ptrFromInt(result -% 0x24), 0x10) != 0) {
|
||||
con.fmt("[framecrash] Stale relativeTo ptr 0x{x:0>8} in anchor 0x{x:0>8} — nulled\n", .{ result, this });
|
||||
|
||||
// Self-heal: clear the dangling pointer in the anchor object
|
||||
const field: *align(1) u32 = @ptrFromInt(this + 0x0C);
|
||||
field.* = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Module API
|
||||
// =============================================================================
|
||||
|
||||
pub fn installHooks() void {
|
||||
// Root cause fix: 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), &.{})) {
|
||||
// con.print("[framecrash] ERROR: Failed to install frame cleanup detour\n");
|
||||
// } else {
|
||||
// con.print("[framecrash] Frame cleanup detour installed\n");
|
||||
// }
|
||||
}
|
||||
|
||||
pub fn removeHooks() void {
|
||||
cleanup_hook.remove();
|
||||
con.print("[framecrash] Frame cleanup detour removed\n");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const hook = @import("hook");
|
||||
const con = @import("../console.zig");
|
||||
|
||||
const WINAPI = @import("std").builtin.CallingConvention.winapi;
|
||||
extern "kernel32" fn GetTickCount() callconv(WINAPI) u32;
|
||||
@@ -400,6 +401,7 @@ fn callOriginalSceneEnd(device: u32) void {
|
||||
// =============================================================================
|
||||
|
||||
pub fn installHooks() void {
|
||||
con.print("[interact] Module loaded\n");
|
||||
// SceneEnd — per-frame loot queue processing
|
||||
// Uses thunk: __fastcall(ECX=device, EDX) → cdecl(device, edx)
|
||||
if (scene_end_hook.prepare(Offsets.ADDR_SceneEnd, 9, &.{})) {
|
||||
|
||||
+413
-4
@@ -8,6 +8,8 @@ const build_opts = struct {
|
||||
const interact = @import("build_options").enable_interact;
|
||||
const outline = @import("build_options").enable_outline;
|
||||
const markers = @import("build_options").enable_markers;
|
||||
const framecrash = @import("build_options").enable_framecrash;
|
||||
const combatlog = @import("build_options").enable_combatlog;
|
||||
};
|
||||
|
||||
// Conditional module imports
|
||||
@@ -15,6 +17,8 @@ const screenshot = if (build_opts.screenshot) @import("screenshot/screenshot.zig
|
||||
const interact = if (build_opts.interact) @import("interact/interact.zig") else struct {};
|
||||
const outline = if (build_opts.outline) @import("outline/api.zig") else struct {};
|
||||
const markers = if (build_opts.markers) @import("markers/markers.zig") else struct {};
|
||||
const framecrash = if (build_opts.framecrash) @import("framecrash/framecrash.zig") else struct {};
|
||||
const combatlog = if (build_opts.combatlog) @import("combatlog/combatlog.zig") else struct {};
|
||||
|
||||
const WINAPI = std.builtin.CallingConvention.winapi;
|
||||
const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
|
||||
@@ -300,9 +304,47 @@ const markers_files = if (build_opts.markers) [_]FileEntry{
|
||||
.{ .name = "Bindings.xml", .data = @embedFile("markers/addon/Bindings.xml") },
|
||||
} else [_]FileEntry{};
|
||||
|
||||
const markers_assets = if (build_opts.markers) [_]FileEntry{
|
||||
.{ .name = "xyz.m2", .data = @embedFile("markers/assets/World/ArtTest/Boxtest/xyz.m2") },
|
||||
.{ .name = "xyz.blp", .data = @embedFile("markers/assets/World/ArtTest/Boxtest/xyz.blp") },
|
||||
// Marker model + skin + textures served under Spells\ prefix
|
||||
const markers_spells_assets = if (build_opts.markers) [_]FileEntry{
|
||||
// Models (5 colors)
|
||||
.{ .name = "Raid_UI_FX_Yellow.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Yellow.m2") },
|
||||
.{ .name = "Raid_UI_FX_Cyan.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Cyan.m2") },
|
||||
.{ .name = "Raid_UI_FX_Green.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Green.m2") },
|
||||
.{ .name = "Raid_UI_FX_Purple.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Purple.m2") },
|
||||
.{ .name = "Raid_UI_FX_Red.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Red.m2") },
|
||||
// Skin files
|
||||
.{ .name = "Raid_UI_FX_Yellow00.skin", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Yellow00.skin") },
|
||||
.{ .name = "Raid_UI_FX_Cyan00.skin", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Cyan00.skin") },
|
||||
.{ .name = "Raid_UI_FX_Green00.skin", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Green00.skin") },
|
||||
.{ .name = "Raid_UI_FX_Purple00.skin", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Purple00.skin") },
|
||||
.{ .name = "Raid_UI_FX_Red00.skin", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Red00.skin") },
|
||||
// Per-model raid target icon textures
|
||||
.{ .name = "RaidTarget_Star.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_Star.blp") },
|
||||
.{ .name = "RaidTarget_Square.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_Square.blp") },
|
||||
.{ .name = "RaidTarget_Triangle.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_Triangle.blp") },
|
||||
.{ .name = "RaidTarget_Diamond.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_Diamond.blp") },
|
||||
.{ .name = "RaidTarget_X.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_X.blp") },
|
||||
// Shared effect textures
|
||||
.{ .name = "T_VFX_FLARE05_32ALPHA.BLP", .data = @embedFile("markers/assets/Spells/T_VFX_FLARE05_32ALPHA.BLP") },
|
||||
.{ .name = "GRAD2D.BLP", .data = @embedFile("markers/assets/Spells/GRAD2D.BLP") },
|
||||
.{ .name = "GRAD2C2.BLP", .data = @embedFile("markers/assets/Spells/GRAD2C2.BLP") },
|
||||
.{ .name = "NEXUS_FIREBEAM_FAINT_SQUARE_ORA.BLP", .data = @embedFile("markers/assets/Spells/NEXUS_FIREBEAM_FAINT_SQUARE_ORA.BLP") },
|
||||
} else [_]FileEntry{};
|
||||
|
||||
// Shared effect textures served under World\Expansion01\Doodads\Zulaman\Doors\ prefix
|
||||
const markers_world_assets = if (build_opts.markers) [_]FileEntry{
|
||||
.{ .name = "T_VFX_FIRE03_A.BLP", .data = @embedFile("markers/assets/World/Expansion01/Doodads/Zulaman/Doors/T_VFX_FIRE03_A.BLP") },
|
||||
.{ .name = "T_VFX_BORDER6.BLP", .data = @embedFile("markers/assets/World/Expansion01/Doodads/Zulaman/Doors/T_VFX_BORDER6.BLP") },
|
||||
} else [_]FileEntry{};
|
||||
|
||||
// XYZ debug model (renamed to avoid collision with game's built-in xyz.m2)
|
||||
const markers_xyz_model = if (build_opts.markers) [_]FileEntry{
|
||||
.{ .name = "WU_XYZ.m2", .data = @embedFile("markers/assets/Spells/WU_XYZ.m2") },
|
||||
} else [_]FileEntry{};
|
||||
|
||||
// XYZ texture served under World\ArtTest\Boxtest\ (matches M2 internal reference)
|
||||
const markers_xyz_texture = if (build_opts.markers) [_]FileEntry{
|
||||
.{ .name = "xyz.blp", .data = @embedFile("markers/assets/Spells/xyz.blp") },
|
||||
} else [_]FileEntry{};
|
||||
|
||||
// All addon prefixes to check
|
||||
@@ -312,7 +354,10 @@ const addon_prefixes = [_]AddonPrefix{
|
||||
.{ .prefix = "Interface\\AddOns\\Interact\\", .files = &interact_files },
|
||||
.{ .prefix = "Interface\\AddOns\\Outline\\", .files = &outline_files },
|
||||
.{ .prefix = "Interface\\AddOns\\Markers\\", .files = &markers_files },
|
||||
.{ .prefix = "World\\ArtTest\\Boxtest\\", .files = &markers_assets },
|
||||
.{ .prefix = "Spells\\", .files = &markers_spells_assets },
|
||||
.{ .prefix = "Spells\\", .files = &markers_xyz_model },
|
||||
.{ .prefix = "World\\Expansion01\\Doodads\\Zulaman\\Doors\\", .files = &markers_world_assets },
|
||||
.{ .prefix = "World\\ArtTest\\Boxtest\\", .files = &markers_xyz_texture },
|
||||
};
|
||||
|
||||
fn findEmbeddedFile(path: [*:0]const u8) ?*const FileEntry {
|
||||
@@ -389,6 +434,355 @@ fn loadFileDetour(
|
||||
return orig(unk, path, buf_out, size_out, extra_alloc, flags, async_ptr);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// In-memory file serving hooks (Storm file I/O layer)
|
||||
// =============================================================================
|
||||
//
|
||||
// M2 model loading bypasses our LoadFileWithTextureResourceFallback hook.
|
||||
// These hooks intercept at the lower Storm file I/O level (openFileWithOptions
|
||||
// and friends) to create fake file contexts for embedded files and serve data
|
||||
// from memory.
|
||||
//
|
||||
// Fake context detection: type==0, handle(+0x04)==NULL, embedded_ptr(+0x30)!=0
|
||||
|
||||
var open_file_hook: hook.Hook = .{};
|
||||
var get_file_size_hook: hook.Hook = .{};
|
||||
var read_file_hook: hook.Hook = .{};
|
||||
var cleanup_file_handle_hook: hook.Hook = .{};
|
||||
var process_async_hook: hook.Hook = .{};
|
||||
var model_load_hook: hook.Hook = .{};
|
||||
|
||||
// Windows API imports for async handling
|
||||
extern "kernel32" fn EnterCriticalSection(lpCriticalSection: *anyopaque) callconv(WINAPI) void;
|
||||
extern "kernel32" fn LeaveCriticalSection(lpCriticalSection: *anyopaque) callconv(WINAPI) void;
|
||||
extern "kernel32" fn SetEvent(hEvent: *anyopaque) callconv(WINAPI) i32;
|
||||
|
||||
/// Check if a file context is one of our fakes
|
||||
fn isFakeFileContext(ctx_addr: u32) bool {
|
||||
if (ctx_addr < 0x10000) return false;
|
||||
return hook.readMem(u32, ctx_addr) == 0 and // type == 0 (disk)
|
||||
hook.readMem(u32, ctx_addr + 0x04) == 0 and // handle == NULL
|
||||
hook.readMem(u32, ctx_addr + 0x30) != 0; // embedded ptr set
|
||||
}
|
||||
|
||||
/// Call initializeFileContext (0x647290) — __thiscall(ECX=ctx, type)
|
||||
fn callInitFileContext(ctx: [*]u8, file_type: u32) void {
|
||||
asm volatile (
|
||||
\\push %[ftype]
|
||||
\\call *%[func]
|
||||
:
|
||||
: [_] "{ecx}" (@intFromPtr(ctx)),
|
||||
[ftype] "r" (file_type),
|
||||
[func] "r" (@as(u32, 0x647290)),
|
||||
: .{ .eax = true, .edx = true, .memory = true, .cc = true }
|
||||
);
|
||||
}
|
||||
|
||||
/// Call cleanupFileContext (0x6472d0) — __thiscall(ECX=ctx)
|
||||
fn callCleanupFileContext(ctx: [*]u8) void {
|
||||
asm volatile (
|
||||
\\call *%[func]
|
||||
:
|
||||
: [_] "{ecx}" (@intFromPtr(ctx)),
|
||||
[func] "r" (@as(u32, 0x6472d0)),
|
||||
: .{ .eax = true, .edx = true, .memory = true, .cc = true }
|
||||
);
|
||||
}
|
||||
|
||||
/// Free a buffer via FreeMemory/SMemFree (0x646430) — __stdcall(ptr, src, flags)
|
||||
fn freeGameBuffer(ptr: [*]u8) void {
|
||||
asm volatile (
|
||||
\\push $0xffffffff
|
||||
\\push %[src]
|
||||
\\push %[ptr]
|
||||
\\call *%[func]
|
||||
:
|
||||
: [ptr] "r" (@intFromPtr(ptr)),
|
||||
[src] "r" (@intFromPtr(@as([*:0]const u8, "weirdutils"))),
|
||||
[func] "r" (@as(u32, 0x646430)),
|
||||
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
|
||||
);
|
||||
}
|
||||
|
||||
// --- Hook 1: openFileWithOptions (0x6477c0) ---
|
||||
|
||||
fn openFileDetour(
|
||||
archive_ptr: u32,
|
||||
path: [*:0]const u8,
|
||||
flags: u32,
|
||||
handle_out: *u32,
|
||||
) callconv(sc) u32 {
|
||||
if (findEmbeddedFile(path)) |entry| {
|
||||
// Allocate and zero-fill 0x60-byte file context
|
||||
const ctx = allocateGameBuffer(0x60) orelse {
|
||||
handle_out.* = 0;
|
||||
return 0;
|
||||
};
|
||||
@memset(ctx[0..0x60], 0);
|
||||
|
||||
// Initialize critical section and set type=0
|
||||
callInitFileContext(ctx, 0);
|
||||
|
||||
// Store embedded data pointer and size in unused fields
|
||||
@as(*align(1) u32, @ptrFromInt(@intFromPtr(ctx) + 0x30)).* = @intCast(@intFromPtr(entry.data.ptr));
|
||||
@as(*align(1) u32, @ptrFromInt(@intFromPtr(ctx) + 0x34)).* = @intCast(entry.data.len);
|
||||
|
||||
// Duplicate path string at +0x0C for game's use
|
||||
const path_span = std.mem.span(path);
|
||||
const path_len: u32 = @intCast(path_span.len + 1);
|
||||
if (allocateGameBuffer(path_len)) |path_buf| {
|
||||
@memcpy(path_buf[0..path_span.len], path_span);
|
||||
path_buf[path_span.len] = 0;
|
||||
@as(*align(1) u32, @ptrFromInt(@intFromPtr(ctx) + 0x0C)).* = @intFromPtr(path_buf);
|
||||
}
|
||||
|
||||
handle_out.* = @intFromPtr(ctx);
|
||||
con.fmt("[file] fake ctx: {s} ({d} bytes)\n", .{ path_span, entry.data.len });
|
||||
return 2; // success (non-zero type code)
|
||||
}
|
||||
|
||||
const orig = open_file_hook.getTrampoline(
|
||||
*const fn (u32, [*:0]const u8, u32, *u32) callconv(sc) u32,
|
||||
);
|
||||
return orig(archive_ptr, path, flags, handle_out);
|
||||
}
|
||||
|
||||
// --- Hook 2: GetFileSizeFromHandle (0x6487f0) ---
|
||||
|
||||
fn getFileSizeDetour(
|
||||
file_ctx: u32,
|
||||
high_size_out: ?*u32,
|
||||
) callconv(sc) u32 {
|
||||
if (isFakeFileContext(file_ctx)) {
|
||||
if (high_size_out) |h| h.* = 0;
|
||||
return hook.readMem(u32, file_ctx + 0x34);
|
||||
}
|
||||
|
||||
const orig = get_file_size_hook.getTrampoline(
|
||||
*const fn (u32, ?*u32) callconv(sc) u32,
|
||||
);
|
||||
return orig(file_ctx, high_size_out);
|
||||
}
|
||||
|
||||
// --- Hook 3: ReadFileFromMultipleSources (0x648460) ---
|
||||
|
||||
fn readFileDetour(
|
||||
ctx: u32,
|
||||
buffer: [*]u8,
|
||||
size: u32,
|
||||
bytes_read_out: ?*u32,
|
||||
async_ptr: u32,
|
||||
param6: u32,
|
||||
) callconv(sc) u32 {
|
||||
if (isFakeFileContext(ctx)) {
|
||||
const data_ptr = hook.readMem(u32, ctx + 0x30);
|
||||
const data_size = hook.readMem(u32, ctx + 0x34);
|
||||
const read_size = @min(size, data_size);
|
||||
|
||||
const src: [*]const u8 = @ptrFromInt(data_ptr);
|
||||
@memcpy(buffer[0..read_size], src[0..read_size]);
|
||||
|
||||
if (bytes_read_out) |out| out.* = read_size;
|
||||
|
||||
// If async, signal the completion event immediately
|
||||
if (async_ptr != 0) {
|
||||
const event_handle = hook.readMem(u32, async_ptr + 4);
|
||||
if (event_handle != 0) {
|
||||
_ = SetEvent(@ptrFromInt(event_handle));
|
||||
}
|
||||
}
|
||||
|
||||
return 1; // success
|
||||
}
|
||||
|
||||
const orig = read_file_hook.getTrampoline(
|
||||
*const fn (u32, [*]u8, u32, ?*u32, u32, u32) callconv(sc) u32,
|
||||
);
|
||||
return orig(ctx, buffer, size, bytes_read_out, async_ptr, param6);
|
||||
}
|
||||
|
||||
// --- Hook 4: processAsyncFileOperation (0x647350) ---
|
||||
|
||||
fn processAsyncDetour(param1: u32, _edx: u32) callconv(.c) void {
|
||||
_ = _edx;
|
||||
const ctx_addr = hook.readMem(u32, param1 + 0x08);
|
||||
|
||||
if (isFakeFileContext(ctx_addr)) {
|
||||
// Enter critical section (same as original prologue)
|
||||
EnterCriticalSection(@ptrFromInt(ctx_addr + 0x24));
|
||||
|
||||
// Read request fields
|
||||
const dest = hook.readMem(u32, param1 + 0x0C);
|
||||
const req_size = hook.readMem(u32, param1 + 0x10);
|
||||
const data_ptr = hook.readMem(u32, ctx_addr + 0x30);
|
||||
const data_size = hook.readMem(u32, ctx_addr + 0x34);
|
||||
const read_size = @min(req_size, data_size);
|
||||
|
||||
// Copy embedded data to destination buffer
|
||||
const src: [*]const u8 = @ptrFromInt(data_ptr);
|
||||
const dst: [*]u8 = @ptrFromInt(dest);
|
||||
@memcpy(dst[0..read_size], src[0..read_size]);
|
||||
|
||||
// Replicate cleanup epilogue from original function:
|
||||
// 1. Decrement refcount at *(ctx + 0x5c)
|
||||
const rc_addr = ctx_addr + 0x5c;
|
||||
const cur_rc = hook.readMem(i32, rc_addr);
|
||||
@as(*align(1) i32, @ptrFromInt(rc_addr)).* = cur_rc - 1;
|
||||
|
||||
// 2. Leave critical section
|
||||
LeaveCriticalSection(@ptrFromInt(ctx_addr + 0x24));
|
||||
|
||||
// 3. Signal completion event: *(*(param1 + 0x14) + 4)
|
||||
const seek_struct = hook.readMem(u32, param1 + 0x14);
|
||||
if (seek_struct != 0) {
|
||||
const event_handle = hook.readMem(u32, seek_struct + 4);
|
||||
if (event_handle != 0) {
|
||||
_ = SetEvent(@ptrFromInt(event_handle));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If close-after-read flag at *(ctx + 0x58) is set, clean up
|
||||
const close_flag = hook.readMem(u32, ctx_addr + 0x58);
|
||||
if (close_flag != 0) {
|
||||
cleanupFileHandleDetour(ctx_addr);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Not our fake — call original via trampoline (__fastcall ECX=param1)
|
||||
hook.fastcall(void, process_async_hook.trampoline, param1, 0);
|
||||
}
|
||||
|
||||
// --- Hook 6: CleanupFileHandleResources (0x648730) ---
|
||||
|
||||
fn cleanupFileHandleDetour(file_ctx: u32) callconv(sc) void {
|
||||
if (isFakeFileContext(file_ctx)) {
|
||||
const ctx: [*]u8 = @ptrFromInt(file_ctx);
|
||||
|
||||
// cleanupFileContext frees +0x0C, +0x10, +0x18, +0x1C and destroys critical section.
|
||||
// Do NOT manually free +0x0C here — that would be a double-free.
|
||||
callCleanupFileContext(ctx);
|
||||
|
||||
// Free the 0x60-byte context block
|
||||
freeGameBuffer(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const orig = cleanup_file_handle_hook.getTrampoline(
|
||||
*const fn (u32) callconv(sc) void,
|
||||
);
|
||||
orig(file_ctx);
|
||||
}
|
||||
|
||||
// --- Hook 5: loadModelFromFileAsync (0x71d4e0) ---
|
||||
|
||||
fn loadModelAsyncDetour(model: u32, _edx: u32, file_handle: u32, should_use_callback: u32) callconv(.c) u32 {
|
||||
_ = _edx;
|
||||
|
||||
// Debug: log every call to confirm the hook is firing
|
||||
con.fmt("[file] loadModelAsync: model=0x{x} fh=0x{x} cb={d}\n", .{ model, file_handle, should_use_callback });
|
||||
con.fmt("[file] fh type={d} handle=0x{x} embed=0x{x} isFake={}\n", .{
|
||||
hook.readMem(u32, file_handle),
|
||||
hook.readMem(u32, file_handle + 0x04),
|
||||
hook.readMem(u32, file_handle + 0x30),
|
||||
isFakeFileContext(file_handle),
|
||||
});
|
||||
|
||||
// file_handle IS the file context address directly (Ghidra shows pointer* but
|
||||
// the assembly pushes it directly to GetFileSizeFromHandle — no dereference)
|
||||
if (isFakeFileContext(file_handle)) {
|
||||
const data_ptr = hook.readMem(u32, file_handle + 0x30);
|
||||
const data_size = hook.readMem(u32, file_handle + 0x34);
|
||||
con.fmt("[file] embed_ptr=0x{x} embed_size={d}\n", .{ data_ptr, data_size });
|
||||
|
||||
// Toggle callback flag (bit 1 of model+8) based on shouldUseCallback
|
||||
const flags = hook.readMem(u32, model + 0x08);
|
||||
if (should_use_callback != 0) {
|
||||
@as(*align(1) u32, @ptrFromInt(model + 0x08)).* = flags | 2;
|
||||
} else {
|
||||
@as(*align(1) u32, @ptrFromInt(model + 0x08)).* = flags & ~@as(u32, 2);
|
||||
}
|
||||
|
||||
// Store size in model first (original does this before allocation)
|
||||
@as(*align(1) u32, @ptrFromInt(model + 0x134)).* = data_size;
|
||||
|
||||
// Allocate buffer via setCullMode (0x71f9a0) — same as original path
|
||||
// setCullMode is __fastcall(ECX=size), returns buffer pointer
|
||||
const buffer_addr = hook.fastcall(u32, 0x71f9a0, data_size, 0);
|
||||
if (buffer_addr == 0) {
|
||||
con.print("[file] setCullMode alloc failed\n");
|
||||
return 0;
|
||||
}
|
||||
con.fmt("[file] buffer=0x{x}\n", .{buffer_addr});
|
||||
|
||||
// Store buffer in model object
|
||||
@as(*align(1) u32, @ptrFromInt(model + 0x130)).* = buffer_addr;
|
||||
|
||||
// Copy embedded data into the allocated buffer
|
||||
const buffer: [*]u8 = @ptrFromInt(buffer_addr);
|
||||
const src: [*]const u8 = @ptrFromInt(data_ptr);
|
||||
@memcpy(buffer[0..data_size], src[0..data_size]);
|
||||
con.print("[file] memcpy done\n");
|
||||
|
||||
// No async task — set task pointer to NULL
|
||||
@as(*align(1) u32, @ptrFromInt(model + 0x0c)).* = 0;
|
||||
con.print("[file] task=0 set\n");
|
||||
|
||||
// Skip cleanup for now (leaks 0x60 bytes) — investigate separately
|
||||
// cleanupFileHandleDetour(file_handle);
|
||||
con.print("[file] skipping cleanup (leak ok)\n");
|
||||
|
||||
// Call processLoadedModelData directly — __fastcall(ECX=model)
|
||||
con.fmt("[file] calling processLoadedModelData(0x{x})...\n", .{model});
|
||||
const result = hook.fastcall(u32, 0x71d640, model, 0);
|
||||
con.fmt("[file] processLoadedModelData returned 0x{x}\n", .{result});
|
||||
|
||||
con.fmt("[file] loadModelAsync: sync loaded {d} bytes\n", .{data_size});
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Not our fake — call original trampoline (__thiscall: ECX=model, stack: fileHandle, shouldUseCallback)
|
||||
return asm volatile (
|
||||
\\push %[cb]
|
||||
\\push %[fh]
|
||||
\\call *%[func]
|
||||
: [ret] "={eax}" (-> u32),
|
||||
: [_] "{ecx}" (model),
|
||||
[fh] "r" (file_handle),
|
||||
[cb] "r" (should_use_callback),
|
||||
[func] "r" (model_load_hook.trampoline),
|
||||
: .{ .edx = true, .memory = true, .cc = true }
|
||||
);
|
||||
}
|
||||
|
||||
// --- Install/remove in-memory file hooks ---
|
||||
|
||||
fn installFileHooks() void {
|
||||
_ = open_file_hook.install(0x6477c0, 9, @intFromPtr(&openFileDetour), &.{});
|
||||
_ = get_file_size_hook.install(0x6487f0, 6, @intFromPtr(&getFileSizeDetour), &.{});
|
||||
_ = read_file_hook.install(0x648460, 6, @intFromPtr(&readFileDetour), &.{});
|
||||
_ = cleanup_file_handle_hook.install(0x648730, 7, @intFromPtr(&cleanupFileHandleDetour), &.{});
|
||||
|
||||
// loadModelFromFileAsync is __thiscall(ECX=model, fileHandle, shouldUseCallback) — needs thunk bridge
|
||||
if (model_load_hook.prepare(0x71d4e0, 6, &.{})) {
|
||||
const thunk = model_load_hook.mem.? + 32;
|
||||
_ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&loadModelAsyncDetour), 2);
|
||||
model_load_hook.activate(@intFromPtr(thunk));
|
||||
}
|
||||
|
||||
con.print("[file] in-memory file hooks installed\n");
|
||||
}
|
||||
|
||||
fn removeFileHooks() void {
|
||||
model_load_hook.remove();
|
||||
process_async_hook.remove();
|
||||
cleanup_file_handle_hook.remove();
|
||||
read_file_hook.remove();
|
||||
get_file_size_hook.remove();
|
||||
open_file_hook.remove();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook: LoadScriptFunctions (0x490250)
|
||||
// =============================================================================
|
||||
@@ -555,9 +949,17 @@ fn install() void {
|
||||
con.init();
|
||||
con.print("[weirdutils] Installing hooks\n");
|
||||
_ = protection_hook.install(0x42a320, 6, @intFromPtr(&luaProtectionDetour), &.{});
|
||||
installFileHooks();
|
||||
_ = file_hook.install(0x648620, 6, @intFromPtr(&loadFileDetour), &.{});
|
||||
_ = lsf_hook.install(0x490250, 6, @intFromPtr(&loadScriptFunctionsDetour), &.{1});
|
||||
|
||||
if (build_opts.framecrash) {
|
||||
framecrash.installHooks();
|
||||
}
|
||||
if (build_opts.combatlog) {
|
||||
combatlog.installHooks();
|
||||
}
|
||||
|
||||
if (load_addons_hook.prepare(0x51F600, 7, &.{})) {
|
||||
const thunk = load_addons_hook.mem.? + 32;
|
||||
_ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&loadAddonsDetour), 0);
|
||||
@@ -589,10 +991,17 @@ fn uninstall() void {
|
||||
if (build_opts.interact) {
|
||||
interact.removeHooks();
|
||||
}
|
||||
if (build_opts.framecrash) {
|
||||
framecrash.removeHooks();
|
||||
}
|
||||
if (build_opts.combatlog) {
|
||||
combatlog.removeHooks();
|
||||
}
|
||||
|
||||
load_addons_hook.remove();
|
||||
lsf_hook.remove();
|
||||
file_hook.remove();
|
||||
removeFileHooks();
|
||||
protection_hook.remove();
|
||||
con.deinit();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# M2 Model Loading Research
|
||||
|
||||
## Root Cause (Confirmed)
|
||||
M2 model loading uses `openFileWithOptions` (0x6477c0) directly — it **never** goes through our hooked `LoadFileWithTextureResourceFallback` (0x648620). That's why embedded M2 files aren't served.
|
||||
|
||||
## M2 Loading Call Chain
|
||||
```
|
||||
createModelAttachment (0x707350)
|
||||
→ loadResourceByPath (0x706a50)
|
||||
→ openFileWithOptions (0x6477c0) ← file not found, returns NULL
|
||||
→ loadModelFromFileAsync (0x71d4e0) ← never reached
|
||||
→ falls back to ErrorCube.mdx
|
||||
```
|
||||
|
||||
## File Context Structure (0x60 bytes, allocated by openFileWithOptions)
|
||||
- **+0x00**: type (0=disk, 1=validated disk, 2/3=storage, 4=archive)
|
||||
- **+0x04**: file handle pointer (disk types)
|
||||
- **+0x0C**: path string
|
||||
- **+0x14**: pre-read size (type 1)
|
||||
- **+0x24**: critical section
|
||||
- **+0x3C**: stream handle (types 2/3)
|
||||
- **+0x40**: archive handle (type 4)
|
||||
|
||||
## Calling Conventions (verified from disasm)
|
||||
| Function | Convention | Params | RET |
|
||||
|---|---|---|---|
|
||||
| `openFileWithOptions` (0x6477c0) | `__stdcall` | 4 | `RET 0x10` |
|
||||
| `GetFileSizeFromHandle` (0x6487f0) | `__stdcall` | 2 | `RET 0x08` |
|
||||
| `ReadFileFromMultipleSources` (0x648460) | `__stdcall` | 5 | `RET 0x14` |
|
||||
| `CleanupFileHandleResources` (0x648730) | `__stdcall` | 1 | `RET 0x04` |
|
||||
| `loadModelFromFileAsync` (0x71d4e0) | `__thiscall` | ECX=this, 2 stack | `RET 0x08` |
|
||||
|
||||
## GetFileSizeFromHandle (0x6487f0) — dispatches on context type
|
||||
- Type 0: `fstatFileHandle(ctx[1]+0x10)` — needs real file handle
|
||||
- Type 1: returns `ctx[5]` (value at +0x14) — **simplest, just returns a stored value**
|
||||
- Types 2/3: `GetAudioStreamPosition(ctx[0xf])`
|
||||
- Type 4: `getFileSize(ctx[0x10])`
|
||||
|
||||
## ReadFileFromMultipleSources (0x648460) — dispatches on context type
|
||||
- Type 0: `fileReadWithLock(buffer, 1, size, ctx[1])` — needs real file handle
|
||||
- Other types: use respective handle fields
|
||||
|
||||
## initializeFileContext (0x647290) — __thiscall, ECX=ctx, 1 stack param (type)
|
||||
```c
|
||||
TraverseListNodes((LPCRITICAL_SECTION)(this + 0x24)); // init critical section
|
||||
*(uint *)this = param_1; // +0x00: type
|
||||
*(uint *)(this + 0x04) = 0; // file handle
|
||||
*(uint *)(this + 0x08) = 0;
|
||||
*(uint *)(this + 0x0C) = 0; // path
|
||||
*(uint *)(this + 0x18) = 0;
|
||||
*(uint *)(this + 0x3C) = 0;
|
||||
*(uint *)(this + 0x40) = 0;
|
||||
*(uint *)(this + 0x54) = 0;
|
||||
*(uint *)(this + 0x58) = 0;
|
||||
*(uint *)(this + 0x10) = 0;
|
||||
*(uint *)(this + 0x5C) = 0;
|
||||
*(uint *)(this + 0x1C) = 0;
|
||||
```
|
||||
|
||||
## CleanupFileHandleResources (0x648730) — __stdcall, 1 param, RET 0x04
|
||||
```c
|
||||
if (ctx + 0x04 != NULL) closeFileStreamSafely(ctx + 0x04); // disk file handle
|
||||
if (ctx + 0x3C != 0) Stream_CompareBuffers(ctx + 0x3C); // stream
|
||||
if (ctx + 0x40 != NULL) closeArchiveFile(ctx + 0x40); // archive
|
||||
// then frees path at +0x0C, buffer at +0x18, context at +0x1C
|
||||
// calls cleanupFileContext (0x6472d0) on the 0x60-byte struct
|
||||
// frees the struct itself
|
||||
```
|
||||
|
||||
## Async Task Structure (from loadModelFromFileAsync)
|
||||
```
|
||||
+0x00: file handle context
|
||||
+0x04: destination buffer ptr
|
||||
+0x08: data size
|
||||
+0x0C: model object (callback context)
|
||||
+0x10: onModelLoadComplete callback
|
||||
+0x14: error callback (0x71d610)
|
||||
+0x18: 0
|
||||
+0x1C: byte 0
|
||||
+0x1D: byte 1
|
||||
+0x28: byte 0
|
||||
```
|
||||
Queued via `AsyncTask_QueueForExecution` (0x443ae0). Executor reads from file handle into buffer, then calls completion callback.
|
||||
|
||||
## loadResourceByPath (0x706a50) — __thiscall, ECX=resourceMgr
|
||||
After `openFileWithOptions` succeeds:
|
||||
1. Allocates 0x164-byte model object via `M2_AllocateModelBuffer`
|
||||
2. `initializeModelObject(modelObj, resourceMgr)`
|
||||
3. `loadModelFromFileAsync(modelObj, fileHandle, loadFlags)`
|
||||
4. On success: copies normalized path into modelObj+0x20, sets up hash links
|
||||
5. On failure: `CleanupFileHandleResources`, free model object, return NULL
|
||||
|
||||
## LoadFileWithTextureResourceFallback (0x648620) — our hooked function
|
||||
Calls `openFileWithOptions(param_1, path, async_flag, &handle_out)`, then:
|
||||
1. `GetFileSizeFromHandle(handle, NULL)` → size
|
||||
2. `M2_AllocateModelBuffer(size + extra_alloc)` → buffer
|
||||
3. `ReadFileFromMultipleSources(handle, buffer, size, &bytesRead, asyncFlag)` → data
|
||||
4. Returns buffer + size to caller
|
||||
**Only called by UI/config/addon loaders**, never by M2 model loading.
|
||||
|
||||
## The Core Problem
|
||||
Every file context type needs a **real file handle or archive handle** for the read path. There's no "memory buffer" type. A fake context that works through the normal read pipeline requires either:
|
||||
- A real handle pointing to our data, or
|
||||
- Hooking the read functions to detect our fakes and return embedded data
|
||||
|
||||
## Hook Options
|
||||
|
||||
### Option 1: Triple hook (openFileWithOptions + GetFileSizeFromHandle + ReadFileFromMultipleSources)
|
||||
- Create fake type-0 context with NULL handle, store data ptr/size in unused fields (+0x30/+0x34)
|
||||
- GetFileSizeFromHandle hook: detect fake (type 0, +0x04==NULL, +0x30!=0), return +0x34
|
||||
- ReadFileFromMultipleSources hook: detect fake, memcpy from +0x30
|
||||
- **Risk**: async executor might call fileReadWithLock directly, bypassing ReadFileFromMultipleSources
|
||||
|
||||
### Option 2: Hook loadModelFromFileAsync
|
||||
- After openFileWithOptions succeeds with fake context, fill buffer synchronously
|
||||
- Call onModelLoadComplete directly, skip async task
|
||||
- **Need**: onModelLoadComplete address, setCullMode (allocator) address
|
||||
- **Risk**: completion callback may expect async-specific state
|
||||
|
||||
### Option 3: Hook openFileWithOptions only
|
||||
- Make it produce a context that works through existing read pipeline
|
||||
- **Hardest** — requires understanding all read paths for the chosen type
|
||||
|
||||
## Implementation: In-Memory File Serving (chosen: Option 1 extended)
|
||||
|
||||
### Implemented Hooks
|
||||
5 hooks at the Storm file I/O layer, plus the existing `loadFileDetour` at 0x648620:
|
||||
|
||||
| Hook | Target | Convention | Prologue | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| `openFileDetour` | 0x6477c0 | `__stdcall(4)` | 9 bytes | Create fake file context for embedded files |
|
||||
| `getFileSizeDetour` | 0x6487f0 | `__stdcall(2)` | 6 bytes | Return embedded data size |
|
||||
| `readFileDetour` | 0x648460 | `__stdcall(5)` | 6 bytes | memcpy embedded data (sync+async) |
|
||||
| `processAsyncDetour` | 0x647350 | `__fastcall(ECX)` | 7 bytes | Serve data in async executor path |
|
||||
| `cleanupFileHandleDetour` | 0x648730 | `__stdcall(1)` | 7 bytes | Free fake context + path |
|
||||
|
||||
### Fake File Context Layout
|
||||
Allocated via `allocateGameBuffer(0x60)`, zero-filled, then:
|
||||
- `initializeFileContext(ctx, 0)` — sets type=0, inits critsec at +0x24
|
||||
- `+0x0C`: duplicated path string (game-allocated)
|
||||
- `+0x30`: embedded data pointer (custom field, points into DLL .rdata)
|
||||
- `+0x34`: embedded data size (custom field)
|
||||
- **Detection**: `type==0 && handle(+0x04)==NULL && *(ctx+0x30)!=0`
|
||||
- Return value from openFileWithOptions: 2 (non-zero = success)
|
||||
|
||||
### processAsyncFileOperation (0x647350) — verified via Ghidra
|
||||
- `__fastcall(ECX=request)`, plain `RET` (c3)
|
||||
- Request: `+0x08`=file_ctx, `+0x0C`=dest_buf, `+0x10`=read_size, `+0x14`=seek/event struct
|
||||
- Event handle at `*(*(request+0x14)+4)` — signaled via `SetEvent`
|
||||
- Cleanup epilogue: decrement `*(ctx+0x5c)`, `LeaveCriticalSection(ctx+0x24)`, signal event, conditional `CleanupFileHandleResources`
|
||||
- Close-after-read flag at `*(ctx+0x58)`
|
||||
|
||||
### CleanupFileHandleResources (0x648730) — verified decompile
|
||||
```c
|
||||
void CleanupFileHandleResources(int ctx) {
|
||||
if (ctx == 0) return;
|
||||
if (*(ctx+0x04)) closeFileStreamSafely(*(ctx+0x04));
|
||||
if (*(ctx+0x3C)) Stream_CompareBuffers(*(ctx+0x3C));
|
||||
if (*(ctx+0x40)) closeArchiveFile(*(ctx+0x40));
|
||||
if (*(ctx+0x08)) { /* check+free sub-buffer */ FreeMemory(*(ctx+0x08)); }
|
||||
cleanupFileContext(ctx); // destroy critsec
|
||||
FreeMemory(ctx); // free 0x60 struct
|
||||
// NOTE: does NOT free path at +0x0C — we must free it ourselves
|
||||
}
|
||||
```
|
||||
FreeMemory (SMemFree) at **0x646430** — `__stdcall(ptr, src_str, flags)`.
|
||||
|
||||
### Crash: Hook Install Order Matters
|
||||
**Symptom**: Crash on game load in `loadFileDetour` calling `file_hook.getTrampoline()`.
|
||||
The trampoline memory (VirtualAlloc'd) contained zeros instead of the saved prologue.
|
||||
|
||||
**Analysis**:
|
||||
- Crash at `0x075D5E88` (trampoline memory) — bytes: `00 00 00 00`
|
||||
- Return address `0x04AA1542` in weirdutils.dll = `CALL *%eax` in `loadFileDetour`
|
||||
- `getTrampoline` (compiled at DLL+0x3250) reads `file_hook.trampoline` field from `.data` section at `0x1015c0d8`, checks non-NULL, calls through it
|
||||
|
||||
**Root cause**: `LoadFileWithTextureResourceFallback` (0x648620) internally calls `openFileWithOptions` (0x6477c0). If `file_hook` is installed FIRST (copying the 0x648620 prologue to its trampoline), and THEN we patch 0x6477c0, the trampoline's execution context is disrupted. The `file_hook` trampoline runs the original 0x648620 prologue which eventually calls 0x6477c0 — but if 0x6477c0 was patched after the trampoline was built, there may be page-level or VirtualProtect interactions that corrupt the trampoline's allocated memory.
|
||||
|
||||
**Fix**: Install Storm I/O hooks (`installFileHooks`) BEFORE `file_hook` at 0x648620. Remove in reverse order.
|
||||
|
||||
### Open Questions
|
||||
- Does M2 async loading go through `ReadFileFromMultipleSources` or bypass it via direct `fileReadWithLock`? The `processAsyncDetour` hook catches the latter path. If M2 still shows ErrorCube, may need hook 5 (`loadModelFromFileAsync` at 0x71d4e0).
|
||||
- `openFileWithOptions` return value: returns type code (0=fail, 1-4=success per type). We return 2 for fakes. Callers primarily check handle_out != NULL.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -67,8 +67,7 @@ fn createEntityInstance(path: [*:0]const u8, pos: *[3]f32, facing: f32, flags: u
|
||||
[_] "{edx}" (@intFromPtr(pos)),
|
||||
[a] "r" (&stack_args),
|
||||
[func] "r" (o.FN_CREATE_ENTITY_INSTANCE),
|
||||
: .{ .memory = true, .cc = true }
|
||||
);
|
||||
: .{ .memory = true, .cc = true });
|
||||
|
||||
return if (result != 0) @ptrFromInt(result) else null;
|
||||
}
|
||||
@@ -81,8 +80,7 @@ fn cleanupEntity(obj: *anyopaque) void {
|
||||
:
|
||||
: [_] "{ecx}" (@intFromPtr(obj)),
|
||||
[func] "r" (o.FN_CLEANUP_ENTITY),
|
||||
: .{ .eax = true, .edx = true, .memory = true, .cc = true }
|
||||
);
|
||||
: .{ .eax = true, .edx = true, .memory = true, .cc = true });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -91,7 +89,7 @@ fn cleanupEntity(obj: *anyopaque) void {
|
||||
|
||||
var test_marker: ?*anyopaque = null;
|
||||
|
||||
const MODEL_PATH: [*:0]const u8 = "Spells\\ErrorCube.mdx";
|
||||
const MODEL_PATH: [*:0]const u8 = "Spells\\WU_XYZ.m2";
|
||||
|
||||
/// Create a test marker at player position using the native entity creation API.
|
||||
pub fn createTestMarker() ?*anyopaque {
|
||||
@@ -110,7 +108,7 @@ pub fn createTestMarker() ?*anyopaque {
|
||||
con.fmt("[markers] player pos = {d:.1}, {d:.1}, {d:.1}\n", .{ pos.x, pos.y, pos.z });
|
||||
if (pos.x == 0 and pos.y == 0 and pos.z == 0) return null;
|
||||
|
||||
var position = [3]f32{ pos.x, pos.y, pos.z + 2.0 };
|
||||
var position = [3]f32{ pos.x + 10.0, pos.y + 10.0, pos.z + 2.0 };
|
||||
|
||||
con.print("[markers] calling CreateEntityInstance_WithAttachment...\n");
|
||||
const obj = createEntityInstance(MODEL_PATH, &position, 0.0, 0, 1) orelse {
|
||||
@@ -161,8 +159,7 @@ fn luaPushNumber(L: u32, n: f64) void {
|
||||
[lo] "r" (raw[0]),
|
||||
[hi] "r" (raw[1]),
|
||||
[func] "r" (@as(u32, 0x6F3810)),
|
||||
: .{ .eax = true, .edx = true, .memory = true, .cc = true }
|
||||
);
|
||||
: .{ .eax = true, .edx = true, .memory = true, .cc = true });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -215,6 +212,7 @@ pub fn luaGetPlayerPosition(L: u32) callconv(.c) u32 {
|
||||
// =============================================================================
|
||||
|
||||
pub fn installHooks() void {
|
||||
con.print("[markers] Module loaded\n");
|
||||
// Nothing to hook - markers are created via Lua commands
|
||||
}
|
||||
|
||||
|
||||
@@ -78,3 +78,58 @@ pub const FN_CM2_CREATE_FOR_MODEL_OBJECT: usize = 0x00695100;
|
||||
/// ECX = world object, EDX = 4x4 transform matrix (float[16])
|
||||
/// Stack: bounds (float[6] min/max), halfExtents (float[3]), forceUpdate (int)
|
||||
pub const FN_UPDATE_OBJECT_TRANSFORM: usize = 0x006717d0;
|
||||
|
||||
// =============================================================================
|
||||
// File I/O (Storm) — for in-memory file serving
|
||||
// =============================================================================
|
||||
|
||||
/// openFileWithOptions — __stdcall(4), RET 0x10, prologue=9
|
||||
/// (archive_ptr, path, flags, handle_out) → type_code (0=fail, 1-4=success)
|
||||
pub const FN_OPEN_FILE_WITH_OPTIONS: usize = 0x006477c0;
|
||||
|
||||
/// GetFileSizeFromHandle — __stdcall(2), RET 0x08, prologue=6
|
||||
/// (file_context, high_size_out) → size
|
||||
pub const FN_GET_FILE_SIZE: usize = 0x006487f0;
|
||||
|
||||
/// ReadFileFromMultipleSources — __stdcall(6), RET 0x18, prologue=6
|
||||
/// (context, buffer, size, bytes_read_out, async_ptr, param6) → bool
|
||||
/// async_ptr==NULL: synchronous read. Non-NULL: queues async operation.
|
||||
pub const FN_READ_FILE: usize = 0x00648460;
|
||||
|
||||
/// CleanupFileHandleResources — __stdcall(1), RET 0x04, prologue=7
|
||||
/// (file_context) → 1
|
||||
pub const FN_CLEANUP_FILE_HANDLE: usize = 0x00648730;
|
||||
|
||||
/// processAsyncFileOperation — __fastcall(ECX=request), plain RET, prologue=7
|
||||
/// Request structure: +0x08=file_ctx, +0x0C=dest_buf, +0x10=read_size,
|
||||
/// +0x14=seek/event_struct (*(+0x14)+4 = event handle)
|
||||
pub const FN_PROCESS_ASYNC_FILE_OP: usize = 0x00647350;
|
||||
|
||||
/// initializeFileContext — __thiscall(ECX=ctx, type)
|
||||
/// Sets context type, initializes critical section at +0x24, zeroes fields.
|
||||
pub const FN_INIT_FILE_CONTEXT: usize = 0x00647290;
|
||||
|
||||
/// cleanupFileContext — __thiscall(ECX=ctx)
|
||||
/// Destroys critical section, cleanup companion to initializeFileContext.
|
||||
pub const FN_CLEANUP_FILE_CONTEXT: usize = 0x006472d0;
|
||||
|
||||
/// FreeMemory (SMemFree) — __stdcall(3): (ptr, src_str, flags)
|
||||
pub const FN_FREE_MEMORY: usize = 0x00646430;
|
||||
|
||||
// =============================================================================
|
||||
// M2 model loading (async pipeline)
|
||||
// =============================================================================
|
||||
|
||||
/// loadModelFromFileAsync — __thiscall(ECX=model_obj), 2 stack params, RET 0x08
|
||||
/// (fileHandle: **ctx, shouldUseCallback: int) → 1
|
||||
/// Prologue: 55 8b ec 8b 55 0c 56 8b f1 — safe sizes: [6, 7, 9]
|
||||
/// Allocates async task to read file and call onModelLoadComplete when done.
|
||||
/// The async executor at 0x71d610 calls fileReadWithLock directly, bypassing
|
||||
/// our ReadFileFromMultipleSources hook — hence this hook fills the buffer
|
||||
/// synchronously for fake file contexts.
|
||||
pub const FN_LOAD_MODEL_ASYNC: usize = 0x0071d4e0;
|
||||
|
||||
/// processLoadedModelData — __fastcall(ECX=model), no stack params, plain RET
|
||||
/// Parses model header from buffer at model+0x130 (ptr) / model+0x134 (size),
|
||||
/// initializes model resources, sets bit 0 of model+8 when done.
|
||||
pub const FN_PROCESS_LOADED_MODEL_DATA: usize = 0x0071d640;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! and a Lua C callback for `/wu outline` commands.
|
||||
|
||||
const hook = @import("hook");
|
||||
const con = @import("../console.zig");
|
||||
const tracker = @import("tracker.zig");
|
||||
const model_hook = @import("model_hook.zig");
|
||||
const d3d9_hook = @import("d3d9_hook.zig");
|
||||
@@ -13,6 +14,7 @@ const d3d9_hook = @import("d3d9_hook.zig");
|
||||
/// dummy D3D9 device during engine init corrupts the d3d9 proxy's state and
|
||||
/// causes model rendering to stutter at ~10fps.
|
||||
pub fn init() bool {
|
||||
con.print("[outline] Module loaded\n");
|
||||
if (!model_hook.installHooks()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const std = @import("std");
|
||||
const hook = @import("hook");
|
||||
const con = @import("../console.zig");
|
||||
const png = @import("png.zig");
|
||||
|
||||
const WINAPI = std.builtin.CallingConvention.winapi;
|
||||
@@ -340,6 +341,7 @@ pub fn screenshotCommand(L: *anyopaque) callconv(.c) u32 {
|
||||
// =============================================================================
|
||||
|
||||
pub fn installHook() void {
|
||||
con.print("[screenshot] Module loaded\n");
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user