diff --git a/ideas/ground-projected-raid-markers.md b/ideas/ground-projected-raid-markers.md new file mode 100644 index 0000000..781fb81 --- /dev/null +++ b/ideas/ground-projected-raid-markers.md @@ -0,0 +1,273 @@ +# Ground-Projected Raid Markers + +**Goal:** Render placeable raid markers (like Wrath+) that project onto the ground as area indicators. + +**Status:** Research complete, implementation pending + +--- + +## What We Have + +### D3D9 Rendering Infrastructure +- **File:** `src/outline/d3d9_hook.zig` +- Render target management (silhouette RT, JFA ping-pong buffers) +- Shader assembly via D3DX (PS 3.0) +- Fullscreen quad rendering with `DrawPrimitiveUP` +- Complete state save/restore around custom passes +- Hooks: EndScene, DrawIndexedPrimitive, Reset + +### Model Rendering Hooks +- **File:** `src/outline/model_hook.zig` +- `CM2SceneRenderDraw` hook — batch reordering for depth control +- `CM2Scene_DrawBatchProjected` hook — per-batch interception +- Access to render context and model pointers +- Batch reordering ensures outline targets render when only terrain+WMO depth exists + +### Render Pipeline Documentation +- **File:** `docs/RENDER_ARCHITECTURE.md` +- Terrain renderer: `CullAndProcessWorldChunks` (0x00683040) +- WMO renderer: `ProcessStaticObjectsCulling` (0x00683bf0) +- M2 model pipeline fully mapped +- Depth buffer control via hook ordering + +### Outline Rendering Research +- **File:** `docs/outline-rendering-research.md` +- JFA (Jump Flood Algorithm) for distance-field outlines +- Stencil + blur techniques (Valve L4D style) +- Screen-space dilation approaches +- All SM 3.0 compatible + +### UnitXP SP3 Reference +- **Path:** `/media/storage/projects/UnitXP_SP3_Orig/UnitXP_SP3/` +- **Key file:** `Vanilla1121_functions.h` +- `vanilla1121_worldToScreen(C3Vector& world)` — world → screen projection +- `CWorld_Intersect()` — raycast through world geometry +- `vanilla1121_unitPosition()` — unit world position +- `vanilla1121_getCameraPosition()` — camera world position + +### WoWee Terrain Renderer Reference +- **Path:** `/media/storage/projects/WoWee/src/rendering/terrain_renderer.cpp` +- Modern OpenGL terrain rendering with multi-layer textures +- Frustum culling, LOD, shadow mapping +- Good reference for understanding terrain data structures + +--- + +## What's Missing + +### 1. Terrain Height Query +```c +// Need: Get terrain Z at world (X, Y) +float GetTerrainHeight(float worldX, float worldY); +``` + +**Options:** +- Reverse engineer WoW's internal terrain height function +- Use `CWorld_Intersect()` with vertical ray: `(x, y, +1000) → (x, y, -1000)` +- ADT tile parsing (complex, requires MPQ access) + +### 2. Terrain Normal Query +```c +// Need: Get terrain normal at world (X, Y) for quad orientation +C3Vector GetTerrainNormal(float worldX, float worldY); +``` + +**Options:** +- Sample height at 4 neighboring points, compute normal +- Reverse engineer WoW's internal function + +### 3. Depth Buffer as Texture (for projected decals) +```c +// Need: Access depth buffer as readable texture +IDirect3DTexture9* GetDepthAsTexture(); +``` + +**D3D9 approaches:** +- `INTZ` / `RAWZ` format hack (requires driver support) +- `D3DFMT_D24S8` → `D3DFMT_D24X8` with `INTZ` fourcc +- Render depth to separate RT during terrain pass +- See: https://aras-p.info/texts/D3D9GPUHacks.html + +--- + +## Implementation Options + +### Option A: Screen-Space Marker (Simplest) + +**Complexity:** Low +**Visual Quality:** Basic (icon on screen, no ground conformity) + +**Implementation:** +1. Hook raid target array at `0x00B71368` (8 GUIDs) +2. Get marked unit's world position via `vanilla1121_unitPosition()` +3. Project to screen via `vanilla1121_worldToScreen()` +4. Draw icon sprite in EndScene at screen position +5. Optional: Depth test against terrain depth buffer + +**Pros:** +- Minimal implementation +- Uses existing infrastructure +- No terrain queries needed + +**Cons:** +- Marker doesn't conform to terrain +- Looks like a floating icon, not ground projection +- No "area on the ground" effect + +--- + +### Option B: Projected Ground Decal (Recommended) + +**Complexity:** Medium +**Visual Quality:** Good (conforms to terrain, area indicator) + +**Implementation:** +1. Store marker world positions (from raid target array or click events) +2. In EndScene, after terrain+WMO depth written: +3. For each active marker: + - Bind depth buffer as texture (INTZ hack or separate RT) + - Render fullscreen quad with decal shader + - Shader reconstructs world position from depth + - If within marker radius, output marker color/texture + - Distance fade at edges for soft boundary + +**Decal Shader (HLSL SM 3.0):** +```hlsl +// Uniforms set by CPU +float3 MarkerCenter; // World position +float MarkerRadius; // In world units +float4 MarkerColor; // RGBA +float2 ScreenSize; // For UV calculation + +// Depth buffer bound to s0 +sampler2D DepthSampler : register(s0); + +float4 DecalPS(float2 uv : TEXCOORD0) : COLOR +{ + // Read depth, reconstruct world position + float depth = tex2D(DepthSampler, uv).r; + float3 worldPos = ReconstructWorldPosition(uv, depth); + + // Distance from marker center (XZ plane) + float2 offset = worldPos.xz - MarkerCenter.xz; + float dist = length(offset); + + // Soft falloff + float alpha = saturate(1.0 - (dist / MarkerRadius)); + alpha = smoothstep(0.0, 0.3, alpha); // Soft edge + + // Output + return float4(MarkerColor.rgb, MarkerColor.a * alpha); +} +``` + +**Pros:** +- Conforms to terrain contours +- Looks like ground projection +- Can support multiple markers +- Soft edges for aesthetic + +**Cons:** +- Requires depth buffer access (driver-dependent) +- More complex shader setup +- Per-marker render pass overhead + +--- + +### Option C: World-Space Quad (Best Conformity) + +**Complexity:** High +**Visual Quality:** Best (actual geometry on terrain) + +**Implementation:** +1. Create marker quad mesh (4 vertices, 2 triangles) +2. Hook `CM2SceneRenderDraw` to inject custom geometry +3. For each marker: + - Query terrain height at marker position + - Query terrain normal for orientation + - Position quad at terrain height, orient to normal + - Add to render batch with marker texture +4. Render as part of M2 batch with depth testing + +**Pros:** +- Perfect terrain conformity +- Actual geometry, not shader trick +- Proper depth testing with other objects + +**Cons:** +- Requires terrain height/normal queries +- More complex geometry management +- Hook injection into render pipeline + +--- + +## Recommended Path + +### Phase 1: Proof of Concept (Screen-Space) +1. Implement basic screen-space marker rendering +2. Hook raid target array, project positions +3. Draw simple circle/icon at screen position +4. Verify hook integration works + +**Estimated effort:** 1-2 hours + +### Phase 2: Ground Decal (Primary Target) +1. Implement INTZ depth texture hack +2. Write decal projection shader +3. Add marker position storage +4. Render projected circles on terrain + +**Estimated effort:** 4-6 hours + +### Phase 3: Polish +1. Add marker textures (skull, cross, square, etc.) +2. Soft edge falloff +3. Multiple marker colors +4. Optional: Click-to-place interface + +**Estimated effort:** 2-3 hours + +--- + +## Key Files to Create/Modify + +### New Files +``` +src/marker/ +├── marker_tracker.zig # Track active markers, positions +├── marker_decal.zig # Decal rendering shader + pipeline +├── marker_hooks.zig # Raid target array hooks +└── marker_types.zig # Data structures +``` + +### Modified Files +``` +src/outline/d3d9_hook.zig # Add depth texture creation +src/outline/types.zig # Add depth texture format constants +src/main.zig # Initialize marker system +``` + +--- + +## External References + +### D3D9 Depth Buffer Hacks +- https://aras-p.info/texts/D3D9GPUHacks.html +- INTZ / RAWZ format for reading depth as texture + +### Decal Rendering Techniques +- Valve L4D Glow Effect (stencil + blur): https://developer.valvesoftware.com/wiki/L4D_Glow_Effect +- Unreal Engine deferred decals: https://docs.unrealengine.com/4.27/en-US/RenderingAndGraphics/DeferredRendering/ + +### WoW 1.12.1 Internals +- wowdev.wiki for ADT terrain format +- UnitXP_SP3 for function signatures and calling conventions + +--- + +## Notes + +- Raid markers in Wrath+ use ground-projected circles with icon in center +- Our outline system already has the render target / shader infrastructure +- Depth buffer access is the main technical challenge for Option B +- Option A is good for quick testing, Option B is the production target diff --git a/libs/hook/build.zig b/libs/hook/build.zig index c4ea0e5..622b131 100644 --- a/libs/hook/build.zig +++ b/libs/hook/build.zig @@ -15,4 +15,21 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); hook_mod.addOptions("config", options); + + // x86 length disassembler — standalone, no dependencies + const x86dis_mod = b.addModule("x86dis", .{ + .root_source_file = b.path("src/x86dis.zig"), + .target = target, + .optimize = optimize, + }); + + // Generic hook — uses x86dis + hook for auto-sizing trampolines + const generic_hook_mod = b.addModule("generic_hook", .{ + .root_source_file = b.path("src/generic_hook.zig"), + .target = target, + .optimize = optimize, + }); + generic_hook_mod.addImport("x86dis", x86dis_mod); + generic_hook_mod.addImport("hook.zig", hook_mod); + generic_hook_mod.addOptions("config", options); } diff --git a/libs/hook/src/generic_hook.zig b/libs/hook/src/generic_hook.zig new file mode 100644 index 0000000..231c5f1 --- /dev/null +++ b/libs/hook/src/generic_hook.zig @@ -0,0 +1,279 @@ +//! Generic x86 inline hook — no manual prologue size or fixup lists needed. +//! +//! Uses the x86 length disassembler (HDE32 port) to automatically determine +//! how many prologue bytes to steal, and relocates all relative instructions. +//! +//! Combines MinHook's compact disassembler with HadesMem's type-safe approach: +//! declare the function signature once at comptime, get a correctly-typed +//! trampoline and detour with zero manual casting. +//! +//! ## Low-level API (GenericHook) +//! +//! ```zig +//! var my_hook: GenericHook = .{}; +//! if (my_hook.install(0x401000, @intFromPtr(&myDetour)) == .ok) { +//! const orig = my_hook.getTrampoline(OrigFnType); +//! _ = orig(); +//! } +//! my_hook.remove(); +//! ``` +//! +//! ## Type-safe API (Detour) — HadesMem-inspired +//! +//! ```zig +//! const MyHook = Detour(fn (u32, u32) callconv(.{ .x86_stdcall = .{} }) u32); +//! var hook: MyHook = .{}; +//! hook.attach(0x401000, myDetour); +//! // Inside detour: hook.callOriginal(.{arg1, arg2}); +//! ``` + +const std = @import("std"); +const x86dis = @import("x86dis"); +const hook_base = @import("hook.zig"); + +const VirtualAlloc = hook_base.VirtualAlloc; +const VirtualFree = hook_base.VirtualFree; +const PAGE_EXECUTE_READWRITE = hook_base.PAGE_EXECUTE_READWRITE; +const MEM_COMMIT = hook_base.MEM_COMMIT; +const MEM_RELEASE = hook_base.MEM_RELEASE; +const writeProtected = hook_base.writeProtected; + +const JMP_SIZE: usize = 5; // E9 + rel32 +const MAX_STOLEN: usize = 32; +const TRAMPOLINE_BUF: usize = 64; + +// ═══════════════════════════════════════════════════════════════════════ +// GenericHook — low-level auto-sizing hook +// ═══════════════════════════════════════════════════════════════════════ + +pub const GenericHook = struct { + mem: ?[*]u8 = null, + trampoline: usize = 0, + target: usize = 0, + stolen_size: usize = 0, + saved_bytes: [MAX_STOLEN]u8 = undefined, + + pub const Error = enum { + ok, + alloc_failed, + disasm_error, + prologue_too_short, + unsupported_relocation, + }; + + /// Analyse target, build trampoline, patch target → detour. One call. + pub fn install(self: *GenericHook, target: usize, detour_addr: usize) Error { + const err = self.prepare(target); + if (err != .ok) return err; + self.activate(detour_addr); + return .ok; + } + + /// Phase 1: disassemble prologue, allocate trampoline, copy + relocate. + pub fn prepare(self: *GenericHook, target: usize) Error { + if (self.mem != null) return .ok; + + const src: [*]const u8 = @ptrFromInt(target); + + // ── determine how many bytes to steal ── + var stolen: usize = 0; + while (stolen < JMP_SIZE) { + const insn = x86dis.decode(src + stolen); + if (insn.flags & x86dis.F_ERROR != 0) return .disasm_error; + if (insn.len == 0) return .disasm_error; + stolen += insn.len; + if (stolen > MAX_STOLEN) return .prologue_too_short; + } + + // ── allocate ── + const mem = VirtualAlloc(null, TRAMPOLINE_BUF, MEM_COMMIT, PAGE_EXECUTE_READWRITE) orelse return .alloc_failed; + + self.mem = mem; + self.target = target; + self.stolen_size = stolen; + self.trampoline = @intFromPtr(mem); + + @memcpy(self.saved_bytes[0..stolen], src[0..stolen]); + + // ── check if already hooked (E9 at target) — chain through ── + if (src[0] == 0xE9) { + const other_detour = hook_base.rel32Target(target); + mem[0] = 0xE9; + hook_base.writeRel32(mem + 1, self.trampoline + 1, other_detour); + return .ok; + } + + // ── build trampoline: copy + relocate ── + var t_pos: usize = 0; + var s_pos: usize = 0; + + while (s_pos < stolen) { + const insn = x86dis.decode(src + s_pos); + const op = insn.opcode; + const src_addr = target + s_pos; + const dst_addr = self.trampoline + t_pos; + + if (insn.flags & x86dis.F_RELATIVE != 0) { + if (op == 0xE8 or op == 0xE9) { + // CALL/JMP rel32 + const abs = hook_base.rel32Target(src_addr); + mem[t_pos] = op; + hook_base.writeRel32(mem + t_pos + 1, dst_addr + 1, abs); + t_pos += 5; + } else if (op == 0x0F and insn.opcode2 >= 0x80 and insn.opcode2 <= 0x8F) { + // Jcc rel32 (0F 80-8F) + const abs = jcc32Target(src_addr); + mem[t_pos] = 0x0F; + mem[t_pos + 1] = insn.opcode2; + hook_base.writeRel32(mem + t_pos + 2, dst_addr + 2, abs); + t_pos += 6; + } else if (op >= 0x70 and op <= 0x7F) { + // Short Jcc → expand to near Jcc (0F 8x) + const offset = @as(i8, @bitCast(src[s_pos + 1])); + const abs: usize = @bitCast(@as(isize, @intCast(src_addr + 2)) + offset); + mem[t_pos] = 0x0F; + mem[t_pos + 1] = op + 0x10; + hook_base.writeRel32(mem + t_pos + 2, dst_addr + 2, abs); + t_pos += 6; + } else if (op == 0xEB) { + // Short JMP → expand to near JMP (E9) + const offset = @as(i8, @bitCast(src[s_pos + 1])); + const abs: usize = @bitCast(@as(isize, @intCast(src_addr + 2)) + offset); + mem[t_pos] = 0xE9; + hook_base.writeRel32(mem + t_pos + 1, dst_addr + 1, abs); + t_pos += 5; + } else { + // LOOP/JECXZ or unknown — cannot trivially expand + self.cleanup(); + return .unsupported_relocation; + } + } else { + // Non-relative — copy verbatim + @memcpy(mem[t_pos .. t_pos + insn.len], src[s_pos .. s_pos + insn.len]); + t_pos += insn.len; + } + s_pos += insn.len; + } + + // ── JMP back to original code after stolen bytes ── + mem[t_pos] = 0xE9; + hook_base.writeRel32( + mem + t_pos + 1, + self.trampoline + t_pos + 1, + target + stolen, + ); + + return .ok; + } + + /// Phase 2: write the E9 JMP patch at the target. + pub fn activate(self: *GenericHook, detour_addr: usize) void { + var patch: [MAX_STOLEN]u8 = .{0x90} ** MAX_STOLEN; + patch[0] = 0xE9; + hook_base.writeRel32(patch[1..5], self.target + 1, detour_addr); + writeProtected(self.target, patch[0..self.stolen_size]); + } + + /// Restore original bytes and free trampoline memory. + pub fn remove(self: *GenericHook) void { + if (self.mem == null) return; + writeProtected(self.target, self.saved_bytes[0..self.stolen_size]); + _ = VirtualFree(@ptrFromInt(@intFromPtr(self.mem.?)), 0, MEM_RELEASE); + self.mem = null; + } + + /// Get trampoline as a typed function pointer. + pub fn getTrampoline(self: *const GenericHook, comptime T: type) T { + return @ptrFromInt(self.trampoline); + } + + fn cleanup(self: *GenericHook) void { + if (self.mem) |m| { + _ = VirtualFree(@ptrFromInt(@intFromPtr(m)), 0, MEM_RELEASE); + self.mem = null; + } + } +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Detour(FnType) — HadesMem-style type-safe generic hook +// ═══════════════════════════════════════════════════════════════════════ + +/// Comptime-generic typed detour. Declare the target function's type once; +/// get type-checked attach/callOriginal with no manual pointer casts. +/// +/// ```zig +/// const StdcallU32x2 = fn (u32, u32) callconv(.{ .x86_stdcall = .{} }) u32; +/// const MyHook = generic_hook.Detour(StdcallU32x2); +/// var hook: MyHook = .{}; +/// hook.attach(0x401000, &myDetour); +/// // in detour: hook.callOriginal(.{ a, b }); +/// hook.detach(); +/// ``` +pub fn Detour(comptime FnType: type) type { + const FnInfo = @typeInfo(FnType).@"fn"; + const FnPtr = *const FnType; + const ReturnType = FnInfo.return_type orelse void; + const ParamTypes = FnInfo.params; + + return struct { + inner: GenericHook = .{}, + + const Self = @This(); + + /// Hook the function at `target` to redirect to `detour`. + pub fn attach(self: *Self, target: usize, detour: FnPtr) GenericHook.Error { + return self.inner.install(target, @intFromPtr(detour)); + } + + /// Call the original (pre-hook) function through the trampoline. + pub fn callOriginal(self: *const Self, args: anytype) ReturnType { + const orig: FnPtr = @ptrFromInt(self.inner.trampoline); + return @call(.auto, orig, coerceArgs(ParamTypes, args)); + } + + /// Unhook: restore original bytes, free trampoline. + pub fn detach(self: *Self) void { + self.inner.remove(); + } + + /// Get the trampoline as the correctly-typed function pointer. + pub fn original(self: *const Self) FnPtr { + return @ptrFromInt(self.inner.trampoline); + } + }; +} + +/// Coerce a tuple of args into the exact parameter types expected. +fn coerceArgs(comptime params: anytype, args: anytype) CoercedTuple(params) { + var result: CoercedTuple(params) = undefined; + inline for (0..params.len) |idx| { + @field(result, std.fmt.comptimePrint("{d}", .{idx})) = args[idx]; + } + return result; +} + +fn CoercedTuple(comptime params: anytype) type { + var fields: [params.len]std.builtin.Type.StructField = undefined; + inline for (0..params.len) |idx| { + fields[idx] = .{ + .name = std.fmt.comptimePrint("{d}", .{idx}), + .type = params[idx].type.?, + .default_value_ptr = null, + .is_comptime = false, + .alignment = 0, + }; + } + return @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = true, + } }); +} + +/// Resolve absolute target of a Jcc rel32 (0F 8x xx xx xx xx) — 6 byte insn. +fn jcc32Target(addr: usize) usize { + const disp: u32 = @bitCast(@as(*align(1) const i32, @ptrFromInt(addr + 2)).*); + return (addr + 6) +% disp; +} diff --git a/libs/hook/src/x86dis.zig b/libs/hook/src/x86dis.zig new file mode 100644 index 0000000..bb57720 --- /dev/null +++ b/libs/hook/src/x86dis.zig @@ -0,0 +1,402 @@ +//! Minimal x86 (32-bit) length disassembler. +//! +//! Faithful port of Vyacheslav Patkov's Hacker Disassembler Engine 32 (HDE32), +//! used by MinHook. Only computes instruction length + flags needed for +//! relocation (F_RELATIVE). ~470 bytes of table data, compiles to ~1-2 KB. + +const std = @import("std"); + +// ── public flags ─────────────────────────────────────────────────────── +pub const F_MODRM: u32 = 0x00000001; +pub const F_SIB: u32 = 0x00000002; +pub const F_IMM8: u32 = 0x00000004; +pub const F_IMM16: u32 = 0x00000008; +pub const F_IMM32: u32 = 0x00000010; +pub const F_DISP8: u32 = 0x00000020; +pub const F_DISP16: u32 = 0x00000040; +pub const F_DISP32: u32 = 0x00000080; +pub const F_RELATIVE: u32 = 0x00000100; +pub const F_ERROR: u32 = 0x00001000; + +// ── internal cflags ──────────────────────────────────────────────────── +const C_MODRM: u8 = 0x01; +const C_IMM8: u8 = 0x02; +const C_IMM16: u8 = 0x04; +const C_IMM_P66: u8 = 0x10; +const C_REL8: u8 = 0x20; +const C_REL32: u8 = 0x40; +const C_GROUP: u8 = 0x80; +const C_ERROR: u8 = 0xff; + +const PRE_NONE: u8 = 0x01; +const PRE_66: u8 = 0x08; +const PRE_67: u8 = 0x10; + +const DELTA_OPCODES: usize = 0x4a; + +// HDE32 opcode table — verbatim from table32.h +const hde32_table = [_]u8{ + 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, + 0xa8, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xac, 0xaa, 0xb2, 0xaa, 0x9f, 0x9f, + 0x9f, 0x9f, 0xb5, 0xa3, 0xa3, 0xa4, 0xaa, 0xaa, 0xba, 0xaa, 0x96, 0xaa, 0xa8, 0xaa, 0xc3, + 0xc3, 0x96, 0x96, 0xb7, 0xae, 0xd6, 0xbd, 0xa3, 0xc5, 0xa3, 0xa3, 0x9f, 0xc3, 0x9c, 0xaa, + 0xaa, 0xac, 0xaa, 0xbf, 0x03, 0x7f, 0x11, 0x7f, 0x01, 0x7f, 0x01, 0x3f, 0x01, 0x01, 0x90, + 0x82, 0x7d, 0x97, 0x59, 0x59, 0x59, 0x59, 0x59, 0x7f, 0x59, 0x59, 0x60, 0x7d, 0x7f, 0x7f, + 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x9a, 0x88, 0x7d, + 0x59, 0x50, 0x50, 0x50, 0x50, 0x59, 0x59, 0x59, 0x59, 0x61, 0x94, 0x61, 0x9e, 0x59, 0x59, + 0x85, 0x59, 0x92, 0xa3, 0x60, 0x60, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, + 0x59, 0x59, 0x9f, 0x01, 0x03, 0x01, 0x04, 0x03, 0xd5, 0x03, 0xcc, 0x01, 0xbc, 0x03, 0xf0, + 0x10, 0x10, 0x10, 0x10, 0x50, 0x50, 0x50, 0x50, 0x14, 0x20, 0x20, 0x20, 0x20, 0x01, 0x01, + 0x01, 0x01, 0xc4, 0x02, 0x10, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0xc0, 0xc2, 0x10, 0x11, + 0x02, 0x03, 0x11, 0x03, 0x03, 0x04, 0x00, 0x00, 0x14, 0x00, 0x02, 0x00, 0x00, 0xc6, 0xc8, + 0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0xff, 0xca, + 0x01, 0x01, 0x01, 0x00, 0x06, 0x00, 0x04, 0x00, 0xc0, 0xc2, 0x01, 0x01, 0x03, 0x01, 0xff, + 0xff, 0x01, 0x00, 0x03, 0xc4, 0xc4, 0xc6, 0x03, 0x01, 0x01, 0x01, 0xff, 0x03, 0x03, 0x03, + 0xc8, 0x40, 0x00, 0x0a, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x7f, 0x00, 0x33, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xbf, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, + 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x7f, 0x00, 0x00, 0xff, 0x4a, 0x4a, 0x4a, 0x4a, 0x4b, 0x52, 0x4a, 0x4a, 0x4a, 0x4a, 0x4f, + 0x4c, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x55, 0x45, 0x40, 0x4a, 0x4a, 0x4a, + 0x45, 0x59, 0x4d, 0x46, 0x4a, 0x5d, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, + 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x61, 0x63, 0x67, 0x4e, 0x4a, 0x4a, 0x6b, 0x6d, 0x4a, 0x4a, + 0x45, 0x6d, 0x4a, 0x4a, 0x44, 0x45, 0x4a, 0x4a, 0x00, 0x00, 0x00, 0x02, 0x0d, 0x06, 0x06, + 0x06, 0x06, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x06, 0x00, 0x06, 0x06, 0x02, 0x06, + 0x00, 0x0a, 0x0a, 0x07, 0x07, 0x06, 0x02, 0x05, 0x05, 0x02, 0x02, 0x00, 0x00, 0x04, 0x04, + 0x04, 0x04, 0x00, 0x00, 0x00, 0x0e, 0x05, 0x06, 0x06, 0x06, 0x01, 0x06, 0x00, 0x00, 0x08, + 0x00, 0x10, 0x00, 0x18, 0x00, 0x20, 0x00, 0x28, 0x00, 0x30, 0x00, 0x80, 0x01, 0x82, 0x01, + 0x86, 0x00, 0xf6, 0xcf, 0xfe, 0x3f, 0xab, 0x00, 0xb0, 0x00, 0xb1, 0x00, 0xb3, 0x00, 0xba, + 0xf8, 0xbb, 0x00, 0xc0, 0x00, 0xc1, 0x00, 0xc7, 0xbf, 0x62, 0xff, 0x00, 0x8d, 0xff, 0x00, + 0xc4, 0xff, 0x00, 0xc5, 0xff, 0x00, +}; + +pub const Insn = struct { + len: u8, + flags: u32, + opcode: u8, + opcode2: u8, +}; + +/// Decode the instruction at `code`, returning its length and flags. +pub fn decode(code: [*]const u8) Insn { + var result = Insn{ .len = 0, .flags = 0, .opcode = 0, .opcode2 = 0 }; + + var p: usize = 0; + var pref: u8 = 0; + var disp_size: u8 = 0; + + // ── prefixes ── + var prefix_count: u8 = 16; + prefix_loop: while (prefix_count > 0) : (prefix_count -= 1) { + switch (code[p]) { + 0xf3, 0xf2 => pref |= if (code[p] == 0xf3) 0x04 else 0x02, + 0xf0 => pref |= 0x20, // PRE_LOCK + 0x26, 0x2e, 0x36, 0x3e, 0x64, 0x65 => pref |= 0x40, // PRE_SEG + 0x66 => pref |= PRE_66, + 0x67 => pref |= PRE_67, + else => break :prefix_loop, + } + p += 1; + } + + result.flags = @as(u32, pref) << 23; + + if (pref == 0) pref |= PRE_NONE; + + // ── opcode ── + var ht_base: usize = 0; + var c = code[p]; + p += 1; + result.opcode = c; + + if (c == 0x0f) { + // two-byte opcode + result.opcode2 = code[p]; + c = code[p]; + p += 1; + ht_base = DELTA_OPCODES; + } else if (c >= 0xa0 and c <= 0xa3) { + // MOV moffs — address-size prefix swaps operand-size behavior + if (pref & PRE_67 != 0) + pref |= PRE_66 + else + pref &= ~PRE_66; + } + + const opcode = c; + + // ── two-level table lookup: ht[ht[opcode/4] + (opcode%4)] ── + var cflags: u8 = blk: { + const idx1 = ht_base + @as(usize, opcode / 4); + if (idx1 >= hde32_table.len) break :blk C_ERROR; + const idx2 = ht_base + @as(usize, hde32_table[idx1]) + @as(usize, opcode % 4); + if (idx2 >= hde32_table.len) break :blk C_ERROR; + break :blk hde32_table[idx2]; + }; + + if (cflags == C_ERROR) { + result.flags |= F_ERROR; + cflags = 0; + if ((opcode & 0xfd) == 0x24) // (opcode & -3) == 0x24 + cflags +%= 1; + } + + // ── group resolution ── + var x: u8 = 0; + if (cflags & C_GROUP != 0) { + const group_idx = ht_base + @as(usize, cflags & 0x7f); + if (group_idx + 1 < hde32_table.len) { + const t = std.mem.readInt(u16, hde32_table[group_idx..][0..2], .little); + cflags = @truncate(t); + x = @truncate(t >> 8); + } + } + + // ── modrm ── + if (cflags & C_MODRM != 0) { + result.flags |= F_MODRM; + const modrm = code[p]; + p += 1; + const m_mod = modrm >> 6; + const m_rm: u8 = modrm & 7; + const m_reg: u3 = @truncate((modrm & 0x3f) >> 3); + + // F6 TEST imm8 / F7 TEST imm16/32 + if (m_reg <= 1) { + if (opcode == 0xf6) + cflags |= C_IMM8; + if (opcode == 0xf7) + cflags |= C_IMM_P66; + } + + // displacement + switch (m_mod) { + 0 => { + if (pref & PRE_67 != 0) { + if (m_rm == 6) disp_size = 2; + } else { + if (m_rm == 5) disp_size = 4; + } + }, + 1 => disp_size = 1, + 2 => { + disp_size = 2; + if (pref & PRE_67 == 0) + disp_size = 4; + }, + else => {}, + } + + // SIB byte + if (m_mod != 3 and m_rm == 4 and (pref & PRE_67 == 0)) { + result.flags |= F_SIB; + const sib = code[p]; + p += 1; + if ((sib & 7) == 5 and (m_mod & 1) == 0) + disp_size = 4; + } + + // displacement bytes + switch (disp_size) { + 1 => { + result.flags |= F_DISP8; + p += 1; + }, + 2 => { + result.flags |= F_DISP16; + p += 2; + }, + 4 => { + result.flags |= F_DISP32; + p += 4; + }, + else => {}, + } + } + + // ── immediates ── + if (cflags & C_IMM_P66 != 0) { + if (cflags & C_REL32 != 0) { + if (pref & PRE_66 != 0) { + result.flags |= F_IMM16 | F_RELATIVE; + p += 2; + // disasm_done — skip remaining immediate checks + result.len = @intCast(p); + if (result.len > 15) { + result.flags |= F_ERROR; + result.len = 15; + } + return result; + } + // fall through to rel32_ok below + } else { + if (pref & PRE_66 != 0) { + result.flags |= F_IMM16; + p += 2; + } else { + result.flags |= F_IMM32; + p += 4; + } + } + } + + if (cflags & C_IMM16 != 0) { + if (result.flags & F_IMM32 != 0) { + result.flags |= F_IMM16; + } else if (result.flags & F_IMM16 != 0) { + // F_2IMM16 + } else { + result.flags |= F_IMM16; + } + p += 2; + } + if (cflags & C_IMM8 != 0) { + result.flags |= F_IMM8; + p += 1; + } + + if (cflags & C_REL32 != 0) { + result.flags |= F_IMM32 | F_RELATIVE; + p += 4; + } else if (cflags & C_REL8 != 0) { + result.flags |= F_IMM8 | F_RELATIVE; + p += 1; + } + + result.len = @intCast(p); + if (result.len > 15) { + result.flags |= F_ERROR; + result.len = 15; + } + return result; +} + +// ── tests ────────────────────────────────────────────────────────────── + +test "push ebp" { + const d = decode(&[_]u8{ 0x55, 0xCC }); + try std.testing.expectEqual(@as(u8, 1), d.len); +} + +test "mov ebp, esp" { + // 8B EC (or 89 E5) + const d = decode(&[_]u8{ 0x8B, 0xEC }); + try std.testing.expectEqual(@as(u8, 2), d.len); + try std.testing.expect(d.flags & F_MODRM != 0); +} + +test "call rel32" { + const d = decode(&[_]u8{ 0xE8, 0x78, 0x56, 0x34, 0x12 }); + try std.testing.expectEqual(@as(u8, 5), d.len); + try std.testing.expect(d.flags & F_RELATIVE != 0); + try std.testing.expect(d.flags & F_IMM32 != 0); +} + +test "jmp rel32" { + const d = decode(&[_]u8{ 0xE9, 0x00, 0x00, 0x00, 0x00 }); + try std.testing.expectEqual(@as(u8, 5), d.len); + try std.testing.expect(d.flags & F_RELATIVE != 0); +} + +test "sub esp, imm8" { + // 83 EC 10 + const d = decode(&[_]u8{ 0x83, 0xEC, 0x10 }); + try std.testing.expectEqual(@as(u8, 3), d.len); + try std.testing.expect(d.flags & F_MODRM != 0); + try std.testing.expect(d.flags & F_IMM8 != 0); +} + +test "mov eax, [ebp+8]" { + // 8B 45 08 + const d = decode(&[_]u8{ 0x8B, 0x45, 0x08 }); + try std.testing.expectEqual(@as(u8, 3), d.len); + try std.testing.expect(d.flags & F_MODRM != 0); + try std.testing.expect(d.flags & F_DISP8 != 0); +} + +test "jz rel32 (0F 84)" { + const d = decode(&[_]u8{ 0x0F, 0x84, 0x10, 0x00, 0x00, 0x00 }); + try std.testing.expectEqual(@as(u8, 6), d.len); + try std.testing.expect(d.flags & F_RELATIVE != 0); +} + +test "nop" { + const d = decode(&[_]u8{0x90}); + try std.testing.expectEqual(@as(u8, 1), d.len); +} + +test "ret" { + const d = decode(&[_]u8{0xC3}); + try std.testing.expectEqual(@as(u8, 1), d.len); +} + +test "short jmp EB" { + const d = decode(&[_]u8{ 0xEB, 0x05 }); + try std.testing.expectEqual(@as(u8, 2), d.len); + try std.testing.expect(d.flags & F_RELATIVE != 0); + try std.testing.expect(d.flags & F_IMM8 != 0); +} + +test "short jcc 74 (jz rel8)" { + const d = decode(&[_]u8{ 0x74, 0x0A }); + try std.testing.expectEqual(@as(u8, 2), d.len); + try std.testing.expect(d.flags & F_RELATIVE != 0); +} + +test "mov eax, imm32" { + const d = decode(&[_]u8{ 0xB8, 0x44, 0x33, 0x22, 0x11 }); + try std.testing.expectEqual(@as(u8, 5), d.len); +} + +test "push imm32" { + const d = decode(&[_]u8{ 0x68, 0x44, 0x33, 0x22, 0x11 }); + try std.testing.expectEqual(@as(u8, 5), d.len); +} + +test "push imm8" { + // 6A 01 + const d = decode(&[_]u8{ 0x6A, 0x01 }); + try std.testing.expectEqual(@as(u8, 2), d.len); +} + +test "mov [ebp-4], eax" { + // 89 45 FC + const d = decode(&[_]u8{ 0x89, 0x45, 0xFC }); + try std.testing.expectEqual(@as(u8, 3), d.len); + try std.testing.expect(d.flags & F_MODRM != 0); + try std.testing.expect(d.flags & F_DISP8 != 0); +} + +test "lea eax, [ecx+edx*4+8]" { + // 8D 44 91 08 + const d = decode(&[_]u8{ 0x8D, 0x44, 0x91, 0x08 }); + try std.testing.expectEqual(@as(u8, 4), d.len); + try std.testing.expect(d.flags & F_MODRM != 0); + try std.testing.expect(d.flags & F_SIB != 0); + try std.testing.expect(d.flags & F_DISP8 != 0); +} + +test "mov [disp32], eax" { + // A3 xx xx xx xx + const d = decode(&[_]u8{ 0xA3, 0x00, 0x10, 0x40, 0x00 }); + try std.testing.expectEqual(@as(u8, 5), d.len); +} + +test "sub esp, imm32" { + // 81 EC 00 01 00 00 + const d = decode(&[_]u8{ 0x81, 0xEC, 0x00, 0x01, 0x00, 0x00 }); + try std.testing.expectEqual(@as(u8, 6), d.len); + try std.testing.expect(d.flags & F_MODRM != 0); +} + +test "test eax, imm32 (F7 C0)" { + // F7 C0 FF 00 00 00 = test eax, 0xFF + const d = decode(&[_]u8{ 0xF7, 0xC0, 0xFF, 0x00, 0x00, 0x00 }); + try std.testing.expectEqual(@as(u8, 6), d.len); +} + +test "ret imm16" { + // C2 04 00 + const d = decode(&[_]u8{ 0xC2, 0x04, 0x00 }); + try std.testing.expectEqual(@as(u8, 3), d.len); +} diff --git a/src/framecrash/RESEARCH.md b/src/framecrash/RESEARCH.md index 9357e78..49895d0 100644 --- a/src/framecrash/RESEARCH.md +++ b/src/framecrash/RESEARCH.md @@ -343,6 +343,120 @@ but its `relativeTo` field is NULL (no target frame set). `[0, 3, 6]`, used by the width layout pass. `SetFrameHitTestMode` iterates these indices to look up anchors from the frame's anchor array. +--- + +## Third Crash: GetWidth/GetHeight Stack Parameter Mismatch + +### Problem + +After hooking vtable[1] (GetWidth) and vtable[2] (GetHeight), the game crashed +with stack corruption. The hooks were defined with signature `fn(this: u32) f32` +but the actual functions take an extra stack parameter. + +### Root Cause + +`SetFrameHitTestMode` (0x7671a0) calls `vtable[1]` with `PUSH EAX` before the +call — passing 1 stack argument. Since GetWidth/GetHeight are `__thiscall`, the +callee must clean up this argument (RET 4). Our hooks with no stack parameter +used RET 0, leaving 4 bytes on the stack after every call, causing misalignment +and eventual crash. + +### Fix + +Changed hook signatures to include the extra parameter: +```zig +fn getWidthHook(this: u32, param: u32) callconv(THISCALL) f32 +fn getHeightHook(this: u32, param: u32) callconv(THISCALL) f32 +``` + +The `param` value comes from `frame+0x58` and is passed through to the original. + +--- + +## Frame Name Discovery + +### CFrame + 0x98 = Name String Pointer + +Confirmed via Ghidra decompilation of three functions: + +**GetName virtual** at vtable[1] (0x46ff70): +```c +char * GetName(CFrame *this) { + return *(char **)(this + 0x98); // simply returns the name pointer +} +``` + +**SetFrameName** (0x76c650): +```c +void SetFrameName(CFrame *this, char *name) { + // Frees old name at +0x98 if set + // Allocates + copies new name to +0x98 +} +``` + +**CleanupRegion** (0x76c560): +```c +void CleanupRegion(CFrame *this) { + // Frees name string at +0x98 + *(this + 0x98) = NULL; + // ... other cleanup +} +``` + +### CLayoutFrame Offset + +CLayoutFrame is an inner object within CFrame, starting at CFrame + 0x24. +So given a CLayoutFrame pointer (e.g., relativeTo from an anchor): +- `CFrame base = CLayoutFrame_ptr - 0x24` +- `Frame name = *(CFrame_base + 0x98)` = `*(CLayoutFrame_ptr - 0x24 + 0x98)` + +--- + +## Destruction Path Analysis (Ghidra) + +### cleanup_linked_list_structures (0x767720) — The Bottleneck + +All frame destruction goes through this function. Exactly 3 direct callers: + +| Caller | Address | Context | +|--------|---------|---------| +| `destroy_object` | 0x7676f0 | Direct frame destruction | +| `CleanupRegion` | 0x76c560 | Region cleanup (called by WorldObjectBaseDestructor) | +| `cleanupGraphicsResources` | 0x764390 | Graphics teardown | + +### WorldObjectBaseDestructor (0x7693b0) — Common Base + +All frame-type destructors eventually call `WorldObjectBaseDestructor`, which: +1. Calls `cleanup_linked_list_structures(param_1 + 9)` — **our hook fires here** +2. Calls `CleanupRegion` — also calls `cleanup_linked_list_structures` +3. Calls `FrameScript_Destructor` (base class cleanup, list unlinking) + +16 callers of WorldObjectBaseDestructor (all are frame-type destructors): +`WorldObjectDestructor`, `ChatBubbleFrame_Destructor`, `cleanup_minimap_object`, +`CleanupLineObjectManager`, `cleanup_object_manager`, `SetAnimationRotation`, +`CleanupPlayerModel`, `DestroyButtonResources`, `DestroyEditBoxResources`, +`statusBarCleanupResources`, `cleanupMessageFrameResources`, `cleanupScrollFrame`, +`CleanupObjectManager`, `CleanupColorSelectFrame`, `CleanupMovieFrame`, `luaIsVisible` + +### DestroyFrame (0x773240) — NOT a Destruction Path + +Only called from `InitializeFrameProperties` (0x7731d0). This is a **re-initialization** +path, not frame destruction. No need to hook. + +### DestroyFrameScriptObject (0x4c34a0) — Vtable Entry, Already Covered + +Referenced only as DATA at `0x806cb8` (vtable slot). `FrameScript_Destructor` (0x4c3690) +sets the vtable to this and does list unlinking / FreeMemory. It runs **after** +`WorldObjectBaseDestructor`, so `cleanup_linked_list_structures` has already been +called by the time this executes. No need to hook. + +### Conclusion + +**Our single hook on `cleanup_linked_list_structures` covers all frame destruction paths.** +The vtable hooks (GetWidth/GetHeight/GetRelativeTo) remain as defense-in-depth but +are not expected to fire during normal operation — they would only catch bugs in +our cleanup logic or unknown destruction paths. + ### Anchor Vtable (0x0081C44C) — Full Layout ``` [0] +0x00 = 0x00767d80 → GetAnimationOrder (destructor) diff --git a/src/framecrash/framecrash.zig b/src/framecrash/framecrash.zig index 4f00783..8dfa3dd 100644 --- a/src/framecrash/framecrash.zig +++ b/src/framecrash/framecrash.zig @@ -8,11 +8,14 @@ //! 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. +//! other frames whose anchors reference the dying frame. For each, we NULL the +//! relativeTo field, preventing any stale pointer dereferences. //! -//! Defense-in-depth: vtable[3] (GetRelativeTo) hook validates returned pointers -//! with IsBadReadPtr, catching any cases the root cause fix misses. +//! Defense-in-depth: anchor vtable hooks on [1] GetWidth, [2] GetHeight, and +//! [3] GetRelativeTo. Each independently validates anchor+0x0C (relativeTo) +//! with IsBadReadPtr before use. GetWidth/GetHeight return the layout sentinel +//! from [0x00cf550c] when relativeTo is invalid. Catches cases the root cause +//! fix misses (frames destroyed through paths other than cleanup_linked_list). //! //! See RESEARCH.md for full reverse engineering notes. @@ -24,6 +27,15 @@ 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; +extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: [*:0]const u8) callconv(WINAPI) ?*anyopaque; +extern "kernel32" fn ReleaseMutex(hMutex: *anyopaque) callconv(WINAPI) i32; +extern "kernel32" fn CloseHandle(hObject: *anyopaque) callconv(WINAPI) i32; +extern "kernel32" fn GetLastError() callconv(WINAPI) u32; +extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32; +const ERROR_ALREADY_EXISTS: u32 = 183; + +var g_mutex: ?*anyopaque = null; +var g_is_hook_owner: bool = false; // ============================================================================= // Anchor vtable layout (20-byte object allocated in SetPoint / SetAnimationOrder) @@ -66,15 +78,221 @@ const GET_RELATIVE_TO_SLOT: usize = ANCHOR_VTABLE_ADDR + 0x0C; // vtable[3] 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 = .{}; +// ============================================================================= +// Second destruction path: destroyUIElement (0x7645a0) +// +// Called from cleanupGraphicsResources (UI teardown/reload) via the strata loop. +// Frees frames WITHOUT calling cleanup_linked_list_structures — just unlinks from +// lists, cleans up sub-regions, and calls FreeMemory. The dependency list at +// frame+0x34 is never walked, so other frames' anchors are left dangling. +// +// Signature: void* __thiscall destroyUIElement(void* this, byte free_flag) +// Prologue: 55 8B EC 56 8B F1 (6 bytes, no rel32) +// ============================================================================= + +const DESTROY_UI_TARGET: usize = 0x7645a0; +const DESTROY_UI_PROLOGUE_SIZE: usize = 6; + +var destroy_ui_hook: hook.Hook = .{}; + +// ============================================================================= +// Third destruction path: ProcessUIUpdateEvent (0x772ec0) +// +// Virtual function (vtable entry at 0x81c7ac), called via vtable dispatch. +// Calls CleanupUIElement + FreeMemory without cleanup_linked_list_structures. +// Signature: void* __thiscall ProcessUIUpdateEvent(void* this, byte free_flag) +// Prologue: 55 8B EC 56 8B F1 (6 bytes, no rel32) +// ============================================================================= + +const PROCESS_UI_TARGET: usize = 0x772ec0; +const PROCESS_UI_PROLOGUE_SIZE: usize = 6; + +var process_ui_hook: hook.Hook = .{}; + +// ============================================================================= +// Priority 1: Hook PauseAnimationGroup (0x767ee0) — dependency registration +// +// Records every dependency registration so we can later determine whether a +// stale relativeTo pointer was ever registered through PauseAnimationGroup. +// If PauseAnimationGroup was never called for an address, the dependency was +// never created — pointing to a race condition or unknown creation path. +// +// Signature: void __thiscall PauseAnimationGroup(ECX=relativeTo_frame, owner_frame, bitmask) +// Prologue: 55 8B EC 53 8B D9 (6 bytes, no rel32) +// RET 0x8 (callee cleans 2 stack args) +// ============================================================================= + +const PAUSE_ANIM_TARGET: usize = 0x767ee0; +const PAUSE_ANIM_PROLOGUE_SIZE: usize = 6; + +var pause_anim_hook: hook.Hook = .{}; + +// ============================================================================= +// Priority 2: Hook SetAnimationOrder (0x767c70) — anchor creation validation +// +// Validates the relativeTo param with IsBadReadPtr BEFORE the original runs. +// If relativeTo is already freed when the anchor is created, the dependency +// list node goes on dead frame memory (which may be reused). Detects race +// conditions at anchor creation time. +// +// Signature: void __thiscall SetAnimationOrder(ECX=frame, point_enum, relativeTo, +// relPoint, xOfs, yOfs, param_6) +// Prologue: 55 8B EC 8B 45 0C (6 bytes, no rel32) +// RET 0x18 (callee cleans 6 stack args) +// ============================================================================= + +const SET_ANIM_TARGET: usize = 0x767c70; +const SET_ANIM_PROLOGUE_SIZE: usize = 6; + +var set_anim_hook: hook.Hook = .{}; + +// ============================================================================= +// Dependency registration ring buffer — track PauseAnimationGroup calls +// +// When vtable hooks detect a stale pointer, we look up this buffer to answer: +// "was PauseAnimationGroup ever called for this address?" +// ============================================================================= + +const REG_HISTORY_SIZE = 2048; + +const DepRegistration = struct { + relativeTo: u32 = 0, // the frame being depended upon (ECX of PauseAnimationGroup) + owner: u32 = 0, // the frame that owns the anchor + bitmask: u32 = 0, // which anchor slots (OR of 1< best.?.seq) { + best = entry.*; + } + } + } + return best; +} + +/// Count all registrations for a given relativeTo address. +fn countRegistrations(relativeTo: u32) u32 { + var count: u32 = 0; + for (®_history) |*entry| { + if (entry.relativeTo == relativeTo) count += 1; + } + return count; +} + +// ============================================================================= +// Destruction history ring buffer — correlate stale pointers with frame names +// ============================================================================= + +const HISTORY_SIZE = 1024; + +const DestroyedFrame = struct { + addr: u32 = 0, + name: [63:0]u8 = @splat(0), +}; + +var destroy_history: [HISTORY_SIZE]DestroyedFrame = @splat(.{}); +var history_idx: u32 = 0; + +fn recordDestruction(layout_frame: u32) void { + var entry = DestroyedFrame{}; + entry.addr = layout_frame; + + if (getFrameName(layout_frame)) |name| { + const span = std.mem.span(name); + const len = @min(span.len, 63); + @memcpy(entry.name[0..len], span[0..len]); + } + + destroy_history[history_idx % HISTORY_SIZE] = entry; + history_idx +%= 1; +} + +fn lookupDestroyed(layout_frame: u32) ?[]const u8 { + for (&destroy_history) |*entry| { + if (entry.addr == layout_frame) { + const span = std.mem.sliceTo(&entry.name, 0); + return if (span.len > 0) span else null; + } + } + return null; +} + +/// Format info about a stale relativeTo for vtable hook logging. +/// Checks the ring buffer first; falls back to reading (possibly garbage) memory. +fn fmtStaleInfo(relativeTo: u32) struct { name: []const u8, saw_destroy: bool } { + if (lookupDestroyed(relativeTo)) |name| { + return .{ .name = name, .saw_destroy = true }; + } + return .{ .name = fmtFrameName(relativeTo), .saw_destroy = false }; +} + +/// Log registration status for a stale relativeTo address. +fn logRegistrationStatus(relativeTo: u32) void { + const reg_count = countRegistrations(relativeTo); + if (lookupRegistration(relativeTo)) |reg| { + con.fmt("[framecrash] DEP REGISTERED: PauseAnimGroup was called {d}x for 0x{x:0>8}, last owner=0x{x:0>8} mask=0x{x}\n", .{ + reg_count, relativeTo, reg.owner, reg.bitmask, + }); + } else { + con.fmt("[framecrash] DEP NEVER REGISTERED: PauseAnimGroup was NEVER called for 0x{x:0>8} (in {d}-entry buffer)\n", .{ + relativeTo, REG_HISTORY_SIZE, + }); + } +} + +/// Dump diagnostic info for a stale relativeTo pointer not seen in our detour. +fn dumpStaleContext(relativeTo: u32, anchor: u32) void { + // Anchor relPoint enum at +0x10 + if (IsBadReadPtr(@ptrFromInt(anchor + 0x10), 4) != 0) return; + const rel_point = readAligned(anchor + 0x10); + + // Derive owner frame: anchor lives at owner_frame + relPoint*4 + 4 + const owner_layout = anchor -% (rel_point * 4 + 4); + const owner_name = fmtFrameName(owner_layout); + con.fmt("[framecrash] owner=\"{s}\" (0x{x:0>8}), relPoint={d}, stale=0x{x:0>8}\n", .{ + owner_name, owner_layout, rel_point, relativeTo, + }); +} + /// Detour for cleanup_linked_list_structures. Runs before the original to /// walk the dying frame's dependency list and destroy referencing anchors. fn cleanupDetour(frame: u32) callconv(THISCALL) void { + // Record this frame in the destruction history before anything changes + recordDestruction(frame); + + // Count reverse dependencies for logging + const dep_count = countReverseDependencies(frame); + if (dep_count > 0) { + con.fmt("[framecrash] Destroying frame \"{s}\" (0x{x:0>8}), {d} reverse dependencies\n", .{ + fmtFrameName(frame), + frame, + dep_count, + }); + } + cleanupReverseDependencies(frame); // Call original cleanup_linked_list_structures via trampoline @@ -82,10 +300,146 @@ fn cleanupDetour(frame: 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. +/// Detour for destroyUIElement. This is the second frame destruction path, +/// called from cleanupGraphicsResources during UI teardown/reload. The original +/// frees frames without walking the dependency list, leaving stale anchors. +/// Signature: void* __thiscall destroyUIElement(void* this, byte free_flag) +fn destroyUIDetour(frame: u32, free_flag: u32) callconv(THISCALL) u32 { + // Record both possible interpretations: frame as CLayoutFrame inner, + // and frame+0x24 in case frame is actually a CFrame base. + // Anchors store CLayoutFrame inner ptrs as relativeTo. + recordDestruction(frame); + if (IsBadReadPtr(@ptrFromInt(frame + 0x24), 4) == 0) { + recordDestruction(frame + 0x24); + } + + // Try cleaning deps at both offsets. cleanupReverseDependencies is + // guarded by IsBadReadPtr so the wrong offset safely no-ops. + const dep_count_a = countReverseDependencies(frame); + const dep_count_b = countReverseDependencies(frame + 0x24); + + if (dep_count_a > 0) { + con.fmt("[framecrash] destroyUIElement frame \"{s}\" (0x{x:0>8}), {d} reverse deps (layout)\n", .{ + fmtFrameName(frame), frame, dep_count_a, + }); + cleanupReverseDependencies(frame); + } + if (dep_count_b > 0) { + con.fmt("[framecrash] destroyUIElement frame \"{s}\" (0x{x:0>8}), {d} reverse deps (inner+0x24)\n", .{ + fmtFrameName(frame + 0x24), frame + 0x24, dep_count_b, + }); + cleanupReverseDependencies(frame + 0x24); + } + + // Call original destroyUIElement via trampoline + const orig: *const fn (u32, u32) callconv(THISCALL) u32 = @ptrFromInt(destroy_ui_hook.trampoline); + return orig(frame, free_flag); +} + +/// Detour for ProcessUIUpdateEvent — third destruction path, called via vtable. +fn processUIDetour(frame: u32, free_flag: u32) callconv(THISCALL) u32 { + recordDestruction(frame); + if (IsBadReadPtr(@ptrFromInt(frame + 0x24), 4) == 0) { + recordDestruction(frame + 0x24); + } + + const dep_count_a = countReverseDependencies(frame); + const dep_count_b = countReverseDependencies(frame + 0x24); + + if (dep_count_a > 0) { + con.fmt("[framecrash] processUI frame \"{s}\" (0x{x:0>8}), {d} reverse deps (layout)\n", .{ + fmtFrameName(frame), frame, dep_count_a, + }); + cleanupReverseDependencies(frame); + } + if (dep_count_b > 0) { + con.fmt("[framecrash] processUI frame \"{s}\" (0x{x:0>8}), {d} reverse deps (inner+0x24)\n", .{ + fmtFrameName(frame + 0x24), frame + 0x24, dep_count_b, + }); + cleanupReverseDependencies(frame + 0x24); + } + + const orig: *const fn (u32, u32) callconv(THISCALL) u32 = @ptrFromInt(process_ui_hook.trampoline); + return orig(frame, free_flag); +} + +/// Detour for PauseAnimationGroup — records every dependency registration. +/// This tells us whether a stale relativeTo was ever registered through the +/// normal dependency tracking system. +/// Signature: void __thiscall PauseAnimationGroup(ECX=relativeTo_frame, owner_frame, bitmask) +fn pauseAnimDetour(relativeTo_frame: u32, owner_frame: u32, bitmask: u32) callconv(THISCALL) void { + // Record this registration + recordRegistration(relativeTo_frame, owner_frame, bitmask); + + con.fmt("[framecrash] PauseAnimGroup: relativeTo=0x{x:0>8} \"{s}\", owner=0x{x:0>8} \"{s}\", mask=0x{x}\n", .{ + relativeTo_frame, + fmtFrameName(relativeTo_frame), + owner_frame, + fmtFrameName(owner_frame), + bitmask, + }); + + // Call original + const orig = pause_anim_hook.getTrampoline(*const fn (u32, u32, u32) callconv(THISCALL) void); + orig(relativeTo_frame, owner_frame, bitmask); +} + +/// Detour for SetAnimationOrder — validates relativeTo param before anchor creation. +/// Uses cdecl thunk bridge because the function has float params. +/// cdecl args: (ecx=frame, edx=unused, point_enum, relativeTo, relPoint, xOfs_bits, yOfs_bits, param_6) +fn setAnimOrderDetour(frame: u32, _edx: u32, point_enum: u32, relativeTo: u32, rel_point: u32, x_ofs: u32, y_ofs: u32, param_6: u32) callconv(.c) void { + _ = _edx; + + // Validate relativeTo BEFORE the original creates the anchor + if (relativeTo != 0) { + if (IsBadReadPtr(@ptrFromInt(relativeTo), 0x10) != 0) { + con.fmt("[framecrash] RACE: SetAnimOrder creating anchor with INVALID relativeTo=0x{x:0>8}! frame=0x{x:0>8} \"{s}\", point={d}\n", .{ + relativeTo, + frame, + fmtFrameName(frame), + point_enum, + }); + } else if (relativeTo == frame) { + // Self-reference — the original function rejects this, but log it + con.fmt("[framecrash] SetAnimOrder: self-reference rejected, frame=0x{x:0>8}\n", .{frame}); + } + } + + // Call original trampoline as __thiscall(ECX=frame, 6 stack args). + // All args are u32 — float params (xOfs, yOfs) are passed as raw bit patterns + // which the original function reads from the stack as floats. The bit layout + // is identical because __thiscall pushes all non-this args onto the stack. + const orig: *const fn (u32, u32, u32, u32, u32, u32, u32) callconv(THISCALL) void = + @ptrFromInt(set_anim_hook.trampoline); + orig(frame, point_enum, relativeTo, rel_point, x_ofs, y_ofs, param_6); +} + +/// Count how many nodes are in the PauseAnimationGroup dependency list. +fn countReverseDependencies(dying_frame: u32) u32 { + if (IsBadReadPtr(@ptrFromInt(dying_frame + 0x34), 4) != 0) return 0; + + var node: u32 = readAligned(dying_frame + 0x34); + if (node == 0 or (node & 1) != 0) return 0; + + var count: u32 = 0; + while (node != 0 and (node & 1) == 0) { + if (IsBadReadPtr(@ptrFromInt(node), 0x10) != 0) break; + count += 1; + node = readAligned(node + 0x04); + } + return count; +} + +/// Walk the PauseAnimationGroup dependency list on the dying frame and NULL out +/// the relativeTo field in any anchors from other frames that reference it. +/// +/// Safety: purely defensive — does NOT call destructors, free nodes, or modify +/// the dying frame's list pointers. The original cleanup_linked_list_structures +/// handles its own data structures. fn cleanupReverseDependencies(dying_frame: u32) void { - // Read first node from dying_frame+0x34 + // Validate dying_frame+0x34 is readable before dereferencing + if (IsBadReadPtr(@ptrFromInt(dying_frame + 0x34), 4) != 0) return; + var node: u32 = readAligned(dying_frame + 0x34); // Validate: odd pointer or zero means empty list @@ -94,13 +448,14 @@ fn cleanupReverseDependencies(dying_frame: u32) void { var cleaned: u32 = 0; while (node != 0 and (node & 1) == 0) { - // Save next pointer before we potentially free this node + // Validate node is readable (need 0x10 bytes: link0, next, owner_frame, bitmask) + if (IsBadReadPtr(@ptrFromInt(node), 0x10) != 0) break; + 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 + if (owner_frame != 0 and IsBadReadPtr(@ptrFromInt(owner_frame), 0x28) == 0) { var bit: u5 = 0; while (bit < 9) : (bit += 1) { if ((bitmask & (@as(u32, 1) << bit)) == 0) continue; @@ -109,70 +464,123 @@ fn cleanupReverseDependencies(dying_frame: u32) void { const anchor: u32 = readAligned(anchor_slot_addr); if (anchor == 0) continue; + // Validate anchor is readable (need vtable + xOfs + yOfs + relativeTo = 0x10) + if (IsBadReadPtr(@ptrFromInt(anchor), 0x10) != 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) + // Verify vtable matches the known anchor vtable — reject garbage objects 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); + if (vtable != ANCHOR_VTABLE_ADDR) continue; - // NULL the anchor slot in the owner frame - const slot: *align(1) u32 = @ptrFromInt(anchor_slot_addr); - slot.* = 0; + // NULL the relativeTo pointer so it can't dangle. + // Don't call destructors or free the anchor — that risks cascading + // side effects and is unnecessary. A NULL relativeTo is handled + // gracefully by all code paths (luaGetPoint, GetWidth, GetHeight). + const field: *align(1) u32 = @ptrFromInt(anchor + 0x0C); + field.* = 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 }); + con.fmt("[framecrash] Nulled {d} stale relativeTo ptr(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)).*; } +/// Given a CLayoutFrame inner pointer, derive the CFrame base (subtract 0x24) +/// and read the name string at CFrame+0x98. Returns null if any pointer is +/// invalid or the name field is NULL. +fn getFrameName(layout_frame: u32) ?[*:0]const u8 { + if (layout_frame < 0x24) return null; + const frame_base = layout_frame -% 0x24; + + // Validate that frame_base+0x98 (name pointer field) is readable + if (IsBadReadPtr(@ptrFromInt(frame_base + 0x98), 4) != 0) return null; + + const name_ptr = readAligned(frame_base + 0x98); + if (name_ptr == 0) return null; + + // Validate the name string itself is readable (at least 1 byte) + if (IsBadReadPtr(@ptrFromInt(name_ptr), 1) != 0) return null; + + return @ptrFromInt(name_ptr); +} + +/// Format a frame name for logging — returns "FrameName" or "(unnamed)". +fn fmtFrameName(layout_frame: u32) []const u8 { + if (getFrameName(layout_frame)) |name| { + return std.mem.span(name); + } + return "(unnamed)"; +} + // ============================================================================= -// Defense-in-depth: GetRelativeTo vtable hook +// Defense-in-depth: anchor vtable hooks +// +// Three vtable slots must be hooked because they independently read anchor+0x0C +// (relativeTo) without going through each other: +// [1] GetWidth — reads [this+0xC]+0x3C, crashes if relativeTo is NULL/dangling +// [2] GetHeight — same pattern as GetWidth +// [3] GetRelativeTo — returns *(this+0x0C), caller dereferences it +// +// GetRelativeTo NULLs the pointer on detection (self-heal). GetWidth/GetHeight +// must independently handle both NULL and dangling relativeTo by returning the +// sentinel value from [0x00cf550c] — the value SetFrameHitTestMode compares +// against to detect "no dimension available". // ============================================================================= +const GET_WIDTH_SLOT: usize = ANCHOR_VTABLE_ADDR + 0x04; // vtable[1] +const GET_HEIGHT_SLOT: usize = ANCHOR_VTABLE_ADDR + 0x08; // vtable[2] + +/// Sentinel float that SetFrameHitTestMode compares GetWidth/GetHeight results +/// against. Stored at runtime in .bss at 0x00cf550c. +const SENTINEL_ADDR: usize = 0x00cf550c; + +var orig_get_width: usize = 0; +var orig_get_height: usize = 0; 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. +/// Check if a relativeTo pointer (CLayoutFrame inner ptr) is valid. +/// Returns true if the pointer is non-NULL and the frame base region is readable. +fn isRelativeToValid(relativeTo: u32) bool { + if (relativeTo == 0) return false; + // relativeTo is an inner offset; callers subtract 0x24 for the frame base. + // Validate that the frame base region (vtable + lua ref + index) is readable. + return IsBadReadPtr(@ptrFromInt(relativeTo -% 0x24), 0x10) == 0; +} + +/// Hook for vtable[3] GetRelativeTo. Validates the stored pointer. +/// If stale, NULLs anchor+0x0C and returns 0 (safe "no relativeTo" path). fn getRelativeToHook(this: u32) callconv(THISCALL) u32 { - // 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 + if (!isRelativeToValid(result)) { + const info = fmtStaleInfo(result); + if (info.saw_destroy) { + con.fmt("[framecrash] STALE: frame \"{s}\" (0x{x:0>8}) went through detour but dep list missed anchor 0x{x:0>8}, detected in GetRelativeTo\n", .{ + info.name, result, this, + }); + } else { + con.fmt("[framecrash] STALE: frame 0x{x:0>8} NOT seen in detour, anchor 0x{x:0>8}, detected in GetRelativeTo\n", .{ + result, this, + }); + dumpStaleContext(result, this); + } + logRegistrationStatus(result); const field: *align(1) u32 = @ptrFromInt(this + 0x0C); field.* = 0; return 0; @@ -181,22 +589,189 @@ fn getRelativeToHook(this: u32) callconv(THISCALL) u32 { return result; } +/// Hook for vtable[1] GetWidth. Checks anchor+0x0C before calling original. +/// Returns sentinel if relativeTo is NULL or dangling. +/// Signature: f32 __thiscall GetWidth(this, u32 param) — callee cleans 1 stack arg. +fn getWidthHook(this: u32, param: u32) callconv(THISCALL) f32 { + const relativeTo: u32 = readAligned(this + 0x0C); + if (!isRelativeToValid(relativeTo)) { + // Self-heal if dangling (not just NULL) + if (relativeTo != 0) { + const info = fmtStaleInfo(relativeTo); + if (info.saw_destroy) { + con.fmt("[framecrash] STALE: frame \"{s}\" (0x{x:0>8}) went through detour but dep list missed anchor 0x{x:0>8}, detected in GetWidth\n", .{ + info.name, relativeTo, this, + }); + } else { + con.fmt("[framecrash] STALE: frame 0x{x:0>8} NOT seen in detour, anchor 0x{x:0>8}, detected in GetWidth\n", .{ + relativeTo, this, + }); + dumpStaleContext(relativeTo, this); + } + logRegistrationStatus(relativeTo); + const field: *align(1) u32 = @ptrFromInt(this + 0x0C); + field.* = 0; + } + return @as(*align(1) const f32, @ptrFromInt(SENTINEL_ADDR)).*; + } + + const orig: *const fn (u32, u32) callconv(THISCALL) f32 = @ptrFromInt(orig_get_width); + return orig(this, param); +} + +/// Hook for vtable[2] GetHeight. Same pattern as GetWidth. +/// Signature: f32 __thiscall GetHeight(this, u32 param) — callee cleans 1 stack arg. +fn getHeightHook(this: u32, param: u32) callconv(THISCALL) f32 { + const relativeTo: u32 = readAligned(this + 0x0C); + if (!isRelativeToValid(relativeTo)) { + if (relativeTo != 0) { + const info = fmtStaleInfo(relativeTo); + if (info.saw_destroy) { + con.fmt("[framecrash] STALE: frame \"{s}\" (0x{x:0>8}) went through detour but dep list missed anchor 0x{x:0>8}, detected in GetHeight\n", .{ + info.name, relativeTo, this, + }); + } else { + con.fmt("[framecrash] STALE: frame 0x{x:0>8} NOT seen in detour, anchor 0x{x:0>8}, detected in GetHeight\n", .{ + relativeTo, this, + }); + dumpStaleContext(relativeTo, this); + } + logRegistrationStatus(relativeTo); + const field: *align(1) u32 = @ptrFromInt(this + 0x0C); + field.* = 0; + } + return @as(*align(1) const f32, @ptrFromInt(SENTINEL_ADDR)).*; + } + + const orig: *const fn (u32, u32) callconv(THISCALL) f32 = @ptrFromInt(orig_get_height); + return orig(this, param); +} + // ============================================================================= // Module API // ============================================================================= +fn patchVtableSlot(slot_addr: usize, new_fn: usize, save_to: *usize) void { + save_to.* = hook.readMem(u32, slot_addr); + const new_val: u32 = @intCast(new_fn); + hook.writeProtected(slot_addr, std.mem.asBytes(&new_val)); +} + +fn restoreVtableSlot(slot_addr: usize, saved: *usize) void { + if (saved.* != 0) { + const orig: u32 = @intCast(saved.*); + hook.writeProtected(slot_addr, std.mem.asBytes(&orig)); + saved.* = 0; + } +} + pub fn installHooks() void { - // Root cause fix: detour cleanup_linked_list_structures to clean up + // Multi-DLL safety: only one instance per process should hook + var mutex_name_buf: [64]u8 = undefined; + const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\FramecrashHook_{d}", .{GetCurrentProcessId()}) catch return; + mutex_name_buf[mutex_name.len] = 0; + + g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); + if (g_mutex == null) return; + + if (GetLastError() == ERROR_ALREADY_EXISTS) { + _ = CloseHandle(g_mutex.?); + g_mutex = null; + g_is_hook_owner = false; + con.print("[framecrash] Another DLL owns hooks (mutex taken), skipping\n"); + return; + } + g_is_hook_owner = true; + + // Root cause fix #1: detour cleanup_linked_list_structures to clean up // reverse anchor references before the frame is destroyed. // Prologue: 53 56 8B F1 57 (5 bytes, no rel32 fixups needed) - // if (!cleanup_hook.install(CLEANUP_TARGET, CLEANUP_PROLOGUE_SIZE, @intFromPtr(&cleanupDetour), &.{})) { - // con.print("[framecrash] ERROR: Failed to install frame cleanup detour\n"); - // } else { - // con.print("[framecrash] Frame cleanup detour installed\n"); - // } + 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"); + } + + // Root cause fix #2: detour destroyUIElement — the second destruction path + // used by cleanupGraphicsResources during UI teardown/reload. This path + // frees frames without walking the dependency list. + // Prologue: 55 8B EC 56 8B F1 (6 bytes, no rel32 fixups needed) + if (!destroy_ui_hook.install(DESTROY_UI_TARGET, DESTROY_UI_PROLOGUE_SIZE, @intFromPtr(&destroyUIDetour), &.{})) { + con.print("[framecrash] ERROR: Failed to install destroyUIElement detour\n"); + } else { + con.print("[framecrash] destroyUIElement detour installed\n"); + } + + // Root cause fix #3: detour ProcessUIUpdateEvent — virtual function that + // calls CleanupUIElement + FreeMemory without layout cleanup. + // Prologue: 55 8B EC 56 8B F1 (6 bytes, no rel32 fixups needed) + if (!process_ui_hook.install(PROCESS_UI_TARGET, PROCESS_UI_PROLOGUE_SIZE, @intFromPtr(&processUIDetour), &.{})) { + con.print("[framecrash] ERROR: Failed to install ProcessUIUpdateEvent detour\n"); + } else { + con.print("[framecrash] ProcessUIUpdateEvent detour installed\n"); + } + + // Defense-in-depth: patch anchor vtable[1]/[2]/[3] to validate relativeTo + // before use. Catches dangling pointers from any destruction path. + patchVtableSlot(GET_WIDTH_SLOT, @intFromPtr(&getWidthHook), &orig_get_width); + patchVtableSlot(GET_HEIGHT_SLOT, @intFromPtr(&getHeightHook), &orig_get_height); + patchVtableSlot(GET_RELATIVE_TO_SLOT, @intFromPtr(&getRelativeToHook), &orig_get_relative_to); + con.print("[framecrash] Anchor vtable hooks installed (GetWidth/GetHeight/GetRelativeTo)\n"); + + // Diagnostic: hook PauseAnimationGroup to track dependency registrations. + // Answers: "was a dependency ever registered for this stale address?" + // Prologue: 55 8B EC 53 8B D9 (6 bytes, no rel32) + if (!pause_anim_hook.install(PAUSE_ANIM_TARGET, PAUSE_ANIM_PROLOGUE_SIZE, @intFromPtr(&pauseAnimDetour), &.{})) { + con.print("[framecrash] ERROR: Failed to install PauseAnimationGroup detour\n"); + } else { + con.print("[framecrash] PauseAnimationGroup detour installed\n"); + } + + // Diagnostic: hook SetAnimationOrder to detect race conditions. + // Validates relativeTo param BEFORE anchor creation. + // Prologue: 55 8B EC 8B 45 0C (6 bytes, no rel32) + // Uses fastcall-to-cdecl thunk because of float stack params. + if (set_anim_hook.prepare(SET_ANIM_TARGET, SET_ANIM_PROLOGUE_SIZE, &.{})) { + const thunk = set_anim_hook.mem.? + 32; + _ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&setAnimOrderDetour), 6); + set_anim_hook.activate(@intFromPtr(thunk)); + con.print("[framecrash] SetAnimationOrder detour installed\n"); + } else { + con.print("[framecrash] ERROR: Failed to install SetAnimationOrder detour\n"); + } } pub fn removeHooks() void { - cleanup_hook.remove(); - con.print("[framecrash] Frame cleanup detour removed\n"); + if (g_is_hook_owner) { + // Remove diagnostic hooks first (reverse install order) + set_anim_hook.remove(); + con.print("[framecrash] SetAnimationOrder detour removed\n"); + + pause_anim_hook.remove(); + con.print("[framecrash] PauseAnimationGroup detour removed\n"); + + // Restore original vtable pointers (reverse order) + restoreVtableSlot(GET_RELATIVE_TO_SLOT, &orig_get_relative_to); + restoreVtableSlot(GET_HEIGHT_SLOT, &orig_get_height); + restoreVtableSlot(GET_WIDTH_SLOT, &orig_get_width); + con.print("[framecrash] Anchor vtable hooks removed\n"); + + process_ui_hook.remove(); + con.print("[framecrash] ProcessUIUpdateEvent detour removed\n"); + + destroy_ui_hook.remove(); + con.print("[framecrash] destroyUIElement detour removed\n"); + + cleanup_hook.remove(); + con.print("[framecrash] Frame cleanup detour removed\n"); + } + + if (g_is_hook_owner) { + if (g_mutex) |m| { + _ = ReleaseMutex(m); + _ = CloseHandle(m); + g_mutex = null; + } + } + g_is_hook_owner = false; } diff --git a/src/markers/assets/Spells/Raid_UI_FX_Cyan.m2 b/src/markers/assets/Spells/Raid_UI_FX_Cyan.m2 index 3a29cd4..a08ce14 100644 Binary files a/src/markers/assets/Spells/Raid_UI_FX_Cyan.m2 and b/src/markers/assets/Spells/Raid_UI_FX_Cyan.m2 differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Green.m2 b/src/markers/assets/Spells/Raid_UI_FX_Green.m2 index 6b3957d..3831cf0 100644 Binary files a/src/markers/assets/Spells/Raid_UI_FX_Green.m2 and b/src/markers/assets/Spells/Raid_UI_FX_Green.m2 differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Purple.m2 b/src/markers/assets/Spells/Raid_UI_FX_Purple.m2 index 7f77b48..da31645 100644 Binary files a/src/markers/assets/Spells/Raid_UI_FX_Purple.m2 and b/src/markers/assets/Spells/Raid_UI_FX_Purple.m2 differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Red.m2 b/src/markers/assets/Spells/Raid_UI_FX_Red.m2 index a250afa..b3091cd 100644 Binary files a/src/markers/assets/Spells/Raid_UI_FX_Red.m2 and b/src/markers/assets/Spells/Raid_UI_FX_Red.m2 differ diff --git a/src/markers/assets/Spells/Raid_UI_FX_Yellow.m2 b/src/markers/assets/Spells/Raid_UI_FX_Yellow.m2 index d4419f1..1cf673f 100644 Binary files a/src/markers/assets/Spells/Raid_UI_FX_Yellow.m2 and b/src/markers/assets/Spells/Raid_UI_FX_Yellow.m2 differ