diff --git a/build.zig b/build.zig index fcde2f5..b539b36 100644 --- a/build.zig +++ b/build.zig @@ -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, diff --git a/src/combatlog/combatlog.zig b/src/combatlog/combatlog.zig new file mode 100644 index 0000000..69ef18b --- /dev/null +++ b/src/combatlog/combatlog.zig @@ -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 {} diff --git a/src/framecrash/RESEARCH.md b/src/framecrash/RESEARCH.md new file mode 100644 index 0000000..9357e78 --- /dev/null +++ b/src/framecrash/RESEARCH.md @@ -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. diff --git a/src/framecrash/framecrash.zig b/src/framecrash/framecrash.zig index dfa389b..4f00783 100644 --- a/src/framecrash/framecrash.zig +++ b/src/framecrash/framecrash.zig @@ -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"); +} diff --git a/src/interact/interact.zig b/src/interact/interact.zig index f3c7be8..10b4b4f 100644 --- a/src/interact/interact.zig +++ b/src/interact/interact.zig @@ -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, &.{})) { diff --git a/src/main.zig b/src/main.zig index 4c0d898..b4bda47 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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(); } diff --git a/src/markers/RESEARCH.md b/src/markers/RESEARCH.md new file mode 100644 index 0000000..8c94fc6 --- /dev/null +++ b/src/markers/RESEARCH.md @@ -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. diff --git a/src/markers/assets/Spells/GRAD2C2.BLP b/src/markers/assets/Spells/GRAD2C2.BLP new file mode 100644 index 0000000..8570f5d Binary files /dev/null and b/src/markers/assets/Spells/GRAD2C2.BLP differ diff --git a/src/markers/assets/Spells/GRAD2D.BLP b/src/markers/assets/Spells/GRAD2D.BLP new file mode 100644 index 0000000..98b4ba3 Binary files /dev/null and b/src/markers/assets/Spells/GRAD2D.BLP differ diff --git a/src/markers/assets/Spells/NEXUS_FIREBEAM_FAINT_SQUARE_ORA.BLP b/src/markers/assets/Spells/NEXUS_FIREBEAM_FAINT_SQUARE_ORA.BLP new file mode 100644 index 0000000..066f6fd Binary files /dev/null and b/src/markers/assets/Spells/NEXUS_FIREBEAM_FAINT_SQUARE_ORA.BLP differ diff --git a/src/markers/assets/Spells/RaidTarget_Diamond.blp b/src/markers/assets/Spells/RaidTarget_Diamond.blp new file mode 100644 index 0000000..a5271fb Binary files /dev/null and b/src/markers/assets/Spells/RaidTarget_Diamond.blp differ diff --git a/src/markers/assets/Spells/RaidTarget_Square.blp b/src/markers/assets/Spells/RaidTarget_Square.blp new file mode 100644 index 0000000..28e964a Binary files /dev/null and b/src/markers/assets/Spells/RaidTarget_Square.blp differ diff --git a/src/markers/assets/Spells/RaidTarget_Star.blp b/src/markers/assets/Spells/RaidTarget_Star.blp new file mode 100644 index 0000000..2c02d3d Binary files /dev/null and b/src/markers/assets/Spells/RaidTarget_Star.blp differ diff --git a/src/markers/assets/Spells/RaidTarget_Triangle.blp b/src/markers/assets/Spells/RaidTarget_Triangle.blp new file mode 100644 index 0000000..2e28121 Binary files /dev/null and b/src/markers/assets/Spells/RaidTarget_Triangle.blp differ diff --git a/src/markers/assets/Spells/RaidTarget_X.blp b/src/markers/assets/Spells/RaidTarget_X.blp new file mode 100644 index 0000000..e45e60a Binary files /dev/null and b/src/markers/assets/Spells/RaidTarget_X.blp differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Cyan.m2 b/src/markers/assets/Spells/Raid_UI_FX_Cyan.m2 new file mode 100644 index 0000000..8cf3cb4 Binary files /dev/null and b/src/markers/assets/Spells/Raid_UI_FX_Cyan.m2 differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Cyan00.skin b/src/markers/assets/Spells/Raid_UI_FX_Cyan00.skin new file mode 100644 index 0000000..7b32bb8 Binary files /dev/null and b/src/markers/assets/Spells/Raid_UI_FX_Cyan00.skin differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Green.m2 b/src/markers/assets/Spells/Raid_UI_FX_Green.m2 new file mode 100644 index 0000000..410b34e Binary files /dev/null and b/src/markers/assets/Spells/Raid_UI_FX_Green.m2 differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Green00.skin b/src/markers/assets/Spells/Raid_UI_FX_Green00.skin new file mode 100644 index 0000000..7b32bb8 Binary files /dev/null and b/src/markers/assets/Spells/Raid_UI_FX_Green00.skin differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Purple.m2 b/src/markers/assets/Spells/Raid_UI_FX_Purple.m2 new file mode 100644 index 0000000..929c315 Binary files /dev/null and b/src/markers/assets/Spells/Raid_UI_FX_Purple.m2 differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Purple00.skin b/src/markers/assets/Spells/Raid_UI_FX_Purple00.skin new file mode 100644 index 0000000..30bff57 Binary files /dev/null and b/src/markers/assets/Spells/Raid_UI_FX_Purple00.skin differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Red.m2 b/src/markers/assets/Spells/Raid_UI_FX_Red.m2 new file mode 100644 index 0000000..e11f87c Binary files /dev/null and b/src/markers/assets/Spells/Raid_UI_FX_Red.m2 differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Red00.skin b/src/markers/assets/Spells/Raid_UI_FX_Red00.skin new file mode 100644 index 0000000..30bff57 Binary files /dev/null and b/src/markers/assets/Spells/Raid_UI_FX_Red00.skin differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Yellow.m2 b/src/markers/assets/Spells/Raid_UI_FX_Yellow.m2 new file mode 100644 index 0000000..43bedd5 Binary files /dev/null and b/src/markers/assets/Spells/Raid_UI_FX_Yellow.m2 differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Yellow00.skin b/src/markers/assets/Spells/Raid_UI_FX_Yellow00.skin new file mode 100644 index 0000000..7b32bb8 Binary files /dev/null and b/src/markers/assets/Spells/Raid_UI_FX_Yellow00.skin differ diff --git a/src/markers/assets/Spells/T_VFX_FLARE05_32ALPHA.BLP b/src/markers/assets/Spells/T_VFX_FLARE05_32ALPHA.BLP new file mode 100644 index 0000000..68b1784 Binary files /dev/null and b/src/markers/assets/Spells/T_VFX_FLARE05_32ALPHA.BLP differ diff --git a/src/markers/assets/Spells/WU_XYZ.m2 b/src/markers/assets/Spells/WU_XYZ.m2 new file mode 100644 index 0000000..8e0d3f3 Binary files /dev/null and b/src/markers/assets/Spells/WU_XYZ.m2 differ diff --git a/src/markers/assets/Spells/xyz.blp b/src/markers/assets/Spells/xyz.blp new file mode 100644 index 0000000..3b74bad Binary files /dev/null and b/src/markers/assets/Spells/xyz.blp differ diff --git a/src/markers/assets/World/Expansion01/Doodads/Zulaman/Doors/T_VFX_BORDER6.BLP b/src/markers/assets/World/Expansion01/Doodads/Zulaman/Doors/T_VFX_BORDER6.BLP new file mode 100644 index 0000000..7e3856b Binary files /dev/null and b/src/markers/assets/World/Expansion01/Doodads/Zulaman/Doors/T_VFX_BORDER6.BLP differ diff --git a/src/markers/assets/World/Expansion01/Doodads/Zulaman/Doors/T_VFX_FIRE03_A.BLP b/src/markers/assets/World/Expansion01/Doodads/Zulaman/Doors/T_VFX_FIRE03_A.BLP new file mode 100644 index 0000000..12c7e92 Binary files /dev/null and b/src/markers/assets/World/Expansion01/Doodads/Zulaman/Doors/T_VFX_FIRE03_A.BLP differ diff --git a/src/markers/markers.zig b/src/markers/markers.zig index bfc5af5..ffb7992 100644 --- a/src/markers/markers.zig +++ b/src/markers/markers.zig @@ -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 } diff --git a/src/markers/offsets.zig b/src/markers/offsets.zig index 7cbbcc3..8e6170e 100644 --- a/src/markers/offsets.zig +++ b/src/markers/offsets.zig @@ -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; diff --git a/src/outline/api.zig b/src/outline/api.zig index 7bb9e2d..fcdf15e 100644 --- a/src/outline/api.zig +++ b/src/outline/api.zig @@ -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; } diff --git a/src/screenshot/screenshot.zig b/src/screenshot/screenshot.zig index 131a38c..8d15f7b 100644 --- a/src/screenshot/screenshot.zig +++ b/src/screenshot/screenshot.zig @@ -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)