Add generic hook module (x86dis + auto-sizing detour), framecrash updates, marker assets

Hook lib: port HDE32 length disassembler to Zig (x86dis.zig), add GenericHook
with automatic prologue sizing and relative instruction relocation, add
Detour(FnType) comptime-generic type-safe hook wrapper. Update build.zig
to expose x86dis, generic_hook, hook as separate modules.

Framecrash: expand vtable hook research and implementation.
Markers: updated raid marker M2 assets.
This commit is contained in:
MarcelineVQ
2026-02-28 17:46:03 -08:00
parent 2cca8f4b53
commit 608e1c43d5
11 changed files with 1713 additions and 53 deletions
+273
View File
@@ -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
+17
View File
@@ -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);
}
+279
View File
@@ -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;
}
+402
View File
@@ -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);
}
+114
View File
@@ -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)
+628 -53
View File
@@ -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<<point_enum)
seq: u32 = 0, // monotonic sequence number for ordering
};
var reg_history: [REG_HISTORY_SIZE]DepRegistration = @splat(.{});
var reg_idx: u32 = 0;
fn recordRegistration(relativeTo: u32, owner: u32, bitmask: u32) void {
const seq = reg_idx;
reg_history[reg_idx % REG_HISTORY_SIZE] = .{
.relativeTo = relativeTo,
.owner = owner,
.bitmask = bitmask,
.seq = seq,
};
reg_idx +%= 1;
}
/// Look up whether PauseAnimationGroup was ever called for a given relativeTo address.
/// Returns the most recent registration entry if found, null otherwise.
fn lookupRegistration(relativeTo: u32) ?DepRegistration {
// Search backwards from most recent for best chance of finding it
var best: ?DepRegistration = null;
for (&reg_history) |*entry| {
if (entry.relativeTo == relativeTo) {
if (best == null or entry.seq > best.?.seq) {
best = entry.*;
}
}
}
return best;
}
/// Count all registrations for a given relativeTo address.
fn countRegistrations(relativeTo: u32) u32 {
var count: u32 = 0;
for (&reg_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;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.