Add module control API, bigcursor with fractional scaling, shared mutex

Module control API:
- Three cdecl exports: WeirdUtils_IsModuleActive, WeirdUtils_DisableModule, WeirdUtils_DisableAll
- C header include/weirdutils_api.h for runtime DLL discovery
- All modules gain pub module_name, isActive(), shared mutex via src/mutex.zig
- build.zig refactored to module_list array of ModuleDesc

Bigcursor:
- D3D9 vtable hooks on SetCursorProperties/ShowCursor
- Scale2x/Scale3x pixel-art upscalers in pure Zig (replaces 12K-line hqx C)
- Bilinear resampler for fractional scales (1.0-4.0, default 1.2)
- Win32 cursor via CreateIconIndirect bypasses D3D9 32x32 limit
- FNV-1a hash cache for ~16 cursor bitmaps
- Lua API: SetCursorScale(n) / GetCursorScale()
- CVar cursorScale for Config.wtf persistence (tenths)

Other:
- Add dpslog module stub
- Docs and README updates
This commit is contained in:
MarcelineVQ
2026-03-06 13:27:35 -08:00
parent 0783f3b0dd
commit 2aaa089c76
44 changed files with 1904 additions and 1022 deletions
+669 -27
View File
@@ -1,51 +1,693 @@
//! Big cursor module.
//!
//! Increases the hardware cursor render size for improved visibility.
//! Hooks IDirect3DDevice9::SetCursorProperties to upscale the hardware cursor
//! using the hqx pixel-art scaling algorithm, then sets an enlarged Win32 cursor
//! via CreateIconIndirect (bypassing D3D9's 32x32 limit).
//!
//! TODO: Research cursor rendering pipeline and hook implementation.
//! Supports fractional scales (e.g. 1.5x): hqx to next integer, then bilinear
//! downsample to exact target size.
const std = @import("std");
const hook = @import("zhook");
const con = @import("../console.zig");
const mod_mutex = @import("../mutex.zig");
const WINAPI = std.builtin.CallingConvention.winapi;
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;
const sc: std.builtin.CallingConvention = .{ .x86_stdcall = .{} };
const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
pub const module_name: [*:0]const u8 = "bigcursor";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn installHooks() void {
con.print("[bigcursor] Module loaded (stub)\n");
// =============================================================================
// File logging (survives crashes — console closes too fast)
// =============================================================================
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_BigcursorHook_{d}", .{GetCurrentProcessId()}) catch return;
mutex_name_buf[mutex_name.len] = 0;
extern "kernel32" fn CreateFileA(name: [*:0]const u8, access: u32, share: u32, sa: ?*anyopaque, disp: u32, flags: u32, template: ?*anyopaque) callconv(WINAPI) ?*anyopaque;
extern "kernel32" fn WriteFile(handle: *anyopaque, buf: [*]const u8, len: u32, written: ?*u32, overlapped: ?*anyopaque) callconv(WINAPI) i32;
extern "kernel32" fn FlushFileBuffers(handle: *anyopaque) callconv(WINAPI) i32;
extern "kernel32" fn CloseHandle(handle: *anyopaque) callconv(WINAPI) i32;
g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0]));
if (g_mutex == null) return;
const INVALID_HANDLE: usize = 0xFFFFFFFF;
var g_logfile: ?*anyopaque = null;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
_ = CloseHandle(g_mutex.?);
g_mutex = null;
g_is_hook_owner = false;
con.print("[bigcursor] Another DLL owns hooks (mutex taken), skipping\n");
fn logInit() void {
const h = CreateFileA("bigcursor_debug.log", 0x40000000, 1, null, 2, 0x80, null); // GENERIC_WRITE, FILE_SHARE_READ, CREATE_ALWAYS, NORMAL
if (h) |handle| {
if (@intFromPtr(handle) != INVALID_HANDLE) {
g_logfile = handle;
}
}
}
fn logDeinit() void {
if (g_logfile) |h| {
_ = CloseHandle(h);
g_logfile = null;
}
}
fn log(msg: []const u8) void {
con.print(msg);
if (g_logfile) |h| {
_ = WriteFile(h, msg.ptr, @intCast(msg.len), null, null);
_ = FlushFileBuffers(h);
}
}
fn logFmt(comptime f: []const u8, args: anytype) void {
var buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, f, args) catch return;
log(msg);
}
// =============================================================================
// Scale2x — edge-aware pixel-art 2x upscaler (EPX/AdvMAME2x)
//
// For each pixel P with neighbors A(up) B(right) C(left) D(down):
// E0 = (C==A && C!=D && A!=B) ? A : P E1 = (A==B && A!=C && B!=D) ? B : P
// E2 = (D==C && D!=B && C!=A) ? C : P E3 = (B==D && B!=A && D!=C) ? D : P
// =============================================================================
fn scale2x(src: [*]const u32, src_w: u32, src_h: u32, dst: [*]u32, dst_w: u32) void {
for (0..src_h) |jj| {
const j: u32 = @intCast(jj);
for (0..src_w) |ii| {
const i: u32 = @intCast(ii);
const p = src[j * src_w + i];
const a = if (j > 0) src[(j - 1) * src_w + i] else p;
const b = if (i < src_w - 1) src[j * src_w + i + 1] else p;
const c = if (i > 0) src[j * src_w + i - 1] else p;
const d = if (j < src_h - 1) src[(j + 1) * src_w + i] else p;
const oy = j * 2;
const ox = i * 2;
dst[oy * dst_w + ox] = if (c == a and c != d and a != b) a else p;
dst[oy * dst_w + ox + 1] = if (a == b and a != c and b != d) b else p;
dst[(oy + 1) * dst_w + ox] = if (d == c and d != b and c != a) c else p;
dst[(oy + 1) * dst_w + ox + 1] = if (b == d and b != a and d != c) d else p;
}
}
}
// =============================================================================
// Scale3x — edge-aware pixel-art 3x upscaler (AdvMAME3x)
//
// Neighbors: A(up) B(right) C(left) D(down) + diagonals
// Uses same edge detection as Scale2x, extended to 3x3 output block.
// =============================================================================
fn scale3x(src: [*]const u32, src_w: u32, src_h: u32, dst: [*]u32, dst_w: u32) void {
for (0..src_h) |jj| {
const j: u32 = @intCast(jj);
for (0..src_w) |ii| {
const i: u32 = @intCast(ii);
const idx = j * src_w + i;
const e = src[idx]; // center pixel (P)
const b = if (j > 0) src[idx - src_w] else e; // up
const h = if (j < src_h - 1) src[idx + src_w] else e; // down
const d = if (i > 0) src[idx - 1] else e; // left
const f = if (i < src_w - 1) src[idx + 1] else e; // right
const a = if (j > 0 and i > 0) src[idx - src_w - 1] else e;
const c = if (j > 0 and i < src_w - 1) src[idx - src_w + 1] else e;
const g = if (j < src_h - 1 and i > 0) src[idx + src_w - 1] else e;
const ii_ = if (j < src_h - 1 and i < src_w - 1) src[idx + src_w + 1] else e;
const oy = j * 3;
const ox = i * 3;
if (b != h and d != f) {
dst[oy * dst_w + ox] = if (d == b) d else e;
dst[oy * dst_w + ox + 1] = if (d == b and e != c or b == f and e != a) b else e;
dst[oy * dst_w + ox + 2] = if (b == f) f else e;
dst[(oy + 1) * dst_w + ox] = if (d == b and e != g or d == h and e != a) d else e;
dst[(oy + 1) * dst_w + ox + 1] = e;
dst[(oy + 1) * dst_w + ox + 2] = if (b == f and e != ii_ or h == f and e != c) f else e;
dst[(oy + 2) * dst_w + ox] = if (d == h) d else e;
dst[(oy + 2) * dst_w + ox + 1] = if (d == h and e != ii_ or h == f and e != g) h else e;
dst[(oy + 2) * dst_w + ox + 2] = if (h == f) f else e;
} else {
dst[oy * dst_w + ox] = e;
dst[oy * dst_w + ox + 1] = e;
dst[oy * dst_w + ox + 2] = e;
dst[(oy + 1) * dst_w + ox] = e;
dst[(oy + 1) * dst_w + ox + 1] = e;
dst[(oy + 1) * dst_w + ox + 2] = e;
dst[(oy + 2) * dst_w + ox] = e;
dst[(oy + 2) * dst_w + ox + 1] = e;
dst[(oy + 2) * dst_w + ox + 2] = e;
}
}
}
}
// =============================================================================
// Win32 externs
// =============================================================================
extern "kernel32" fn VirtualProtect(addr: *anyopaque, size: usize, new_prot: u32, old_prot: *u32) callconv(WINAPI) i32;
extern "gdi32" fn CreateBitmap(w: i32, h: i32, planes: u32, bpp: u32, bits: ?*const anyopaque) callconv(WINAPI) ?*anyopaque;
extern "gdi32" fn DeleteObject(obj: *anyopaque) callconv(WINAPI) i32;
extern "user32" fn CreateIconIndirect(info: *IconInfo) callconv(WINAPI) ?*anyopaque;
extern "user32" fn DestroyCursor(cursor: *anyopaque) callconv(WINAPI) i32;
extern "user32" fn SetCursor(cursor: ?*anyopaque) callconv(WINAPI) ?*anyopaque;
const IconInfo = extern struct {
fIcon: i32 = 0,
xHotspot: u32 = 0,
yHotspot: u32 = 0,
hbmMask: ?*anyopaque = null,
hbmColor: ?*anyopaque = null,
};
// =============================================================================
// WoW CVar API (1.12.1 build 5875)
// =============================================================================
const CVAR_LOOKUP: usize = 0x0063DEC0;
// RegisterCVar: __fastcall(ECX=name, EDX=help, stack: unk1, default, callback, category, unk2, unk3)
const RegisterCVarFn = *const fn ([*:0]const u8, u32, u32, [*:0]const u8, u32, u32, u32, u32) callconv(fc) u32;
const registerCVar: RegisterCVarFn = @ptrFromInt(0x0063DB90);
const CVAR_NAME = "cursorScale";
/// Read CVar integer value (tenths: 15 = 1.5x). Returns f32 scale.
fn readCVarScaleInit() f32 {
const cvar_ptr = hook.fastcall(u32, CVAR_LOOKUP, @intFromPtr(@as([*:0]const u8, CVAR_NAME)), @as(u32, 0));
if (cvar_ptr == 0) return g_scale_f;
// CVar struct: integer value at offset +40 bytes (10 pointer-sized fields)
const val = hook.readMem(i32, cvar_ptr + 40);
if (val >= 10 and val <= 40) {
return @as(f32, @floatFromInt(val)) / 10.0;
}
return g_scale_f;
}
// =============================================================================
// D3D9 vtable indices
// =============================================================================
const VT_SetCursorProperties: usize = 10;
const VT_ShowCursor: usize = 12;
// IDirect3DSurface9 vtable
const SVT_GetDesc: usize = 12;
const SVT_LockRect: usize = 13;
const SVT_UnlockRect: usize = 14;
// D3D9 constants
const D3DFMT_A8R8G8B8: u32 = 21;
// Game's GxDevice → IDirect3DDevice9 pointer chain
const GX_DEVICE_PTR: usize = 0xC0ED38;
const GX_DEVICE_D3D_OFFSET: usize = 0x38A8;
// =============================================================================
// COM helpers
// =============================================================================
inline fn vtbl(obj: *anyopaque) [*]usize {
return @ptrFromInt(hook.readMem(u32, @intFromPtr(obj)));
}
// =============================================================================
// State
// =============================================================================
var d3d9_vtable: ?[*]usize = null;
var orig_set_cursor_props: usize = 0;
var orig_show_cursor: usize = 0;
var hooks_installed: bool = false;
var g_scale_f: f32 = 1.2; // fractional scale (1.0 = off, 1.2 = default, etc.)
var g_hcursor: ?*anyopaque = null; // current enlarged HCURSOR
var g_cursor_visible: bool = false;
// Simple pixel hash cache for ~10 distinct WoW cursor bitmaps.
// Cache key includes scale so changing scale invalidates.
const MAX_CACHE = 16;
const CacheEntry = struct {
hash: u64 = 0,
hcursor: ?*anyopaque = null,
valid: bool = false,
};
var g_cache: [MAX_CACHE]CacheEntry = [_]CacheEntry{.{}} ** MAX_CACHE;
var g_cache_count: u32 = 0;
// =============================================================================
// FNV-1a hash for cursor pixel data (includes scale in hash)
// =============================================================================
fn hashPixels(data: [*]const u8, len: usize, scale_bits: u32) u64 {
var h: u64 = 0xcbf29ce484222325;
// Mix scale into hash so different scales produce different cache keys
h ^= scale_bits;
h *%= 0x100000001b3;
for (data[0..len]) |byte| {
h ^= byte;
h *%= 0x100000001b3;
}
return h;
}
// =============================================================================
// Surface helpers
// =============================================================================
const D3DLOCKED_RECT = extern struct {
Pitch: i32 = 0,
pBits: ?[*]u8 = null,
};
const D3DSURFACE_DESC = extern struct {
Format: u32 = 0,
Type: u32 = 0,
Usage: u32 = 0,
Pool: u32 = 0,
MultiSampleType: u32 = 0,
MultiSampleQuality: u32 = 0,
Width: u32 = 0,
Height: u32 = 0,
};
fn surfaceGetDesc(surface: *anyopaque, desc: *D3DSURFACE_DESC) i32 {
const f: *const fn (*anyopaque, *D3DSURFACE_DESC) callconv(sc) i32 = @ptrFromInt(vtbl(surface)[SVT_GetDesc]);
return f(surface, desc);
}
fn surfaceLockRect(surface: *anyopaque, locked: *D3DLOCKED_RECT, rect: ?*anyopaque, flags: u32) i32 {
const f: *const fn (*anyopaque, *D3DLOCKED_RECT, ?*anyopaque, u32) callconv(sc) i32 = @ptrFromInt(vtbl(surface)[SVT_LockRect]);
return f(surface, locked, rect, flags);
}
fn surfaceUnlockRect(surface: *anyopaque) i32 {
const f: *const fn (*anyopaque) callconv(sc) i32 = @ptrFromInt(vtbl(surface)[SVT_UnlockRect]);
return f(surface);
}
// =============================================================================
// Bilinear resampler (downsample hqx output to exact target size)
// =============================================================================
fn lerpChannel(a: u32, b: u32, c: u32, d: u32, fx: f32, fy: f32) u8 {
const fa: f32 = @floatFromInt(a);
const fb: f32 = @floatFromInt(b);
const fc_: f32 = @floatFromInt(c);
const fd: f32 = @floatFromInt(d);
const val = fa * (1.0 - fx) * (1.0 - fy) + fb * fx * (1.0 - fy) + fc_ * (1.0 - fx) * fy + fd * fx * fy;
return @intFromFloat(@min(@max(val, 0.0), 255.0));
}
fn bilinearResample(
src: [*]const u32,
src_w: u32,
src_h: u32,
dst: [*]u32,
dst_w: u32,
dst_h: u32,
) void {
const sw_f: f32 = @floatFromInt(src_w);
const sh_f: f32 = @floatFromInt(src_h);
const dw_f: f32 = @floatFromInt(dst_w);
const dh_f: f32 = @floatFromInt(dst_h);
for (0..dst_h) |dy| {
const sy_f = (@as(f32, @floatFromInt(dy)) + 0.5) * sh_f / dh_f - 0.5;
const sy_floor = @max(sy_f, 0.0);
const sy0: u32 = @intFromFloat(sy_floor);
const sy1 = @min(sy0 + 1, src_h - 1);
const fy = sy_f - @as(f32, @floatFromInt(sy0));
for (0..dst_w) |dx| {
const sx_f = (@as(f32, @floatFromInt(dx)) + 0.5) * sw_f / dw_f - 0.5;
const sx_floor = @max(sx_f, 0.0);
const sx0: u32 = @intFromFloat(sx_floor);
const sx1 = @min(sx0 + 1, src_w - 1);
const fx = sx_f - @as(f32, @floatFromInt(sx0));
const c00 = src[sy0 * src_w + sx0];
const c10 = src[sy0 * src_w + sx1];
const c01 = src[sy1 * src_w + sx0];
const c11 = src[sy1 * src_w + sx1];
const b = lerpChannel(c00 & 0xFF, c10 & 0xFF, c01 & 0xFF, c11 & 0xFF, fx, fy);
const g = lerpChannel((c00 >> 8) & 0xFF, (c10 >> 8) & 0xFF, (c01 >> 8) & 0xFF, (c11 >> 8) & 0xFF, fx, fy);
const r = lerpChannel((c00 >> 16) & 0xFF, (c10 >> 16) & 0xFF, (c01 >> 16) & 0xFF, (c11 >> 16) & 0xFF, fx, fy);
const a = lerpChannel((c00 >> 24) & 0xFF, (c10 >> 24) & 0xFF, (c01 >> 24) & 0xFF, (c11 >> 24) & 0xFF, fx, fy);
dst[dy * dst_w + dx] = @as(u32, a) << 24 | @as(u32, r) << 16 | @as(u32, g) << 8 | @as(u32, b);
}
}
}
// =============================================================================
// Create enlarged Win32 cursor from BGRA pixel data
// =============================================================================
// Static buffers — single-threaded D3D9 calls, no contention.
var s_src_buf: [32 * 32]u32 = undefined;
var s_scaled_buf: [128 * 128]u32 = undefined; // Scale2x/3x output
var s_dst_buf: [128 * 128]u32 = undefined; // final output (after bilinear)
var s_mask_buf: [128 * 128 / 8]u8 = undefined;
fn createEnlargedCursor(
src_pixels: [*]const u8,
src_pitch: u32,
src_w: u32,
src_h: u32,
hotspot_x: u32,
hotspot_y: u32,
scale_f: f32,
) ?*anyopaque {
// Compute final target dimensions
const dst_w: u32 = @intFromFloat(@as(f32, @floatFromInt(src_w)) * scale_f);
const dst_h: u32 = @intFromFloat(@as(f32, @floatFromInt(src_h)) * scale_f);
if (dst_w > 128 or dst_h > 128 or dst_w < 1 or dst_h < 1) return null;
if (src_w > 32 or src_h > 32) return null;
// Pick smallest scale factor that covers the target: 2x, 3x, or 2x+2x=4x
const int_factor: u32 = if (scale_f <= 2.0) 2 else if (scale_f <= 3.0) 3 else 4;
const scaled_w = src_w * int_factor;
const scaled_h = src_h * int_factor;
const needs_resample = (dst_w != scaled_w or dst_h != scaled_h);
// Copy source pixels to contiguous buffer (surface pitch may differ from width*4)
const src_row_bytes = src_w * 4;
for (0..src_h) |y| {
const src_row = src_pixels + y * src_pitch;
const dst_row: [*]u8 = @ptrCast(&s_src_buf[y * src_w]);
@memcpy(dst_row[0..src_row_bytes], src_row[0..src_row_bytes]);
}
// Run edge-aware pixel-art upscaler
switch (int_factor) {
2 => scale2x(&s_src_buf, src_w, src_h, &s_scaled_buf, scaled_w),
3 => scale3x(&s_src_buf, src_w, src_h, &s_scaled_buf, scaled_w),
4 => {
// 4x = two passes of Scale2x
scale2x(&s_src_buf, src_w, src_h, &s_dst_buf, src_w * 2);
scale2x(&s_dst_buf, src_w * 2, src_h * 2, &s_scaled_buf, scaled_w);
},
else => return null,
}
// Bilinear resample to exact target size if needed
const final_pixels: [*]u32 = if (needs_resample) blk: {
bilinearResample(&s_scaled_buf, scaled_w, scaled_h, &s_dst_buf, dst_w, dst_h);
break :blk &s_dst_buf;
} else &s_scaled_buf;
// Create Win32 cursor via CreateIconIndirect
const mask_bytes = dst_w * dst_h / 8;
@memset(s_mask_buf[0..mask_bytes], 0xFF);
const hbm_mask = CreateBitmap(@intCast(dst_w), @intCast(dst_h), 1, 1, &s_mask_buf) orelse return null;
const hbm_color = CreateBitmap(@intCast(dst_w), @intCast(dst_h), 1, 32, final_pixels) orelse {
_ = DeleteObject(hbm_mask);
return null;
};
const hot_x: u32 = @intFromFloat(@as(f32, @floatFromInt(hotspot_x)) * scale_f);
const hot_y: u32 = @intFromFloat(@as(f32, @floatFromInt(hotspot_y)) * scale_f);
var info = IconInfo{
.fIcon = 0,
.xHotspot = hot_x,
.yHotspot = hot_y,
.hbmMask = hbm_mask,
.hbmColor = hbm_color,
};
const hcursor = CreateIconIndirect(&info);
_ = DeleteObject(hbm_mask);
_ = DeleteObject(hbm_color);
return hcursor;
}
// =============================================================================
// Cache management
// =============================================================================
fn cacheLookup(pixel_hash: u64) ?*anyopaque {
for (&g_cache) |*entry| {
if (entry.valid and entry.hash == pixel_hash)
return entry.hcursor;
}
return null;
}
fn cacheInsert(pixel_hash: u64, hcursor: *anyopaque) void {
var idx: u32 = g_cache_count;
if (idx >= MAX_CACHE) {
if (g_cache[0].hcursor) |old| _ = DestroyCursor(old);
for (0..MAX_CACHE - 1) |i| g_cache[i] = g_cache[i + 1];
idx = MAX_CACHE - 1;
} else {
g_cache_count += 1;
}
g_cache[idx] = .{ .hash = pixel_hash, .hcursor = hcursor, .valid = true };
}
fn cacheClear() void {
for (&g_cache) |*entry| {
if (entry.hcursor) |old| _ = DestroyCursor(old);
entry.* = .{};
}
g_cache_count = 0;
}
// =============================================================================
// Hook: IDirect3DDevice9::SetCursorProperties (VT slot 10)
// =============================================================================
fn hkSetCursorProperties(device: *anyopaque, x_hotspot: u32, y_hotspot: u32, cursor_surface: *anyopaque) callconv(sc) i32 {
const origFn: *const fn (*anyopaque, u32, u32, *anyopaque) callconv(sc) i32 =
@ptrFromInt(orig_set_cursor_props);
if (g_scale_f <= 1.0) return origFn(device, x_hotspot, y_hotspot, cursor_surface);
// Get surface dimensions and format
var desc: D3DSURFACE_DESC = .{};
const hr_desc = surfaceGetDesc(cursor_surface, &desc);
if (hr_desc < 0) return origFn(device, x_hotspot, y_hotspot, cursor_surface);
if (desc.Format != D3DFMT_A8R8G8B8 or desc.Width > 32 or desc.Height > 32) {
return origFn(device, x_hotspot, y_hotspot, cursor_surface);
}
// Lock source surface to read pixels
var locked: D3DLOCKED_RECT = .{};
const hr_lock = surfaceLockRect(cursor_surface, &locked, null, 0x10);
if (hr_lock < 0) return origFn(device, x_hotspot, y_hotspot, cursor_surface);
const bits = locked.pBits orelse {
_ = surfaceUnlockRect(cursor_surface);
return origFn(device, x_hotspot, y_hotspot, cursor_surface);
};
const pitch: u32 = if (locked.Pitch > 0) @intCast(locked.Pitch) else {
_ = surfaceUnlockRect(cursor_surface);
return origFn(device, x_hotspot, y_hotspot, cursor_surface);
};
// Hash pixel data + scale for cache key
const data_size = pitch * desc.Height;
const scale_bits: u32 = @bitCast(g_scale_f);
const pixel_hash = hashPixels(bits, data_size, scale_bits);
// Check cache
if (cacheLookup(pixel_hash)) |cached_cursor| {
_ = surfaceUnlockRect(cursor_surface);
g_hcursor = cached_cursor;
const result = origFn(device, x_hotspot, y_hotspot, cursor_surface);
if (g_cursor_visible) _ = SetCursor(g_hcursor);
return result;
}
// Run hqx upscale + bilinear downsample
if (createEnlargedCursor(bits, pitch, desc.Width, desc.Height, x_hotspot, y_hotspot, g_scale_f)) |new_cursor| {
cacheInsert(pixel_hash, new_cursor);
g_hcursor = new_cursor;
}
_ = surfaceUnlockRect(cursor_surface);
const result = origFn(device, x_hotspot, y_hotspot, cursor_surface);
if (g_hcursor != null and g_cursor_visible) {
_ = SetCursor(g_hcursor);
}
return result;
}
// =============================================================================
// Hook: IDirect3DDevice9::ShowCursor (VT slot 12)
// =============================================================================
fn hkShowCursor(device: *anyopaque, bShow: i32) callconv(sc) i32 {
const origFn: *const fn (*anyopaque, i32) callconv(sc) i32 =
@ptrFromInt(orig_show_cursor);
g_cursor_visible = bShow != 0;
if (g_hcursor != null and g_scale_f > 1.0) {
// Hide D3D9's 32x32 hardware cursor — we use a Win32 cursor instead
_ = origFn(device, 0);
if (g_cursor_visible) {
_ = SetCursor(g_hcursor);
} else {
_ = SetCursor(null);
}
return if (g_cursor_visible) 1 else 0;
}
return origFn(device, bShow);
}
// =============================================================================
// Lua API: SetCursorScale(n) / GetCursorScale()
// =============================================================================
fn luaPushNumber(L_ptr: usize, n: f64) void {
const raw: [2]u32 = @bitCast(n);
asm volatile (
\\push %[hi]
\\push %[lo]
\\call *%[func]
:
: [_] "{ecx}" (L_ptr),
[lo] "r" (raw[0]),
[hi] "r" (raw[1]),
[func] "r" (@as(u32, 0x6F3810)),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
pub fn luaSetCursorScale(L: *anyopaque) callconv(.c) u32 {
const L_ptr = @intFromPtr(L);
const nargs = hook.fastcall(i32, 0x6F3070, L_ptr, @as(u32, 0)); // lua_gettop
if (nargs < 1) return 0;
const val: f32 = @floatCast(hook.fastcall(f64, 0x6F3620, L_ptr, @as(i32, 1))); // lua_tonumber
if (val < 1.0 or val > 4.0) return 0;
if (val != g_scale_f) {
g_scale_f = val;
cacheClear();
g_hcursor = null;
logFmt("[bigcursor] scale set to {d:.2}\n", .{g_scale_f});
}
return 0;
}
pub fn luaGetCursorScale(L: *anyopaque) callconv(.c) u32 {
luaPushNumber(@intFromPtr(L), @floatCast(g_scale_f));
return 1;
}
// =============================================================================
// Vtable patching
// =============================================================================
fn patchVtableEntry(vtable_ptr: [*]usize, idx: usize, new_fn: usize, old_fn: *usize) bool {
old_fn.* = vtable_ptr[idx];
var old_prot: u32 = 0;
const addr: *anyopaque = @ptrFromInt(@intFromPtr(&vtable_ptr[idx]));
if (VirtualProtect(addr, @sizeOf(usize), 0x40, &old_prot) == 0) return false;
vtable_ptr[idx] = new_fn;
_ = VirtualProtect(addr, @sizeOf(usize), old_prot, &old_prot);
return true;
}
fn restoreVtableEntry(vtable_ptr: [*]usize, idx: usize, old_fn: usize) void {
var old_prot: u32 = 0;
const addr: *anyopaque = @ptrFromInt(@intFromPtr(&vtable_ptr[idx]));
if (VirtualProtect(addr, @sizeOf(usize), 0x40, &old_prot) == 0) return;
vtable_ptr[idx] = old_fn;
_ = VirtualProtect(addr, @sizeOf(usize), old_prot, &old_prot);
}
fn getD3D9VTable() ?[*]usize {
const gx_device = hook.readMem(u32, GX_DEVICE_PTR);
if (gx_device == 0) return null;
const d3d_device = hook.readMem(u32, gx_device + GX_DEVICE_D3D_OFFSET);
if (d3d_device == 0) return null;
const vtable_addr = hook.readMem(u32, d3d_device);
if (vtable_addr == 0) return null;
return @ptrFromInt(vtable_addr);
}
// =============================================================================
// Late init (called from engineInitDetour when D3D9 device exists)
// =============================================================================
pub fn lateInit() void {
if (!g_is_hook_owner) return;
if (hooks_installed) return;
const vt = getD3D9VTable() orelse {
con.print("[bigcursor] D3D9 device not available yet\n");
return;
};
d3d9_vtable = vt;
if (!patchVtableEntry(vt, VT_SetCursorProperties, @intFromPtr(&hkSetCursorProperties), &orig_set_cursor_props)) {
con.print("[bigcursor] failed to hook SetCursorProperties\n");
return;
}
g_is_hook_owner = true;
if (!patchVtableEntry(vt, VT_ShowCursor, @intFromPtr(&hkShowCursor), &orig_show_cursor)) {
con.print("[bigcursor] failed to hook ShowCursor\n");
restoreVtableEntry(vt, VT_SetCursorProperties, orig_set_cursor_props);
return;
}
hooks_installed = true;
// Register CVar for persistence (tenths: 15 = 1.5x, 20 = 2.0x)
_ = registerCVar(CVAR_NAME, 0, 0, "12", 0, 1, 0, 0);
g_scale_f = readCVarScaleInit();
logFmt("[bigcursor] D3D9 hooks installed (scale={d:.1}x)\n", .{g_scale_f});
}
// =============================================================================
// Module lifecycle
// =============================================================================
pub fn isActive() bool {
return g_is_hook_owner;
}
pub fn installHooks() void {
logInit();
log("[bigcursor] Module loaded\n");
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
if (hooks_installed) {
if (d3d9_vtable) |vt| {
if (orig_show_cursor != 0) restoreVtableEntry(vt, VT_ShowCursor, orig_show_cursor);
if (orig_set_cursor_props != 0) restoreVtableEntry(vt, VT_SetCursorProperties, orig_set_cursor_props);
}
hooks_installed = false;
}
cacheClear();
g_hcursor = null;
if (g_is_hook_owner) {
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
logDeinit();
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Debug console — compiles out entirely in non-Debug builds.
//! Debug console - compiles out entirely in non-Debug builds.
//!
//! Usage from any module:
//! const con = @import("../console.zig"); // or appropriate relative path
+14 -22
View File
@@ -15,7 +15,7 @@ const hook = @import("zhook");
const con = @import("../console.zig");
// =============================================================================
// Windows API (project-specific — not in hook lib)
// Windows API (project-specific - not in hook lib)
// =============================================================================
const WINAPI = std.builtin.CallingConvention.winapi;
@@ -276,29 +276,25 @@ fn revertLooseFilePatches() void {
// Init / Cleanup
// =============================================================================
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "customassets";
var installed: bool = false;
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn isActive() bool {
return g_is_hook_owner;
}
pub fn installHooks() void {
con.print("[customassets] Module loaded\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_CustomAssetsHook_{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("[customassets] Another DLL owns hooks (mutex taken), skipping\n");
return;
}
g_is_hook_owner = true;
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
applyGlobPatch();
applyLooseFilePatches();
@@ -317,11 +313,7 @@ pub fn removeHooks() void {
}
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+34
View File
@@ -0,0 +1,34 @@
//! DPS log module.
//!
//! Provides structured Lua objects for combat log events so addons can read
//! parsed fields directly instead of re-parsing the combat log string.
//!
//! TODO: Hook combat log event dispatch, build Lua tables per event type.
const con = @import("../console.zig");
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "dpslog";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn isActive() bool {
return g_is_hook_owner;
}
pub fn installHooks() void {
con.print("[dpslog] Module loaded (stub)\n");
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+52 -52
View File
@@ -30,7 +30,7 @@ ESI=03319888 EDI=303A4588 EBP=00F2FADC ESP=00F2FACC
processGraphicsFrame (0x764330)
→ renderAllFrameLayers (0x765650)
→ processFrameUpdates
→ DispatchHeartbeatEvent (0x76b2c0) — fires OnUpdate
→ DispatchHeartbeatEvent (0x76b2c0) - fires OnUpdate
→ ExecuteLuaCallback (0x704f10)
→ luaD_pcall (0x6f6960)
→ luaCallFunction (0x6f6050)
@@ -66,14 +66,14 @@ This is the Lua API `frame:GetPoint(index)`. It returns anchor point info:
0x007a2408: CMP EBX,0x9 ; 9 anchor point types max
0x007a240b: JL 0x007a23f8
; === Found anchor — get relativeTo frame ===
; === Found anchor - get relativeTo frame ===
0x007a2416: MOV EDX,[EDI] ; anchor vtable
0x007a2418: MOV ECX,EDI ; this = anchor
0x007a241a: CALL [EDX + 0xc] ; vtable[3]() → GetRelativeTo → returns raw ptr
0x007a241d: TEST EAX,EAX
0x007a241f: JZ 0x007a24d7 ; NULL → safe "no relativeTo" path
; Second call to same vfunc — gets value for real this time
; Second call to same vfunc - gets value for real this time
0x007a2425: MOV EAX,[EDI]
0x007a2429: CALL [EAX + 0xc] ; vtable[3]() again
0x007a242c: TEST EAX,EAX
@@ -106,19 +106,19 @@ the point name + x/y offsets, skipping the relativeTo frame entirely.
## Anchor Object Structure (0x14 = 20 bytes)
Discovered from `SetAnimationOrder` (0x00767c70) — the internal C++ `SetPoint`:
Discovered from `SetAnimationOrder` (0x00767c70) - the internal C++ `SetPoint`:
```
Offset Size Field
+0x00 4 vtable pointer = PTR_GetAnimationOrder_0081c44c
+0x04 4 x offset (float)
+0x08 4 y offset (float)
+0x0C 4 relativeTo frame pointer (RAW — no refcount, no validation!)
+0x0C 4 relativeTo frame pointer (RAW - no refcount, no validation!)
+0x10 4 relative point enum (uint)
```
**Vtable address**: `0x0081c44c` (in .rdata)
**vtable[3]** (at vtable+0xC = `0x0081c458`): GetRelativeTo — simply returns `this+0x0C`
**vtable[3]** (at vtable+0xC = `0x0081c458`): GetRelativeTo - simply returns `this+0x0C`
### Anchor Creation (in SetAnimationOrder)
```c
@@ -164,7 +164,7 @@ invalidation mechanism.** When the relativeTo frame is destroyed:
| 0x403f50 | `ValidateObjectPointer` | Calls `IsBadReadPtr` via `[0x007ff2b8]` |
| 0x4c38a0 | `FrameScript_ValidateMemory` | Global memory validation |
### Type ID Globals (runtime values, .bss — not readable from Ghidra)
### Type ID Globals (runtime values, .bss - not readable from Ghidra)
| Address | Name |
|---------|------|
| 0x00cf0c10 | `g_ParentFrameTypeID` |
@@ -175,20 +175,20 @@ invalidation mechanism.** When the relativeTo frame is destroyed:
### Frame Lifecycle
| Address | Name |
|---------|------|
| 0x773240 | `DestroyFrame` — clears fields +0/+4, unlinks |
| 0x4c34a0 | `DestroyFrameScriptObject` — clears lua ref, unlinks from list, frees |
| 0x773240 | `DestroyFrame` - clears fields +0/+4, unlinks |
| 0x4c34a0 | `DestroyFrameScriptObject` - clears lua ref, unlinks from list, frees |
| 0x4c3510 | `FrameScript_InsertIntoList` |
| 0x4c3c10 | `FrameScript_UnlinkFromList` |
| 0x701bd0 | `RegisterFrameScriptReference` — creates Lua table + metatable for frame |
| 0x701bd0 | `RegisterFrameScriptReference` - creates Lua table + metatable for frame |
### Anchor Functions
| Address | Name |
|---------|------|
| 0x767c70 | `SetAnimationOrder` — internal SetPoint (creates anchor object) |
| 0x768010 | `IsAnimationPlaying` — recursive anchor dependency check |
| 0x7a2340 | `luaGetPoint` — Lua API, CRASH SITE |
| 0x7a2540 | `luaSetPoint` — Lua API |
| 0x7a2940 | `luaClearAllPoints` — Lua API |
| 0x767c70 | `SetAnimationOrder` - internal SetPoint (creates anchor object) |
| 0x768010 | `IsAnimationPlaying` - recursive anchor dependency check |
| 0x7a2340 | `luaGetPoint` - Lua API, CRASH SITE |
| 0x7a2540 | `luaSetPoint` - Lua API |
| 0x7a2940 | `luaClearAllPoints` - Lua API |
### Windows API
| IAT Address | API |
@@ -267,7 +267,7 @@ ESI=16D046A8 EDI=17077C2C EBP=00F2F7EC ESP=00F2F7D8
0x007A2FB8: F6 41 3C 02 TEST byte ptr [ECX+0x3C], 0x02 ; ECX=0 → reads 0x0000003C
```
ECX is NULL — the frame/layout object pointer is missing.
ECX is NULL - the frame/layout object pointer is missing.
**Note**: EDX=0x0081C44C (the anchor vtable) is just a leftover register value,
NOT caused by our vtable hook. EDX is not used at the crash site.
@@ -275,18 +275,18 @@ NOT caused by our vtable hook. EDX is not used at the crash site.
### Call Chain
```
GetAnimationSmoothing (0x768d20)
→ calculate_negative... (0x7673d0) — pushes 0x0081C3D0, calls SetFrameHitTestMode
→ SetFrameHitTestMode (0x7671a0) — iterates anchors, calls vtable[1] (+0x04)
→ calculate_negative... (0x7673d0) - pushes 0x0081C3D0, calls SetFrameHitTestMode
→ SetFrameHitTestMode (0x7671a0) - iterates anchors, calls vtable[1] (+0x04)
→ luaGetWidth (0x7a2f90) ← CRASH: ECX=NULL
```
This crashes during **frame layout calculation at startup** — the frame layout
This crashes during **frame layout calculation at startup** - the frame layout
system is computing dimensions, and a frame's layout dependency (parent or
anchor target) is NULL.
### Key Disassembly (crash site in luaGetWidth)
```asm
; luaGetWidth prologue — sets up local vars
; luaGetWidth prologue - sets up local vars
0x007a2faa: MOV [EBP-8], 0x0
0x007a2fb1: MOV [EBP-4], 0x0
0x007a2fb8: TEST byte ptr [ECX+0x3C], 0x02 ← CRASH (ECX=NULL)
@@ -339,7 +339,7 @@ anchor target) is NULL.
and dereference it at `+0x3C` **without** a NULL check. The anchor exists,
but its `relativeTo` field is NULL (no target frame set).
**`0x0081C3D0` is NOT a vtable** — it's a static array of 3 anchor-point indices:
**`0x0081C3D0` is NOT a vtable** - it's a static array of 3 anchor-point indices:
`[0, 3, 6]`, used by the width layout pass. `SetFrameHitTestMode` iterates these
indices to look up anchors from the frame's anchor array.
@@ -356,7 +356,7 @@ but the actual functions take an extra stack parameter.
### Root Cause
`SetFrameHitTestMode` (0x7671a0) calls `vtable[1]` with `PUSH EAX` before the
call — passing 1 stack argument. Since GetWidth/GetHeight are `__thiscall`, the
call - passing 1 stack argument. Since GetWidth/GetHeight are `__thiscall`, the
callee must clean up this argument (RET 4). Our hooks with no stack parameter
used RET 0, leaving 4 bytes on the stack after every call, causing misalignment
and eventual crash.
@@ -414,7 +414,7 @@ So given a CLayoutFrame pointer (e.g., relativeTo from an anchor):
## Destruction Path Analysis (Ghidra)
### cleanup_linked_list_structures (0x767720) — The Bottleneck
### cleanup_linked_list_structures (0x767720) - The Bottleneck
All frame destruction goes through this function. Exactly 3 direct callers:
@@ -424,11 +424,11 @@ All frame destruction goes through this function. Exactly 3 direct callers:
| `CleanupRegion` | 0x76c560 | Region cleanup (called by WorldObjectBaseDestructor) |
| `cleanupGraphicsResources` | 0x764390 | Graphics teardown |
### WorldObjectBaseDestructor (0x7693b0) — Common Base
### WorldObjectBaseDestructor (0x7693b0) - Common Base
All frame-type destructors eventually call `WorldObjectBaseDestructor`, which:
1. Calls `cleanup_linked_list_structures(param_1 + 9)` — **our hook fires here**
2. Calls `CleanupRegion` — also calls `cleanup_linked_list_structures`
1. Calls `cleanup_linked_list_structures(param_1 + 9)` - **our hook fires here**
2. Calls `CleanupRegion` - also calls `cleanup_linked_list_structures`
3. Calls `FrameScript_Destructor` (base class cleanup, list unlinking)
16 callers of WorldObjectBaseDestructor (all are frame-type destructors):
@@ -438,12 +438,12 @@ All frame-type destructors eventually call `WorldObjectBaseDestructor`, which:
`statusBarCleanupResources`, `cleanupMessageFrameResources`, `cleanupScrollFrame`,
`CleanupObjectManager`, `CleanupColorSelectFrame`, `CleanupMovieFrame`, `luaIsVisible`
### DestroyFrame (0x773240) — NOT a Destruction Path
### DestroyFrame (0x773240) - NOT a Destruction Path
Only called from `InitializeFrameProperties` (0x7731d0). This is a **re-initialization**
path, not frame destruction. No need to hook.
### DestroyFrameScriptObject (0x4c34a0) — Vtable Entry, Already Covered
### DestroyFrameScriptObject (0x4c34a0) - Vtable Entry, Already Covered
Referenced only as DATA at `0x806cb8` (vtable slot). `FrameScript_Destructor` (0x4c3690)
sets the vtable to this and does list unlinking / FreeMemory. It runs **after**
@@ -454,10 +454,10 @@ called by the time this executes. No need to hook.
**Our single hook on `cleanup_linked_list_structures` covers all frame destruction paths.**
The vtable hooks (GetWidth/GetHeight/GetRelativeTo) remain as defense-in-depth but
are not expected to fire during normal operation — they would only catch bugs in
are not expected to fire during normal operation - they would only catch bugs in
our cleanup logic or unknown destruction paths.
### Anchor Vtable (0x0081C44C) — Full Layout
### Anchor Vtable (0x0081C44C) - Full Layout
```
[0] +0x00 = 0x00767d80 → GetAnimationOrder (destructor)
[1] +0x04 = 0x007a2f90 → luaGetWidth ← CRASH FUNCTION
@@ -465,21 +465,21 @@ our cleanup logic or unknown destruction paths.
[3] +0x0C = 0x00767d70 → GetRelativeTo (already hooked)
```
### Fix Attempt: vtable[1]/[2] hook returning 0.0 — REVERTED
### Fix Attempt: vtable[1]/[2] hook returning 0.0 - REVERTED
Hooked vtable[1] (GetWidth) and vtable[2] (GetHeight) with wrappers that
returned 0.0 when relativeTo was NULL/dangling. **This broke UI layout** because:
1. 0.0 is not the sentinel value that `SetFrameHitTestMode` expects
(it compares via `FCOMP [0x00cf550c]` — a runtime .bss value)
(it compares via `FCOMP [0x00cf550c]` - a runtime .bss value)
2. The functions have side effects (IsAnimationDone, GetAnimationTarget calls)
that update layout state — skipping them entirely is wrong
that update layout state - skipping them entirely is wrong
### Relationship Between Crash 1 and Crash 2
The GetRelativeTo hook (crash 1 fix) self-heals by NULLing anchor+0x0C when
it detects a dangling pointer. But GetWidth/GetHeight read anchor+0x0C
**directly** (not through vtable[3]), so they see the now-NULL value and crash.
The two crashes are likely the same underlying issue — the GetRelativeTo hook
The two crashes are likely the same underlying issue - the GetRelativeTo hook
is masking the dangling pointer but exposing it as a NULL pointer to other code.
---
@@ -491,14 +491,14 @@ is masking the dangling pointer but exposing it as a NULL pointer to other code.
WoW's frame system tracks anchor dependencies via `PauseAnimationGroup` /
`ResumeAnimationGroup`:
**PauseAnimationGroup(relativeTo_frame, owner_frame, bitmask)** — 0x767ee0
**PauseAnimationGroup(relativeTo_frame, owner_frame, bitmask)** - 0x767ee0
- Called by `SetAnimationOrder` (SetPoint) when creating an anchor
- Maintains a linked list on `relativeTo_frame+0x30/0x34`
- Each node is 0x10 bytes: `[link0, next_ptr(+4), owner_frame(+8), bitmask(+C)]`
- Bitmask = `1 << anchor_point_enum` — tracks which anchor slots reference this frame
- Bitmask = `1 << anchor_point_enum` - tracks which anchor slots reference this frame
- If owner already in list, ORs in the new bitmask bits
**ResumeAnimationGroup(relativeTo_frame, owner_frame, bitmask)** — 0x767fa0
**ResumeAnimationGroup(relativeTo_frame, owner_frame, bitmask)** - 0x767fa0
- Called when replacing/removing an anchor
- Walks list at `relativeTo_frame+0x34`, finds matching owner
- Clears bitmask bits: `node+0xC &= ~bitmask`
@@ -506,7 +506,7 @@ WoW's frame system tracks anchor dependencies via `PauseAnimationGroup` /
### Proper Anchor Cleanup (exists but not called on destruction)
**cleanup_array_of_objects** (0x767620) — cleans up a frame's OWN anchors:
**cleanup_array_of_objects** (0x767620) - cleans up a frame's OWN anchors:
```c
for i in 0..9:
anchor = *(frame + i*4 + 4) // anchor slot
@@ -519,13 +519,13 @@ for i in 0..9:
```
Called from: `SetAnimationOrigin` (0x768e20), `StartAnimationGroup` (0x767db0),
`GetAnimationEndDelay` (0x768430) — **never during frame destruction**.
`GetAnimationEndDelay` (0x768430) - **never during frame destruction**.
### Frame Destruction Chain
```
destroy_object (0x7676f0) — thiscall(frame, free_flag)
-> cleanup_linked_list_structures (0x767720) — thiscall(frame)
destroy_object (0x7676f0) - thiscall(frame, free_flag)
-> cleanup_linked_list_structures (0x767720) - thiscall(frame)
-> sets vtable to CLayoutFrame base (0x81c400)
-> SetAnimationOrigin(frame) -> cleanup_array_of_objects(frame)
^ cleans up THIS frame's own anchors (forward direction)
@@ -576,27 +576,27 @@ First 5 bytes (53 56 8B F1 57) can be replaced with JMP rel32 for a detour.
---
## Stale UIParent Pointer — The Persistent Unknown Destruction Path
## Stale UIParent Pointer - The Persistent Unknown Destruction Path
### Discovery
One persistent stale pointer escapes ALL hooked destruction paths. Pattern:
- Address always ends in `X008` (e.g., `0x17f20008`, `0x17fb4008`, `0x03bc0008`)
- CFrame base = addr - 0x24 = `XXXXffe4` — crosses page boundary
- CFrame base = addr - 0x24 = `XXXXffe4` - crosses page boundary
- First page (containing CFrame base, vtable) is DECOMMITTED
- Second page (containing CLayoutFrame inner at +0x24) survives
- Frame name at CFrame+0x98 reads garbage (`"t%Ç"`) from residual second-page data
- NOT in destruction history ring buffer — never went through any hooked detour
- NOT in destruction history ring buffer - never went through any hooked detour
### Diagnostic Hooks Added
**PauseAnimationGroup (0x767ee0)** — dependency registration tracker:
**PauseAnimationGroup (0x767ee0)** - dependency registration tracker:
- `__thiscall(ECX=relativeTo_frame, owner_frame, bitmask)`, RET 0x8
- Prologue: `55 8B EC 53 8B D9` (6 bytes)
- Silently records every registration to a 2048-entry ring buffer
- Queried by vtable hooks when stale pointer detected
**SetAnimationOrder (0x767c70)** — anchor creation validator:
**SetAnimationOrder (0x767c70)** - anchor creation validator:
- `__thiscall(ECX=frame, point_enum, relativeTo, relPoint, xOfs, yOfs, param_6)`, RET 0x18
- Prologue: `55 8B EC 8B 45 0C` (6 bytes)
- Validates relativeTo AND CFrame base (relativeTo - 0x24) with IsBadReadPtr
@@ -627,12 +627,12 @@ SetAnimOrder with the dead relativeTo address:
### Race Condition Confirmed
- `DEP REGISTERED` — PauseAnimationGroup WAS called for the address (dependency existed)
- But name at registration time was `"t%Ç"` (garbage) — the frame was ALREADY DEAD
- `DEP REGISTERED` - PauseAnimationGroup WAS called for the address (dependency existed)
- But name at registration time was `"t%Ç"` (garbage) - the frame was ALREADY DEAD
when PauseAnimationGroup ran
- `SetAnimOrder` RACE check: `IsBadReadPtr(relativeTo - 0x24)` FAILS (first page
decommitted), but `IsBadReadPtr(relativeTo)` passes (second page survives)
- The original game code doesn't validate relativeTo at all — just stores the raw pointer
- The original game code doesn't validate relativeTo at all - just stores the raw pointer
### Symptom: Black Screen
@@ -644,9 +644,9 @@ but the UI is broken.
UIParent is destroyed through an unknown path during a UI reload/transition (character
select → world, or loading screen). The destruction does NOT go through:
- `cleanup_linked_list_structures` (0x767720) — hooked, not triggered
- `destroyUIElement` (0x7645a0) — hooked, not triggered
- `ProcessUIUpdateEvent` (0x772ec0) — hooked, not triggered
- `cleanup_linked_list_structures` (0x767720) - hooked, not triggered
- `destroyUIElement` (0x7645a0) - hooked, not triggered
- `ProcessUIUpdateEvent` (0x772ec0) - hooked, not triggered
A new UIParent is created at a different address, but addon/Blizzard initialization
code passes the OLD (now dead) address to SetPoint/SetAnimOrder.
+44 -52
View File
@@ -35,23 +35,31 @@ extern "kernel32" fn GetLastError() callconv(WINAPI) u32;
extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32;
const ERROR_ALREADY_EXISTS: u32 = 183;
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "framecrash";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn isActive() bool {
return g_is_hook_owner;
}
// =============================================================================
// Anchor vtable layout (20-byte object allocated in SetPoint / SetAnimationOrder)
//
// +0x00 vtable ptr → 0x0081c44c (.rdata)
// +0x04 x offset (float)
// +0x08 y offset (float)
// +0x0C relativeTo (raw frame pointer — the dangerous one)
// +0x0C relativeTo (raw frame pointer - the dangerous one)
// +0x10 relPoint (uint, anchor point enum on the relativeTo frame)
//
// vtable at 0x0081c44c:
// [0] +0x00 GetAnimationOrder (0x767d80) — destructor/cleanup
// [1] +0x04 luaGetWidth (0x7a2f90) — reads [this+0xC]+0x3C
// [2] +0x08 luaGetHeight (0x7a3070) — reads [this+0xC]+0x3C
// [3] +0x0C GetRelativeTo (0x767d70) — returns *(this+0x0C)
// [0] +0x00 GetAnimationOrder (0x767d80) - destructor/cleanup
// [1] +0x04 luaGetWidth (0x7a2f90) - reads [this+0xC]+0x3C
// [2] +0x08 luaGetHeight (0x7a3070) - reads [this+0xC]+0x3C
// [3] +0x0C GetRelativeTo (0x767d70) - returns *(this+0x0C)
// =============================================================================
const ANCHOR_VTABLE_ADDR: usize = 0x0081c44c;
@@ -93,7 +101,7 @@ var cleanup_hook: hook.Detour(CleanupFn) = .{};
// Second destruction path: destroyUIElement (0x7645a0)
//
// Called from cleanupGraphicsResources (UI teardown/reload) via the strata loop.
// Frees frames WITHOUT calling cleanup_linked_list_structures — just unlinks from
// Frees frames WITHOUT calling cleanup_linked_list_structures - just unlinks from
// lists, cleans up sub-regions, and calls FreeMemory. The dependency list at
// frame+0x34 is never walked, so other frames' anchors are left dangling.
//
@@ -121,12 +129,12 @@ const ProcessUIFn = fn (u32, u32) callconv(tc) u32;
var process_ui_hook: hook.Detour(ProcessUIFn) = .{};
// =============================================================================
// Priority 1: Hook PauseAnimationGroup (0x767ee0) — dependency registration
// Priority 1: Hook PauseAnimationGroup (0x767ee0) - dependency registration
//
// Records every dependency registration so we can later determine whether a
// stale relativeTo pointer was ever registered through PauseAnimationGroup.
// If PauseAnimationGroup was never called for an address, the dependency was
// never created — pointing to a race condition or unknown creation path.
// never created - pointing to a race condition or unknown creation path.
//
// Signature: void __thiscall PauseAnimationGroup(ECX=relativeTo_frame, owner_frame, bitmask)
// Prologue: 55 8B EC 53 8B D9 (6 bytes, no rel32)
@@ -139,7 +147,7 @@ const PauseAnimFn = fn (u32, u32, u32) callconv(tc) void;
var pause_anim_hook: hook.Detour(PauseAnimFn) = .{};
// =============================================================================
// Priority 2: Hook SetAnimationOrder (0x767c70) — anchor creation validation
// Priority 2: Hook SetAnimationOrder (0x767c70) - anchor creation validation
//
// Validates the relativeTo param with IsBadReadPtr BEFORE the original runs.
// If relativeTo is already freed when the anchor is created, the dependency
@@ -158,7 +166,7 @@ const SetAnimFn = fn (u32, u32, u32, u32, u32, u32, u32) callconv(tc) void;
var set_anim_hook: hook.Detour(SetAnimFn) = .{};
// =============================================================================
// Dependency registration ring buffer — track PauseAnimationGroup calls
// Dependency registration ring buffer - track PauseAnimationGroup calls
//
// When vtable hooks detect a stale pointer, we look up this buffer to answer:
// "was PauseAnimationGroup ever called for this address?"
@@ -227,7 +235,7 @@ fn getRegisteredName(relativeTo: u32) []const u8 {
}
// =============================================================================
// Destruction history ring buffer — correlate stale pointers with frame names
// Destruction history ring buffer - correlate stale pointers with frame names
// =============================================================================
const HISTORY_SIZE = 1024;
@@ -273,7 +281,7 @@ fn fmtStaleInfo(relativeTo: u32) struct { name: []const u8, saw_destroy: bool }
return .{ .name = fmtFrameName(relativeTo), .saw_destroy = false };
}
// logRegistrationStatus and dumpStaleContext removed — verbose diagnostic logging
// logRegistrationStatus and dumpStaleContext removed - verbose diagnostic logging
// superseded by HEAL/FIX/RACE messages. Ring buffers still used by tryFixStaleRelativeTo.
/// Detour for cleanup_linked_list_structures. Runs before the original to
@@ -298,7 +306,7 @@ fn destroyUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 {
return destroy_ui_hook.callOriginal(.{ frame, free_flag });
}
/// Detour for ProcessUIUpdateEvent — third destruction path, called via vtable.
/// Detour for ProcessUIUpdateEvent - third destruction path, called via vtable.
fn processUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 {
recordDestruction(frame);
if (IsBadReadPtr(@ptrFromInt(frame + 0x24), 4) == 0) {
@@ -309,18 +317,18 @@ fn processUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 {
return process_ui_hook.callOriginal(.{ frame, free_flag });
}
/// Detour for PauseAnimationGroup — records every dependency registration.
/// Detour for PauseAnimationGroup - records every dependency registration.
/// This tells us whether a stale relativeTo was ever registered through the
/// normal dependency tracking system.
/// Signature: void __thiscall PauseAnimationGroup(ECX=relativeTo_frame, owner_frame, bitmask)
fn pauseAnimDetour(relativeTo_frame: u32, owner_frame: u32, bitmask: u32) callconv(tc) void {
// Silently record — queried later by vtable hooks via logRegistrationStatus()
// Silently record - queried later by vtable hooks via logRegistrationStatus()
recordRegistration(relativeTo_frame, owner_frame, bitmask);
pause_anim_hook.callOriginal(.{ relativeTo_frame, owner_frame, bitmask });
}
/// Detour for SetAnimationOrder — validates relativeTo param before anchor creation.
/// Detour for SetAnimationOrder - validates relativeTo param before anchor creation.
/// Float params (xOfs, yOfs) are passed as raw u32 bit patterns on the stack.
fn setAnimOrderDetour(frame: u32, point_enum: u32, relativeTo: u32, rel_point: u32, x_ofs: u32, y_ofs: u32, param_6: u32) callconv(tc) void {
var fixed_relativeTo = relativeTo;
@@ -373,7 +381,7 @@ fn countReverseDependencies(dying_frame: u32) u32 {
/// Walk the PauseAnimationGroup dependency list on the dying frame and NULL out
/// the relativeTo field in any anchors from other frames that reference it.
///
/// Safety: purely defensive — does NOT call destructors, free nodes, or modify
/// Safety: purely defensive - does NOT call destructors, free nodes, or modify
/// the dying frame's list pointers. The original cleanup_linked_list_structures
/// handles its own data structures.
fn cleanupReverseDependencies(dying_frame: u32) void {
@@ -411,12 +419,12 @@ fn cleanupReverseDependencies(dying_frame: u32) void {
const relativeTo: u32 = readAligned(anchor + 0x0C);
if (relativeTo != dying_frame) continue;
// Verify vtable matches the known anchor vtable — reject garbage objects
// Verify vtable matches the known anchor vtable - reject garbage objects
const vtable: u32 = readAligned(anchor);
if (vtable != ANCHOR_VTABLE_ADDR) continue;
// NULL the relativeTo pointer so it can't dangle.
// Don't call destructors or free the anchor — that risks cascading
// Don't call destructors or free the anchor - that risks cascading
// side effects and is unnecessary. A NULL relativeTo is handled
// gracefully by all code paths (luaGetPoint, GetWidth, GetHeight).
const field: *align(1) u32 = @ptrFromInt(anchor + 0x0C);
@@ -457,7 +465,7 @@ fn getFrameName(layout_frame: u32) ?[*:0]const u8 {
return @ptrFromInt(name_ptr);
}
/// Format a frame name for logging — returns "FrameName" or "(unnamed)".
/// Format a frame name for logging - returns "FrameName" or "(unnamed)".
fn fmtFrameName(layout_frame: u32) []const u8 {
if (getFrameName(layout_frame)) |name| {
return std.mem.span(name);
@@ -509,13 +517,13 @@ fn tryFixStaleRelativeTo(anchor: u32, stale: u32) bool {
//
// Three vtable slots must be hooked because they independently read anchor+0x0C
// (relativeTo) without going through each other:
// [1] GetWidth — reads [this+0xC]+0x3C, crashes if relativeTo is NULL/dangling
// [2] GetHeight — same pattern as GetWidth
// [3] GetRelativeTo — returns *(this+0x0C), caller dereferences it
// [1] GetWidth - reads [this+0xC]+0x3C, crashes if relativeTo is NULL/dangling
// [2] GetHeight - same pattern as GetWidth
// [3] GetRelativeTo - returns *(this+0x0C), caller dereferences it
//
// GetRelativeTo NULLs the pointer on detection (self-heal). GetWidth/GetHeight
// must independently handle both NULL and dangling relativeTo by returning the
// sentinel value from [0x00cf550c] — the value SetFrameHitTestMode compares
// sentinel value from [0x00cf550c] - the value SetFrameHitTestMode compares
// against to detect "no dimension available".
// =============================================================================
@@ -550,10 +558,10 @@ fn getRelativeToHook(this: u32) callconv(tc) u32 {
if (!isRelativeToValid(result)) {
// Try to substitute live UIParent before NULLing
if (tryFixStaleRelativeTo(this, result)) {
// Re-call original — it now reads the fixed pointer
// Re-call original - it now reads the fixed pointer
return orig(this);
}
// No substitute available — NULL it out
// No substitute available - NULL it out
const field: *align(1) u32 = @ptrFromInt(this + 0x0C);
field.* = 0;
return 0;
@@ -564,14 +572,14 @@ fn getRelativeToHook(this: u32) callconv(tc) u32 {
/// Hook for vtable[1] GetWidth. Checks anchor+0x0C before calling original.
/// Returns sentinel if relativeTo is NULL or dangling.
/// Signature: f32 __thiscall GetWidth(this, u32 param) — callee cleans 1 stack arg.
/// Signature: f32 __thiscall GetWidth(this, u32 param) - callee cleans 1 stack arg.
fn getWidthHook(this: u32, param: u32) callconv(tc) f32 {
const relativeTo: u32 = readAligned(this + 0x0C);
if (!isRelativeToValid(relativeTo)) {
if (relativeTo != 0) {
// Try to substitute live UIParent instead of NULLing
if (tryFixStaleRelativeTo(this, relativeTo)) {
// Fixed — call original with the healed pointer
// Fixed - call original with the healed pointer
const orig: *const fn (u32, u32) callconv(tc) f32 = @ptrFromInt(orig_get_width);
return orig(this, param);
}
@@ -586,7 +594,7 @@ fn getWidthHook(this: u32, param: u32) callconv(tc) f32 {
}
/// Hook for vtable[2] GetHeight. Same pattern as GetWidth.
/// Signature: f32 __thiscall GetHeight(this, u32 param) — callee cleans 1 stack arg.
/// Signature: f32 __thiscall GetHeight(this, u32 param) - callee cleans 1 stack arg.
fn getHeightHook(this: u32, param: u32) callconv(tc) f32 {
const relativeTo: u32 = readAligned(this + 0x0C);
if (!isRelativeToValid(relativeTo)) {
@@ -625,22 +633,10 @@ fn restoreVtableSlot(slot_addr: usize, saved: *usize) void {
}
pub fn installHooks() void {
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_FramecrashHook_{d}", .{GetCurrentProcessId()}) catch return;
mutex_name_buf[mutex_name.len] = 0;
g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0]));
if (g_mutex == null) return;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
_ = CloseHandle(g_mutex.?);
g_mutex = null;
g_is_hook_owner = false;
con.print("[framecrash] Another DLL owns hooks (mutex taken), skipping\n");
return;
}
g_is_hook_owner = true;
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
// Root cause fix #1: detour cleanup_linked_list_structures to clean up
// reverse anchor references before the frame is destroyed.
@@ -650,7 +646,7 @@ pub fn installHooks() void {
con.print("[framecrash] Frame cleanup detour installed\n");
}
// Root cause fix #2: detour destroyUIElement — the second destruction path
// Root cause fix #2: detour destroyUIElement - the second destruction path
// used by cleanupGraphicsResources during UI teardown/reload. This path
// frees frames without walking the dependency list.
if (destroy_ui_hook.attach(DESTROY_UI_TARGET, &destroyUIDetour) != .ok) {
@@ -659,7 +655,7 @@ pub fn installHooks() void {
con.print("[framecrash] destroyUIElement detour installed\n");
}
// Root cause fix #3: detour ProcessUIUpdateEvent — virtual function that
// Root cause fix #3: detour ProcessUIUpdateEvent - virtual function that
// calls CleanupUIElement + FreeMemory without layout cleanup.
if (process_ui_hook.attach(PROCESS_UI_TARGET, &processUIDetour) != .ok) {
con.print("[framecrash] ERROR: Failed to install ProcessUIUpdateEvent detour\n");
@@ -717,11 +713,7 @@ pub fn removeHooks() void {
}
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+29 -6
View File
@@ -18,18 +18,18 @@
//!
//! Patches applied to SuperWoWhook.dll in memory:
//!
//! 1. 0x3006 (2 bytes) — Skip duplicate heal text in handler
//! 1. 0x3006 (2 bytes) - Skip duplicate heal text in handler
//! The handler at RVA 0x3BF0 creates floating text, then calls through
//! to the original wow.exe function (which also creates text = duplicate).
//! Patch MOV ECX,[EDI] -> JMP +0x7A to skip to the call-through at 0x3C82.
//! Old: 8B 0F
//! New: EB 7A
//!
//! 2. 0x306E (4 bytes) — Redirect HoT text handler pointer
//! 2. 0x306E (4 bytes) - Redirect HoT text handler pointer
//! Old: 9C D8 C4 00
//! New: 06 7C 44 00
//!
//! 3. 0x3123 (4 bytes) — Redirect HoT text handler pointer (second site)
//! 3. 0x3123 (4 bytes) - Redirect HoT text handler pointer (second site)
//! Old: 9C D8 C4 00
//! New: 06 7C 44 00
//!
@@ -125,8 +125,18 @@ fn fileOffsetToVA(base: [*]const u8, file_offset: u32) ?[*]u8 {
return null;
}
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "healtextfix";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
var g_applied_set: ?*const PatchSet = null;
pub fn isActive() bool {
return g_is_hook_owner;
}
/// Scan the DLL's mapped memory for SUPERWOW_VERSION="..." and extract the version string.
fn detectVersion(base: [*]const u8) ?[]const u8 {
const scan_len = 0x20000;
@@ -142,12 +152,17 @@ fn detectVersion(base: [*]const u8) ?[]const u8 {
}
pub fn installHooks() void {
con.print("[healtextfix] Module loaded (stub)\n");
con.print("[healtextfix] Module loaded\n");
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
}
/// Called from engineInitDetour (GameEngine_MainInitialize hook) — late enough
/// Called from engineInitDetour (GameEngine_MainInitialize hook) - late enough
/// that SuperWoWhook.dll should be loaded if present.
pub fn lateInit() void {
if (!g_is_hook_owner) return;
const superwow_base = GetModuleHandleA("SuperWoWhook.dll");
if (superwow_base == null) {
con.print("[healtextfix] SuperWoWhook.dll not found, skipping\n");
@@ -223,7 +238,13 @@ pub fn lateInit() void {
}
pub fn removeHooks() void {
const set = g_applied_set orelse return;
if (!g_is_hook_owner) return;
const set = g_applied_set orelse {
mod_mutex.release(&g_mutex);
g_is_hook_owner = false;
return;
};
const superwow_base = GetModuleHandleA("SuperWoWhook.dll");
if (superwow_base == null) return;
@@ -251,5 +272,7 @@ pub fn removeHooks() void {
}
g_applied_set = null;
mod_mutex.release(&g_mutex);
g_is_hook_owner = false;
con.print("[healtextfix] All patches restored\n");
}
+18 -29
View File
@@ -179,7 +179,7 @@ fn luaPrintError(L: *anyopaque, msg: [*:0]const u8) void {
}
// =============================================================================
// InteractNearest — find closest interactable within 5 yards, right-click it
// InteractNearest - find closest interactable within 5 yards, right-click it
// =============================================================================
pub fn interactNearest(L: *anyopaque) callconv(.c) u32 {
@@ -269,14 +269,22 @@ pub fn interactNearest(L: *anyopaque) callconv(.c) u32 {
}
// =============================================================================
// LootAllCorpses — queue nearby lootable corpses, loot them sequentially
// LootAllCorpses - queue nearby lootable corpses, loot them sequentially
// =============================================================================
const MAX_LOOT_QUEUE: usize = 100;
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "interact";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn isActive() bool {
return g_is_hook_owner;
}
var loot_queue: [MAX_LOOT_QUEUE]u64 = .{0} ** MAX_LOOT_QUEUE;
var loot_queue_count: usize = 0;
var loot_queue_index: usize = 0;
@@ -318,7 +326,7 @@ fn processLootQueue() void {
const loot_guid_hi = hook.readMem(u32, Offsets.LOOT_GUID_HI);
if (loot_guid_lo != 0 or loot_guid_hi != 0) {
// Loot window is open — wait for auto-loot to finish.
// Loot window is open - wait for auto-loot to finish.
// If items remain after timeout (e.g. unique items already owned),
// skip to next corpse.
if (elapsed > 1500) {
@@ -327,7 +335,7 @@ fn processLootQueue() void {
return;
}
// Loot GUID is zero — loot closed or server hasn't responded yet
// Loot GUID is zero - loot closed or server hasn't responded yet
if (elapsed < loot_next_delay) return;
interactNextCorpse();
@@ -404,38 +412,19 @@ fn hookSceneEnd(device: u32) callconv(tc) void {
pub fn installHooks() void {
con.print("[interact] Module loaded\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_InteractHook_{d}", .{GetCurrentProcessId()}) catch return;
mutex_name_buf[mutex_name.len] = 0;
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
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("[interact] Another DLL owns hooks (mutex taken), skipping\n");
return;
}
g_is_hook_owner = true;
// SceneEnd — per-frame loot queue processing
// SceneEnd - per-frame loot queue processing
_ = scene_end_hook.attach(Offsets.ADDR_SceneEnd, &hookSceneEnd);
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
scene_end_hook.detach();
}
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+3 -3
View File
@@ -251,15 +251,15 @@ On DLL init, scan `Logs\` directory for `WoWCombatLog_*.txt` files:
## Resolved Questions
- [x] Section at 0x00843610: `.data` (RW) -- no VirtualProtect needed
- [x] Player name: use `GetObjectName` (0x6264E0) — __fastcall(ECX=guid_ptr) → char*.
RetrieveNPCDataFromCache (0x55f080) does NOT return char* in EAX — it returns
- [x] Player name: use `GetObjectName` (0x6264E0) - __fastcall(ECX=guid_ptr) → char*.
RetrieveNPCDataFromCache (0x55f080) does NOT return char* in EAX - it returns
name bytes. The actual pointer is written to the output buffer param.
GetObjectName is simpler and verified by c_overlay reference.
- [x] Original string too short for timestamped name -- must use pointer redirect
- [x] **ESI clobber crash (ACCESS_VIOLATION at 0x6F61AF)**: luaCallFunction stores
luaState in ESI and the C function pointer in EDI, dispatches via `CALL EDI`,
then reads `[ESI+0x8]`. Our detour's compiled code (Debug build) did NOT push
ESI/EDI/EBX in its prologue — Zig only saves callee-saved registers it
ESI/EDI/EBX in its prologue - Zig only saves callee-saved registers it
allocates directly, but subcalls (callOriginal wrapper, inline asm game calls)
can clobber them without the compiler knowing. **All game functions verified
to preserve ESI/EDI/EBX**: GetPlayerGUID (0x468550, doesn't touch them),
+24 -34
View File
@@ -9,7 +9,7 @@
//! - Session continuation: reuses files modified < 60 min ago
//! - Session marker: writes `COMBATLOG_SESSION: <char> <realm>` on first combat write
//!
//! All DLL-side — no Lua addon needed.
//! All DLL-side - no Lua addon needed.
const std = @import("std");
const hook = @import("zhook");
@@ -72,11 +72,19 @@ const WIN32_FIND_DATAA = extern struct {
// Mutex state
// =============================================================================
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "logsessions";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn isActive() bool {
return g_is_hook_owner;
}
// =============================================================================
// Session state — reset on logout
// Session state - reset on logout
// =============================================================================
/// Static buffers for redirected paths (null-terminated). Must outlive the process.
@@ -105,7 +113,7 @@ var g_combat_marker_written: bool = false;
var g_chat_marker_written: bool = false;
var g_raw_marker_written: bool = false;
/// Raw combat log handle address — captured from initLogDetour when SuperWoW
/// Raw combat log handle address - captured from initLogDetour when SuperWoW
/// calls InitializeLogBuffer for WoWRawCombatLog. We don't know this address
/// statically; SuperWoW passes it as handle_out.
var g_raw_combat_handle_addr: u32 = 0;
@@ -114,7 +122,6 @@ var g_raw_combat_handle_addr: u32 = 0;
var g_original_combat_path_ptr: u32 = 0;
var g_original_chat_path_ptr: u32 = 0;
// =============================================================================
// Character / realm identity
// =============================================================================
@@ -189,7 +196,7 @@ fn setupSessionDir(realm: []const u8, char_name: []const u8) bool {
}
// =============================================================================
// Session continuation — find recent file to reuse
// Session continuation - find recent file to reuse
// =============================================================================
/// Scan directory for files matching `<prefix>_*.txt`, return the newest if
@@ -231,7 +238,7 @@ fn findRecentFile(prefix: []const u8, result_buf: *[260]u8) ?usize {
if (newest_name_len == 0) return null;
// Compare against current time — both UTC FILETIME (100ns units)
// Compare against current time - both UTC FILETIME (100ns units)
var current_ft: FILETIME = undefined;
GetSystemTimeAsFileTime(&current_ft);
const current: u64 = @bitCast(current_ft);
@@ -324,7 +331,7 @@ fn configureSession(char_span: []const u8, realm_span: []const u8) void {
}
// =============================================================================
// HandleCharacterSelection hook — set up paths before world loading
// HandleCharacterSelection hook - set up paths before world loading
// =============================================================================
var enter_world_hook: hook.Detour(fn () callconv(sc) void) = .{};
@@ -363,7 +370,7 @@ fn restorePathPointers() void {
}
// =============================================================================
// InitializeLogBuffer hook — lazy setup + path redirect
// InitializeLogBuffer hook - lazy setup + path redirect
// =============================================================================
var init_log_hook: hook.Detour(fn (u32, u32, u32) callconv(sc) u32) = .{};
@@ -395,7 +402,7 @@ fn initLogDetour(file_path: u32, flags: u32, handle_out: u32) callconv(sc) u32 {
}
// =============================================================================
// WriteFormattedLogMessage hook — inject session markers on first write per log
// WriteFormattedLogMessage hook - inject session markers on first write per log
// =============================================================================
var write_log_hook: hook.Detour(fn (u32, u32, u32) callconv(sc) void) = .{};
@@ -452,7 +459,7 @@ fn writeSessionMarker(handle: u32, fmt_str: [*:0]const u8) void {
}
// =============================================================================
// Shutdown — reset session state for next login
// Shutdown - reset session state for next login
// =============================================================================
/// Resets all session state so the next login gets fresh paths.
@@ -477,7 +484,7 @@ pub fn onShutdown() void {
}
// =============================================================================
// Lua API — log path accessors
// Lua API - log path accessors
// =============================================================================
/// GetCombatLogPath() → string or nil
@@ -507,24 +514,12 @@ pub fn luaGetChatLogPath(L: lua.State) callconv(.c) u32 {
pub fn installHooks() void {
con.print("[logsessions] Module loaded\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_LogSessionsHook_{d}", .{GetCurrentProcessId()}) catch return;
mutex_name_buf[mutex_name.len] = 0;
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
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("[logsessions] Another DLL owns hooks (mutex taken), skipping\n");
return;
}
g_is_hook_owner = true;
// Hook HandleCharacterSelection — sets up paths when player clicks Enter World,
// Hook HandleCharacterSelection - sets up paths when player clicks Enter World,
// before the world loading sequence calls InitializeLogBuffer.
if (enter_world_hook.attach(o.FN_HANDLE_CHAR_SELECT, &enterWorldDetour) != .ok) {
con.print("[logsessions] FAILED to hook HandleCharacterSelection!\n");
@@ -551,12 +546,7 @@ pub fn removeHooks() void {
init_log_hook.detach();
enter_world_hook.detach();
restorePathPointers();
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+5 -6
View File
@@ -4,12 +4,12 @@
// Combat log path and state
// =============================================================================
/// Path table pointer — .data section (RW), points to "Logs\WoWCombatLog.txt" string.
/// Path table pointer - .data section (RW), points to "Logs\WoWCombatLog.txt" string.
/// Index 0 (0x0084360c) = chat log, index 1 (0x00843610) = combat log.
/// Overwriting the u32 at this address redirects where the combat log file is created.
pub const COMBAT_LOG_PATH_PTR: usize = 0x00843610;
/// Chat log path pointer — .data section (RW), points to "Logs\WoWChatLog.txt" string.
/// Chat log path pointer - .data section (RW), points to "Logs\WoWChatLog.txt" string.
/// Index 0 in the path table at 0x0084360c.
pub const CHAT_LOG_PATH_PTR: usize = 0x0084360c;
@@ -31,13 +31,13 @@ pub const REALM_NAME_CVAR_BASE: usize = 0x00c28130;
// Log writing
// =============================================================================
/// InitializeLogBuffer — __stdcall(filePath: [*:0]const u8, flags: u32, handleOut: *u32).
/// InitializeLogBuffer - __stdcall(filePath: [*:0]const u8, flags: u32, handleOut: *u32).
/// Creates a log buffer context. Copies path into context struct (max 260 bytes).
/// Returns nonzero on success. Callee cleans stack (RET 0xC).
/// Called by EnableChatLogging (game) and CombatLogAdd (SuperWoW) with hardcoded paths.
pub const FN_INIT_LOG_BUFFER: usize = 0x0065a0c0;
/// WriteFormattedLogMessage — __stdcall(handle: u32, fmt: [*:0]const u8, va_list: *anyopaque).
/// WriteFormattedLogMessage - __stdcall(handle: u32, fmt: [*:0]const u8, va_list: *anyopaque).
/// Three fixed params, callee cleans stack (RET 0xC). Third arg is va_list pointer.
/// Writes a timestamped, formatted line to the log buffer. Auto-flushes at 48KB.
pub const FN_WRITE_FMT_LOG_MSG: usize = 0x0065ac20;
@@ -46,7 +46,7 @@ pub const FN_WRITE_FMT_LOG_MSG: usize = 0x0065ac20;
// Character select / enter world
// =============================================================================
/// HandleCharacterSelection — void(void), called by Lua EnterWorld().
/// HandleCharacterSelection - void(void), called by Lua EnterWorld().
/// Fires when the player clicks "Enter World" on the character select screen,
/// before world loading and log buffer initialization.
pub const FN_HANDLE_CHAR_SELECT: usize = 0x0046b500;
@@ -66,4 +66,3 @@ pub const CHAR_ENTRY_SIZE: usize = 0x120;
/// Offset of character name within a character entry.
pub const CHAR_NAME_OFFSET: usize = 0x08;
+2 -3
View File
@@ -66,7 +66,7 @@ pub fn pushnil(L: State) void {
pub fn pushnumber(L: State, n: f64) void {
// __thiscall: ECX=L, f64 on stack [EBP+8]/[EBP+0xc], ret 8.
// .never_tail: callee-cleanup pops stack args — tail-call would corrupt the stack.
// .never_tail: callee-cleanup pops stack args - tail-call would corrupt the stack.
const f: *const fn (State, f64) callconv(.{ .x86_thiscall = .{} }) void = @ptrFromInt(0x6F3810);
@call(.never_tail, f, .{ L, n });
}
@@ -136,8 +136,7 @@ pub fn luaError(L: State, msg: [*:0]const u8) void {
: [L] "r" (@intFromPtr(L)),
[msg] "r" (@intFromPtr(msg)),
[func] "r" (@as(u32, 0x6F4940)),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
pub const LuaReg = extern struct {
+130 -43
View File
@@ -15,6 +15,7 @@ const build_opts = struct {
const customassets = @import("build_options").enable_customassets;
const healtextfix = @import("build_options").enable_healtextfix;
const bigcursor = @import("build_options").enable_bigcursor;
const dpslog = @import("build_options").enable_dpslog;
};
// Conditional module imports
@@ -29,6 +30,7 @@ const transmogfix = if (build_opts.transmogfix) @import("transmogfix/transmogfix
const customassets = if (build_opts.customassets) @import("customassets/customassets.zig") else struct {};
const healtextfix = if (build_opts.healtextfix) @import("healtextfix/healtextfix.zig") else struct {};
const bigcursor = if (build_opts.bigcursor) @import("bigcursor/bigcursor.zig") else struct {};
const dpslog = if (build_opts.dpslog) @import("dpslog/dpslog.zig") else struct {};
const WINAPI = std.builtin.CallingConvention.winapi;
const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
@@ -43,7 +45,7 @@ var protection_hook: hook.Detour(fn () callconv(sc) void) = .{};
fn luaProtectionDetour() callconv(sc) void {}
// =============================================================================
// Lua C API wrappers (WoW 1.12.1 — all __fastcall, L in ECX)
// Lua C API wrappers (WoW 1.12.1 - all __fastcall, L in ECX)
// =============================================================================
pub const lua = @import("lua.zig");
@@ -67,8 +69,7 @@ fn allocateGameBuffer(size: u32) ?[*]u8 {
: [size] "r" (size),
[src] "r" (@intFromPtr(@as([*:0]const u8, "weirdutils"))),
[func] "r" (@as(u32, 0x6462E0)),
: .{ .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .ecx = true, .edx = true, .memory = true, .cc = true });
}
// =============================================================================
@@ -89,8 +90,7 @@ fn weirdUtilsVersion(L: lua.State) callconv(.c) u32 {
:
: [_] "{ecx}" (@intFromPtr(L)),
[func] "r" (@as(u32, 0x6F3810)),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
return 1;
}
@@ -114,6 +114,10 @@ fn registerLuaFunctions() void {
registerFunction("GetCombatLogPath", @intFromPtr(&logsessions.luaGetCombatLogPath));
registerFunction("GetChatLogPath", @intFromPtr(&logsessions.luaGetChatLogPath));
}
if (build_opts.bigcursor) {
registerFunction("SetCursorScale", @intFromPtr(&bigcursor.luaSetCursorScale));
registerFunction("GetCursorScale", @intFromPtr(&bigcursor.luaGetCursorScale));
}
if (build_opts.worldmarkers and markers.isActive()) {
// User-facing functions stay global
registerFunction("WorldMarker", @intFromPtr(&markers.luaWorldMarker));
@@ -125,7 +129,6 @@ fn registerLuaFunctions() void {
.{ .name = "SetMarkerDef", .func = @intFromPtr(&markers.luaSetMarkerDef) },
.{ .name = "ClearMarkerDef", .func = @intFromPtr(&markers.luaClearMarkerDef) },
.{ .name = "GetMarkerDef", .func = @intFromPtr(&markers.luaGetMarkerDef) },
.{ .name = null, .func = 0 }, // sentinel
};
lua.openlib(lua.getContext(), "WorldMarkers", &lib, 0);
@@ -343,7 +346,7 @@ fn isFakeFileContext(ctx_addr: u32) bool {
hook.readMem(u32, ctx_addr + 0x30) != 0; // embedded ptr set
}
/// Call initializeFileContext (0x647290) — __thiscall(ECX=ctx, type)
/// Call initializeFileContext (0x647290) - __thiscall(ECX=ctx, type)
fn callInitFileContext(ctx: [*]u8, file_type: u32) void {
asm volatile (
\\push %[ftype]
@@ -352,22 +355,20 @@ fn callInitFileContext(ctx: [*]u8, file_type: u32) void {
: [_] "{ecx}" (@intFromPtr(ctx)),
[ftype] "r" (file_type),
[func] "r" (@as(u32, 0x647290)),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
/// Call cleanupFileContext (0x6472d0) — __thiscall(ECX=ctx)
/// Call cleanupFileContext (0x6472d0) - __thiscall(ECX=ctx)
fn callCleanupFileContext(ctx: [*]u8) void {
asm volatile (
\\call *%[func]
:
: [_] "{ecx}" (@intFromPtr(ctx)),
[func] "r" (@as(u32, 0x6472d0)),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
/// Free a buffer via FreeMemory/SMemFree (0x646430) — __stdcall(ptr, src, flags)
/// Free a buffer via FreeMemory/SMemFree (0x646430) - __stdcall(ptr, src, flags)
fn freeGameBuffer(ptr: [*]u8) void {
asm volatile (
\\push $0xffffffff
@@ -378,8 +379,7 @@ fn freeGameBuffer(ptr: [*]u8) void {
: [ptr] "r" (@intFromPtr(ptr)),
[src] "r" (@intFromPtr(@as([*:0]const u8, "weirdutils"))),
[func] "r" (@as(u32, 0x646430)),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
// --- Hook 1: openFileWithOptions (0x6477c0) ---
@@ -522,7 +522,7 @@ fn processAsyncDetour(param1: u32) callconv(fc) void {
return;
}
// Not our fake — call original
// Not our fake - call original
process_async_hook.callOriginal(.{param1});
}
@@ -540,7 +540,7 @@ fn cleanupFileHandleDetour(file_ctx: u32) callconv(sc) void {
}
}
// Always use original CleanupFileHandleResources — it handles fake contexts correctly
// Always use original CleanupFileHandleResources - it handles fake contexts correctly
// (NULL-safe checks on +0x04/+0x3C/+0x40/+0x08, then cleanupFileContext + FreeMemory).
cleanup_file_handle_hook.callOriginal(.{file_ctx});
@@ -552,7 +552,7 @@ fn cleanupFileHandleDetour(file_ctx: u32) callconv(sc) void {
fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) callconv(tc) u32 {
// file_handle IS the file context address directly (Ghidra shows pointer* but
// the assembly pushes it directly to GetFileSizeFromHandle — no dereference)
// the assembly pushes it directly to GetFileSizeFromHandle - no dereference)
if (isFakeFileContext(file_handle)) {
con.fmt("[file] loadModelAsync: model=0x{x} fh=0x{x} cb={d}\n", .{ model, file_handle, should_use_callback });
const data_ptr = hook.readMem(u32, file_handle + 0x30);
@@ -570,7 +570,7 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32)
// Store size in model first (original does this before allocation)
@as(*align(1) u32, @ptrFromInt(model + 0x134)).* = data_size;
// Allocate buffer via setCullMode (0x71f9a0) — same as original path
// Allocate buffer via setCullMode (0x71f9a0) - same as original path
// setCullMode is __fastcall(ECX=size), returns buffer pointer
const buffer_addr = hook.fastcall(u32, 0x71f9a0, data_size, 0);
if (buffer_addr == 0) {
@@ -588,7 +588,7 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32)
@memcpy(buffer[0..data_size], src[0..data_size]);
con.print("[file] memcpy done\n");
// No async task — set task pointer to NULL
// No async task - set task pointer to NULL
@as(*align(1) u32, @ptrFromInt(model + 0x0c)).* = 0;
con.print("[file] task=0 set\n");
@@ -611,13 +611,13 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32)
hook.readMem(u32, model + 0x138),
});
// Call processLoadedModelData directly — __fastcall(ECX=model)
// Call processLoadedModelData directly - __fastcall(ECX=model)
con.fmt("[file] calling processLoadedModelData(0x{x})...\n", .{model});
const result = hook.fastcall(u32, 0x71d640, model, 0);
con.print("[file] processLoadedModelData returned\n");
con.fmt("[file] result=0x{x}\n", .{result});
// Dump model fields after processLoadedModelData — check if texture async task was created
// Dump model fields after processLoadedModelData - check if texture async task was created
con.print("[file] POST dump:\n");
con.fmt("[file] POST model+0x0c=0x{x} +0x130=0x{x} +0x134=0x{x} +0x138=0x{x}\n", .{
hook.readMem(u32, model + 0x0c),
@@ -631,7 +631,7 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32)
return 1;
}
// Not our fake — call original
// Not our fake - call original
return model_load_hook.callOriginal(.{ model, file_handle, should_use_callback });
}
@@ -744,8 +744,7 @@ fn callLoadFileListWithIncludes(toc_path: [*:0]const u8, md5ctx: *[88]u8, error_
[_] "{edx}" (@intFromPtr(md5ctx)),
[eh] "r" (error_handler),
[func] "r" (@as(u32, 0x6EDB90)),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
fn callLoadUIBindingsFromFile(path: [*:0]const u8, md5ctx: *[88]u8, callback: u32) void {
@@ -761,8 +760,7 @@ fn callLoadUIBindingsFromFile(path: [*:0]const u8, md5ctx: *[88]u8, callback: u3
[path] "r" (@intFromPtr(path)),
[md5] "r" (@intFromPtr(md5ctx)),
[cb] "r" (callback),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
// =============================================================================
@@ -783,11 +781,14 @@ fn engineInitDetour() callconv(sc) void {
if (build_opts.healtextfix) {
healtextfix.lateInit();
}
if (build_opts.bigcursor) {
bigcursor.lateInit();
}
}
// =============================================================================
// Hook: World_HandleLogoutCleanup (0x491180)
// Fires on real character logout/disconnect only — NOT on /reload or map change.
// Fires on real character logout/disconnect only - NOT on /reload or map change.
// =============================================================================
var logout_hook: hook.Detour(fn () callconv(sc) void) = .{};
@@ -795,11 +796,11 @@ var logout_hook: hook.Detour(fn () callconv(sc) void) = .{};
fn logoutDetour() callconv(sc) void {
con.print("[weirdutils] World_HandleLogoutCleanup -- player logout\n");
// Reset per-session state — only on real logout/disconnect, not /reload.
// Reset per-session state - only on real logout/disconnect, not /reload.
if (build_opts.worldmarkers) markers.onShutdown();
if (build_opts.logsessions) logsessions.onShutdown();
// Clean up world objects BEFORE game teardown — modules with
// Clean up world objects BEFORE game teardown - modules with
// remove_on_shutdown must destroy while game systems are alive.
comptime var i = modules.len;
inline while (i > 0) {
@@ -820,13 +821,15 @@ fn logoutDetour() callconv(sc) void {
var shutdown_hook: hook.Detour(fn () callconv(sc) void) = .{};
// =============================================================================
// Module lifecycle — single table drives install, shutdown, and uninstall.
// Module lifecycle - single table drives install, shutdown, and uninstall.
// Adding a module here guarantees all three phases are handled.
// =============================================================================
const ModuleHooks = struct {
name: ?[*:0]const u8 = null,
install: ?*const fn () void = null,
remove: ?*const fn () void = null,
is_active: ?*const fn () bool = null,
/// If true, remove is also called during CGGameUI_Shutdown (before game
/// teardown), not just during DLL unload. Use for modules that create
/// world objects which must be destroyed while game systems are alive.
@@ -836,23 +839,24 @@ const ModuleHooks = struct {
/// Order matters: modules are installed top-to-bottom, removed bottom-to-top.
/// Modules with remove_on_shutdown run their remove during shutdownDetour too.
const modules = [_]ModuleHooks{
if (build_opts.customassets) .{ .install = customassets.installHooks, .remove = customassets.removeHooks } else .{},
if (build_opts.framecrash) .{ .install = framecrash.installHooks, .remove = framecrash.removeHooks } else .{},
if (build_opts.logsessions) .{ .install = logsessions.installHooks, .remove = logsessions.removeHooks } else .{},
if (build_opts.transmogfix) .{ .install = transmogfix.installHooks, .remove = transmogfix.removeHooks } else .{},
if (build_opts.minimapicons) .{ .install = minimapicons.installHooks, .remove = minimapicons.removeHooks } else .{},
if (build_opts.healtextfix) .{ .install = healtextfix.installHooks, .remove = healtextfix.removeHooks } else .{},
if (build_opts.bigcursor) .{ .install = bigcursor.installHooks, .remove = bigcursor.removeHooks } else .{},
if (build_opts.worldmarkers) .{ .install = markers.installHooks, .remove = markers.removeHooks } else .{},
if (build_opts.interact) .{ .install = interact.installHooks, .remove = interact.removeHooks } else .{},
if (build_opts.outline) .{ .remove = outline.cleanup } else .{},
if (build_opts.screenshot) .{ .remove = screenshot.removeHook } else .{},
if (build_opts.customassets) .{ .name = customassets.module_name, .install = customassets.installHooks, .remove = customassets.removeHooks, .is_active = customassets.isActive } else .{},
if (build_opts.framecrash) .{ .name = framecrash.module_name, .install = framecrash.installHooks, .remove = framecrash.removeHooks, .is_active = framecrash.isActive } else .{},
if (build_opts.logsessions) .{ .name = logsessions.module_name, .install = logsessions.installHooks, .remove = logsessions.removeHooks, .is_active = logsessions.isActive } else .{},
if (build_opts.transmogfix) .{ .name = transmogfix.module_name, .install = transmogfix.installHooks, .remove = transmogfix.removeHooks, .is_active = transmogfix.isActive } else .{},
if (build_opts.minimapicons) .{ .name = minimapicons.module_name, .install = minimapicons.installHooks, .remove = minimapicons.removeHooks, .is_active = minimapicons.isActive } else .{},
if (build_opts.healtextfix) .{ .name = healtextfix.module_name, .install = healtextfix.installHooks, .remove = healtextfix.removeHooks, .is_active = healtextfix.isActive } else .{},
if (build_opts.bigcursor) .{ .name = bigcursor.module_name, .install = bigcursor.installHooks, .remove = bigcursor.removeHooks, .is_active = bigcursor.isActive } else .{},
if (build_opts.dpslog) .{ .name = dpslog.module_name, .install = dpslog.installHooks, .remove = dpslog.removeHooks, .is_active = dpslog.isActive } else .{},
if (build_opts.worldmarkers) .{ .name = markers.module_name, .install = markers.installHooks, .remove = markers.removeHooks, .is_active = markers.isActive } else .{},
if (build_opts.interact) .{ .name = interact.module_name, .install = interact.installHooks, .remove = interact.removeHooks, .is_active = interact.isActive } else .{},
if (build_opts.outline) .{ .name = outline.module_name, .remove = outline.cleanup, .is_active = outline.isActive } else .{},
if (build_opts.screenshot) .{ .name = screenshot.module_name, .remove = screenshot.removeHook, .is_active = screenshot.isActive } else .{},
};
fn shutdownDetour() callconv(sc) void {
con.print("[weirdutils] CGGameUI_Shutdown\n");
// Per-session resets and remove_on_shutdown cleanup live in logoutDetour
// (World_HandleLogoutCleanup) — fires on real logout/disconnect only, not /reload.
// (World_HandleLogoutCleanup) - fires on real logout/disconnect only, not /reload.
shutdown_hook.callOriginal(.{});
}
@@ -898,6 +902,89 @@ fn uninstall() void {
con.deinit();
}
// =============================================================================
// Runtime Module Control API - exported for other DLLs
// =============================================================================
fn asciiEqlIgnoreCase(a: [*:0]const u8, b: [*:0]const u8) bool {
var i: usize = 0;
while (true) : (i += 1) {
const ca = a[i];
const cb = b[i];
if (ca == 0 and cb == 0) return true;
if (ca == 0 or cb == 0) return false;
const la = if (ca >= 'A' and ca <= 'Z') ca + 32 else ca;
const lb = if (cb >= 'A' and cb <= 'Z') cb + 32 else cb;
if (la != lb) return false;
}
}
/// Returns 1 if the named module is compiled in AND currently active, 0 otherwise.
fn isModuleActive(name: [*:0]const u8) callconv(.c) i32 {
inline for (modules) |m| {
if (m.name) |mod_name| {
if (asciiEqlIgnoreCase(name, mod_name)) {
if (m.is_active) |active_fn| {
return if (active_fn()) 1 else 0;
}
return 0;
}
}
}
return 0;
}
/// Disables the named module by calling its remove function.
/// Returns 1 if found and removed, 0 if not found or not compiled in.
fn disableModule(name: [*:0]const u8) callconv(.c) i32 {
inline for (modules) |m| {
if (m.name) |mod_name| {
if (asciiEqlIgnoreCase(name, mod_name)) {
if (m.remove) |rm| {
rm();
return 1;
}
return 0;
}
}
}
return 0;
}
/// Disables all modules in reverse order, then detaches core hooks.
/// Returns the number of modules that were disabled.
fn disableAll() callconv(.c) i32 {
var count: i32 = 0;
// Remove modules in reverse order
comptime var i = modules.len;
inline while (i > 0) {
i -= 1;
if (modules[i].remove) |rm| {
rm();
count += 1;
}
}
// Detach core hooks
shutdown_hook.detach();
logout_hook.detach();
engine_init_hook.detach();
load_addons_hook.detach();
lsf_hook.detach();
file_hook.detach();
removeFileHooks();
protection_hook.detach();
return count;
}
comptime {
@export(&isModuleActive, .{ .name = "WeirdUtils_IsModuleActive" });
@export(&disableModule, .{ .name = "WeirdUtils_DisableModule" });
@export(&disableAll, .{ .name = "WeirdUtils_DisableAll" });
}
// =============================================================================
// DLL entry point
// =============================================================================
+39 -39
View File
@@ -59,16 +59,16 @@ Archives are stored in a **dynamic array** managed as a struct:
Global archive array struct at 0x8826b4:
+0x00 [0x8826b4]: capacity (max slots)
+0x04 [0x8826b8]: count (current number of archives)
+0x08 [0x8826bc]: array_ptr (SArchive** — pointer to array of SArchive pointers)
+0x08 [0x8826bc]: array_ptr (SArchive** - pointer to array of SArchive pointers)
+0x0C [0x8826c0]: growth_incr (allocation growth increment)
RTTI tag: ".PAVSArchive@@" at 0x82e248
```
Managed by:
- `GrowArchiveArray` (0x4045a0) — __thiscall, resizes the array
- `ResizeArchiveArray` (0x4046f0) — sets initial capacity
- `MPQ_CleanupAllArchives` (0x403c70) — iterates count→0 calling Archive_Close
- `GrowArchiveArray` (0x4045a0) - __thiscall, resizes the array
- `ResizeArchiveArray` (0x4046f0) - sets initial capacity
- `MPQ_CleanupAllArchives` (0x403c70) - iterates count→0 calling Archive_Close
### SArchive Object (Minimal Wrapper)
@@ -114,18 +114,18 @@ From `InitializeArchiveStructure` (0x655bf0), the full MPQ handle (pointed to by
| +0x290 | `[0xa4]` | Attributes offset |
| +0x294 | `[0xa5]` | Hash table allocated buffer |
The I/O vtable at `+0x140 [0x50]` is critical — it provides the read callbacks that Storm uses to access the archive data. Read calls go through `(*(code **)(*param_1[0x50] + 4))(...)`.
The I/O vtable at `+0x140 [0x50]` is critical - it provides the read callbacks that Storm uses to access the archive data. Read calls go through `(*(code **)(*param_1[0x50] + 4))(...)`.
### Archive Registration (How MPQs Are Opened)
```
MPQ_InitializeArchives (0x403740)
│
├─ ResizeArchiveArray(...) — allocate the global array
├─ ResizeArchiveArray(...) - allocate the global array
│
├─ For each base MPQ (model, texture, terrain, wmo, sound, misc, interface, fonts, dbc):
│ └─ OpenMPQArchiveWithPaths(name, param2, index) [0x403b00]
│ ├─ FormatPath(buf, 0x104, pathIndex, name) — tries "Data\name" then "..\Data\name"
│ ├─ FormatPath(buf, 0x104, pathIndex, name) - tries "Data\name" then "..\Data\name"
│ └─ Archive_OpenUnified(path, ..., &PTR_008826bc[index]) [0x648dd0]
│ └─ OpenFileWithValidation(path, ..., &archive) [0x655690]
│ └─ InitializeArchiveStructure(archive, ...) [0x655bf0]
@@ -189,7 +189,7 @@ struct IOObject { // 0x118 bytes total
[4] +0x10: 0x66e1c0 = IOManagerDestructor
[5] +0x14: 0x66e1e0 = IOManagerCleanup
[6] +0x18: 0x66e200 = (unknown)
[7] +0x1C: 0x7f800000 = (float NaN — padding/sentinel)
[7] +0x1C: 0x7f800000 = (float NaN - padding/sentinel)
[8] +0x20: 0x66e2d0 = ValidateIOOperation
[9] +0x24: 0x66e310 = GetIOResult
[10]+0x28: 0x66e340 = CancelIOOperation
@@ -235,7 +235,7 @@ The `SArchive` wrapper at the top level (8 bytes: `{type, handle}`) bridges thes
Storm has no "register a provider" API. Every type code (0-4) is hardcoded to specific OS handle types. The I/O vtable exists but is deeply intertwined with async thread managers and buffer management (0x118 bytes of state). Creating a custom IO object is theoretically possible but requires replicating the async infrastructure.
The most practical "virtual filesystem" approaches use **real OS handles** that Storm can consume natively — either through temp files or through MPQ archives that Storm opens and manages itself.
The most practical "virtual filesystem" approaches use **real OS handles** that Storm can consume natively - either through temp files or through MPQ archives that Storm opens and manages itself.
---
@@ -245,11 +245,11 @@ The most practical "virtual filesystem" approaches use **real OS handles** that
Use Windows `CreateFile` with `FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE` to create **real OS file handles** backed primarily by the filesystem cache (RAM). Then hook only `openFileWithOptions` to redirect Storm to our temp files.
Windows `FILE_ATTRIBUTE_TEMPORARY` tells the cache manager to avoid flushing to disk if possible — the data lives in RAM. `FILE_FLAG_DELETE_ON_CLOSE` auto-deletes the file when the last handle closes, even on crash.
Windows `FILE_ATTRIBUTE_TEMPORARY` tells the cache manager to avoid flushing to disk if possible - the data lives in RAM. `FILE_FLAG_DELETE_ON_CLOSE` auto-deletes the file when the last handle closes, even on crash.
### Implementation
1. **DLL init** — for each embedded asset:
1. **DLL init** - for each embedded asset:
```c
// Get temp directory
GetTempPath(MAX_PATH, tempDir);
@@ -271,7 +271,7 @@ Windows `FILE_ATTRIBUTE_TEMPORARY` tells the cache manager to avoid flushing to
// Store mapping: "Spells\\WU_XYZ.m2" → tempPath
```
2. **Hook `openFileWithOptions` (0x6477c0)** — single hook:
2. **Hook `openFileWithOptions` (0x6477c0)** - single hook:
```
openFileDetour(archive, path, flags, handle_out):
if path matches our asset map:
@@ -280,18 +280,18 @@ Windows `FILE_ATTRIBUTE_TEMPORARY` tells the cache manager to avoid flushing to
return original(archive, path, flags, handle_out)
```
3. **No other hooks needed** — Storm opens the temp file with `CreateFileA`, gets a real handle. All native I/O works:
3. **No other hooks needed** - Storm opens the temp file with `CreateFileA`, gets a real handle. All native I/O works:
- `GetFileSizeFromHandle` → type-0 dispatch → `fstat` on real handle ✓
- `ReadFileFromMultipleSources` → type-0 dispatch → `fileReadWithLock` on real handle ✓
- `processAsyncFileOperation` → reads from real handle via worker thread ✓
- `CleanupFileHandleResources` → closes real handle ✓
- `loadModelFromFileAsync` → async task reads real handle ✓
4. **Cleanup** — on DLL unload, close our original handles. `FILE_FLAG_DELETE_ON_CLOSE` handles the rest. If the process crashes, Windows still cleans up temp files.
4. **Cleanup** - on DLL unload, close our original handles. `FILE_FLAG_DELETE_ON_CLOSE` handles the rest. If the process crashes, Windows still cleans up temp files.
### Why This Works For Async
The entire reason our current approach needs 6 hooks is that fake type-0 contexts with NULL handles fail when the async executor calls `fileReadWithLock` directly. With real temp file handles, the async executor reads from a real OS handle — no hooks needed on the read path at all.
The entire reason our current approach needs 6 hooks is that fake type-0 contexts with NULL handles fail when the async executor calls `fileReadWithLock` directly. With real temp file handles, the async executor reads from a real OS handle - no hooks needed on the read path at all.
### Path Redirection Strategy
@@ -325,7 +325,7 @@ Files should go in the system temp directory (`GetTempPath`), NOT the game direc
`FILE_ATTRIBUTE_TEMPORARY` is a hint to the Windows cache manager:
- Data is kept in the filesystem cache (RAM) and written to disk lazily
- For small files (~20 assets, totaling a few MB), Windows will almost certainly keep everything in cache
- This is NOT a guarantee — under memory pressure, Windows may flush to disk
- This is NOT a guarantee - under memory pressure, Windows may flush to disk
- But for our use case (~2-3 MB total), the data effectively stays in RAM
### Key Addresses
@@ -343,10 +343,10 @@ OR:
| `locateFileInDirectories` | 0x647e60 | __fastcall(6) | Return temp path as disk file |
### Pros
- **Reduces from 6 hooks to 1** — the biggest win
- **All async I/O works natively** — real OS handles, no fake contexts
- **Reduces from 6 hooks to 1** - the biggest win
- **All async I/O works natively** - real OS handles, no fake contexts
- No fake file contexts, no critical section management, no refcount tracking
- No MPQ building required — files are individual temp files
- No MPQ building required - files are individual temp files
- Auto-cleanup via `FILE_FLAG_DELETE_ON_CLOSE`
- Temp files are invisible to the user (in system temp dir)
- The `loadFileDetour` (0x648620) for addon files could remain unchanged
@@ -356,7 +356,7 @@ OR:
- Requires `CreateFile`/`WriteFile` Win32 calls from Zig (straightforward via `@import("std").os.windows`)
- ~20 temp files created at init (small overhead, ~2-3 MB total)
- Temp file creation adds a few ms to DLL init
- If DLL loads after `scanDirectoriesForFiles` (likely), the disk hash table won't have our files — must use path redirection hook rather than relying on native discovery
- If DLL loads after `scanDirectoriesForFiles` (likely), the disk hash table won't have our files - must use path redirection hook rather than relying on native discovery
---
@@ -386,9 +386,9 @@ Build a real MPQ archive at compile time containing all embedded assets. Write i
| Global array struct | 0x8826b4 | `{capacity, count, array_ptr, growth}` |
### Pros
- **Eliminates ALL 6 Storm I/O hooks** — game reads MPQ natively
- **Eliminates ALL 6 Storm I/O hooks** - game reads MPQ natively
- Zero fake file contexts, zero async handling
- Proven approach — this is how patch.MPQ works
- Proven approach - this is how patch.MPQ works
- The `loadFileDetour` (0x648620) for addon files could remain unchanged
### Cons
@@ -434,10 +434,10 @@ For disk-based MPQs, this vtable points to functions that call `ReadFile`/`SetFi
#### B1: Fake the File Backing (Simpler)
1. Embed MPQ in DLL via `@embedFile`
2. Hook `openFileWithOptions` to intercept when Storm opens `"WeirdUtils.mpq"` — create a fake file context pointing to the embedded MPQ blob
2. Hook `openFileWithOptions` to intercept when Storm opens `"WeirdUtils.mpq"` - create a fake file context pointing to the embedded MPQ blob
3. Add **seek support** to the fake context (current implementation only does flat memcpy from offset 0)
4. Call `Archive_OpenUnified("WeirdUtils.mpq", ...)` — Storm reads the MPQ header, hash table, block table through our hooked I/O
5. For subsequent reads FROM the archive (when loading files within the MPQ), Storm seeks within the same archive file handle — our hook serves the right bytes
4. Call `Archive_OpenUnified("WeirdUtils.mpq", ...)` - Storm reads the MPQ header, hash table, block table through our hooked I/O
5. For subsequent reads FROM the archive (when loading files within the MPQ), Storm seeks within the same archive file handle - our hook serves the right bytes
**Key difference from current approach**: Instead of matching ~20 individual paths and creating ~20 fake contexts, we serve **one** fake file (the MPQ itself). Storm handles all per-file hash lookup, decompression, and I/O natively.
@@ -448,8 +448,8 @@ For disk-based MPQs, this vtable points to functions that call `ReadFile`/`SetFi
- File path matching reduces from ~20 paths to 1
**What we might drop**:
- `loadModelAsyncDetour` (0x71d4e0) — this hook exists because M2 async loading bypasses our read hook for type-0 fake contexts. But archive files go through Storm's type-4 read path, which reads from the archive handle. If the archive handle is our fake context with seek support, Storm's own async code should work.
- `processAsyncDetour` (0x647350) — same reasoning; the async executor reads from the archive file context, which goes through our hooked `readFileDetour`.
- `loadModelAsyncDetour` (0x71d4e0) - this hook exists because M2 async loading bypasses our read hook for type-0 fake contexts. But archive files go through Storm's type-4 read path, which reads from the archive handle. If the archive handle is our fake context with seek support, Storm's own async code should work.
- `processAsyncDetour` (0x647350) - same reasoning; the async executor reads from the archive file context, which goes through our hooked `readFileDetour`.
**Risk**: The async executor might call `fileReadWithLock` directly on the archive handle, bypassing `ReadFileFromMultipleSources`. This is the exact problem that forced hooks 4 and 6 in the current approach. Needs verification.
@@ -483,11 +483,11 @@ For disk-based MPQs, this vtable points to functions that call `ReadFile`/`SetFi
### Concept
Instead of hooking individual I/O functions, hook `locateFileInDirectories` (0x647e60) — the single function that decides WHERE a file comes from (disk, hash cache, or archive). Make it return type=0 (disk) with a path that resolves to our embedded data.
Instead of hooking individual I/O functions, hook `locateFileInDirectories` (0x647e60) - the single function that decides WHERE a file comes from (disk, hash cache, or archive). Make it return type=0 (disk) with a path that resolves to our embedded data.
### Why This Doesn't Quite Work
`locateFileInDirectories` only decides the file *location* — it doesn't serve data. After it returns type=0, `openFileWithOptions` calls `openFileHandle` to actually open the disk file. If there's no real file on disk, this fails.
`locateFileInDirectories` only decides the file *location* - it doesn't serve data. After it returns type=0, `openFileWithOptions` calls `openFileHandle` to actually open the disk file. If there's no real file on disk, this fails.
However, this could work in combination with disk writes (see Approach A) or with a minimal I/O hook.
@@ -548,7 +548,7 @@ This works for **disk-based** loose files but not for in-memory embedded data. H
| Target | Address | Patch |
|--------|---------|-------|
| `CheckFileExistence` | 0x654DD0 | Hook target — `__fastcall(ECX=filename, EDX=flags, [esp+4]=output)` |
| `CheckFileExistence` | 0x654DD0 | Hook target - `__fastcall(ECX=filename, EDX=flags, [esp+4]=output)` |
| Gate 1 (JZ) | 0x654b5c | `74 25 → 90 90` (NOP) |
| Gate 2 (JNZ) | 0x654b6a | `75 17 → 90 90` (NOP) |
| Glob pattern byte | 0x82edc2 | `3F → 2A` ('?' → '*') for multi-char patch names |
@@ -576,20 +576,20 @@ This works for **disk-based** loose files but not for in-memory embedded data. H
### Best Overall: Approach E (Windows Temp File Handles)
**Reasoning**:
1. **Reduces from 6 hooks to 1** — the single biggest complexity reduction possible
2. **All async I/O works natively** — real OS handles eliminate the entire class of async-bypass bugs that forced hooks 4, 5, and 6
3. **No MPQ building required** — avoids the entire hash table encryption / block table / header format complexity
4. **No new RE work** — we already know `openFileWithOptions` (0x6477c0) intimately
5. **Auto-cleanup** — `FILE_FLAG_DELETE_ON_CLOSE` handles cleanup even on crash
6. **Low risk** — we're giving Storm exactly what it expects (real disk files), just in a temp location
7. **Files live in RAM** — `FILE_ATTRIBUTE_TEMPORARY` keeps data in the filesystem cache for our small (~2-3 MB) asset set
1. **Reduces from 6 hooks to 1** - the single biggest complexity reduction possible
2. **All async I/O works natively** - real OS handles eliminate the entire class of async-bypass bugs that forced hooks 4, 5, and 6
3. **No MPQ building required** - avoids the entire hash table encryption / block table / header format complexity
4. **No new RE work** - we already know `openFileWithOptions` (0x6477c0) intimately
5. **Auto-cleanup** - `FILE_FLAG_DELETE_ON_CLOSE` handles cleanup even on crash
6. **Low risk** - we're giving Storm exactly what it expects (real disk files), just in a temp location
7. **Files live in RAM** - `FILE_ATTRIBUTE_TEMPORARY` keeps data in the filesystem cache for our small (~2-3 MB) asset set
**Next steps for Approach E**:
1. Write a prototype: create temp files from embedded data at DLL init
2. Hook `openFileWithOptions` to redirect matching paths to temp file paths
3. Verify M2 model loading works end-to-end (including textures via async path)
4. If successful, remove hooks 2-6 and the fake file context infrastructure
5. Keep `loadFileDetour` (0x648620) for addon files — these use a different pipeline
5. Keep `loadFileDetour` (0x648620) for addon files - these use a different pipeline
### Runner-Up: Approach A (Temp MPQ on Disk)
@@ -680,7 +680,7 @@ The customassets project's `CheckFileExistence` hook and glob pattern patch coul
|--------|---------|-------------|
| Archive array capacity | 0x8826b4 | Max archive slots |
| Archive array count | 0x8826b8 | Current archive count |
| Archive array pointer | 0x8826bc | `SArchive**` — array of pointers |
| Archive array pointer | 0x8826bc | `SArchive**` - array of pointers |
| Archive array growth | 0x8826c0 | Growth increment |
| Disk hash table base | 0xc521e8 | File path → disk path hash table |
| Disk hash table mask | 0xc521f0 | Hash mask (0xffffffff = disabled) |
+116 -116
View File
@@ -1,7 +1,7 @@
# M2 Model Loading Research
## Root Cause (Confirmed)
M2 model loading uses `openFileWithOptions` (0x6477c0) directly — it **never** goes through our hooked `LoadFileWithTextureResourceFallback` (0x648620). That's why embedded M2 files aren't served.
M2 model loading uses `openFileWithOptions` (0x6477c0) directly - it **never** goes through our hooked `LoadFileWithTextureResourceFallback` (0x648620). That's why embedded M2 files aren't served.
## M2 Loading Call Chain
```
@@ -30,17 +30,17 @@ createModelAttachment (0x707350)
| `CleanupFileHandleResources` (0x648730) | `__stdcall` | 1 | `RET 0x04` |
| `loadModelFromFileAsync` (0x71d4e0) | `__thiscall` | ECX=this, 2 stack | `RET 0x08` |
## GetFileSizeFromHandle (0x6487f0) — dispatches on context type
- Type 0: `fstatFileHandle(ctx[1]+0x10)` — needs real file handle
- Type 1: returns `ctx[5]` (value at +0x14) — **simplest, just returns a stored value**
## GetFileSizeFromHandle (0x6487f0) - dispatches on context type
- Type 0: `fstatFileHandle(ctx[1]+0x10)` - needs real file handle
- Type 1: returns `ctx[5]` (value at +0x14) - **simplest, just returns a stored value**
- Types 2/3: `GetAudioStreamPosition(ctx[0xf])`
- Type 4: `getFileSize(ctx[0x10])`
## ReadFileFromMultipleSources (0x648460) — dispatches on context type
- Type 0: `fileReadWithLock(buffer, 1, size, ctx[1])` — needs real file handle
## ReadFileFromMultipleSources (0x648460) - dispatches on context type
- Type 0: `fileReadWithLock(buffer, 1, size, ctx[1])` - needs real file handle
- Other types: use respective handle fields
## initializeFileContext (0x647290) — __thiscall, ECX=ctx, 1 stack param (type)
## initializeFileContext (0x647290) - __thiscall, ECX=ctx, 1 stack param (type)
```c
TraverseListNodes((LPCRITICAL_SECTION)(this + 0x24)); // init critical section
*(uint *)this = param_1; // +0x00: type
@@ -57,7 +57,7 @@ TraverseListNodes((LPCRITICAL_SECTION)(this + 0x24)); // init critical section
*(uint *)(this + 0x1C) = 0;
```
## CleanupFileHandleResources (0x648730) — __stdcall, 1 param, RET 0x04
## CleanupFileHandleResources (0x648730) - __stdcall, 1 param, RET 0x04
```c
if (ctx + 0x04 != NULL) closeFileStreamSafely(ctx + 0x04); // disk file handle
if (ctx + 0x3C != 0) Stream_CompareBuffers(ctx + 0x3C); // stream
@@ -82,7 +82,7 @@ if (ctx + 0x40 != NULL) closeArchiveFile(ctx + 0x40); // archive
```
Queued via `AsyncTask_QueueForExecution` (0x443ae0). Executor reads from file handle into buffer, then calls completion callback.
## loadResourceByPath (0x706a50) — __thiscall, ECX=resourceMgr
## loadResourceByPath (0x706a50) - __thiscall, ECX=resourceMgr
After `openFileWithOptions` succeeds:
1. Allocates 0x164-byte model object via `M2_AllocateModelBuffer`
2. `initializeModelObject(modelObj, resourceMgr)`
@@ -90,7 +90,7 @@ After `openFileWithOptions` succeeds:
4. On success: copies normalized path into modelObj+0x20, sets up hash links
5. On failure: `CleanupFileHandleResources`, free model object, return NULL
## LoadFileWithTextureResourceFallback (0x648620) — our hooked function
## LoadFileWithTextureResourceFallback (0x648620) - our hooked function
Calls `openFileWithOptions(param_1, path, async_flag, &handle_out)`, then:
1. `GetFileSizeFromHandle(handle, NULL)` → size
2. `M2_AllocateModelBuffer(size + extra_alloc)` → buffer
@@ -119,7 +119,7 @@ Every file context type needs a **real file handle or archive handle** for the r
### Option 3: Hook openFileWithOptions only
- Make it produce a context that works through existing read pipeline
- **Hardest** — requires understanding all read paths for the chosen type
- **Hardest** - requires understanding all read paths for the chosen type
## Implementation: In-Memory File Serving (chosen: Option 1 extended)
@@ -136,21 +136,21 @@ Every file context type needs a **real file handle or archive handle** for the r
### Fake File Context Layout
Allocated via `allocateGameBuffer(0x60)`, zero-filled, then:
- `initializeFileContext(ctx, 0)` — sets type=0, inits critsec at +0x24
- `initializeFileContext(ctx, 0)` - sets type=0, inits critsec at +0x24
- `+0x0C`: duplicated path string (game-allocated)
- `+0x30`: embedded data pointer (custom field, points into DLL .rdata)
- `+0x34`: embedded data size (custom field)
- **Detection**: `type==0 && handle(+0x04)==NULL && *(ctx+0x30)!=0`
- Return value from openFileWithOptions: 2 (non-zero = success)
### processAsyncFileOperation (0x647350) — verified via Ghidra
### processAsyncFileOperation (0x647350) - verified via Ghidra
- `__fastcall(ECX=request)`, plain `RET` (c3)
- Request: `+0x08`=file_ctx, `+0x0C`=dest_buf, `+0x10`=read_size, `+0x14`=seek/event struct
- Event handle at `*(*(request+0x14)+4)` — signaled via `SetEvent`
- Event handle at `*(*(request+0x14)+4)` - signaled via `SetEvent`
- Cleanup epilogue: decrement `*(ctx+0x5c)`, `LeaveCriticalSection(ctx+0x24)`, signal event, conditional `CleanupFileHandleResources`
- Close-after-read flag at `*(ctx+0x58)`
### CleanupFileHandleResources (0x648730) — verified decompile
### CleanupFileHandleResources (0x648730) - verified decompile
```c
void CleanupFileHandleResources(int ctx) {
if (ctx == 0) return;
@@ -160,21 +160,21 @@ void CleanupFileHandleResources(int ctx) {
if (*(ctx+0x08)) { /* check+free sub-buffer */ FreeMemory(*(ctx+0x08)); }
cleanupFileContext(ctx); // destroy critsec
FreeMemory(ctx); // free 0x60 struct
// NOTE: does NOT free path at +0x0C — we must free it ourselves
// NOTE: does NOT free path at +0x0C - we must free it ourselves
}
```
FreeMemory (SMemFree) at **0x646430** — `__stdcall(ptr, src_str, flags)`.
FreeMemory (SMemFree) at **0x646430** - `__stdcall(ptr, src_str, flags)`.
### Crash: Hook Install Order Matters
**Symptom**: Crash on game load in `loadFileDetour` calling `file_hook.getTrampoline()`.
The trampoline memory (VirtualAlloc'd) contained zeros instead of the saved prologue.
**Analysis**:
- Crash at `0x075D5E88` (trampoline memory) — bytes: `00 00 00 00`
- Crash at `0x075D5E88` (trampoline memory) - bytes: `00 00 00 00`
- Return address `0x04AA1542` in weirdutils.dll = `CALL *%eax` in `loadFileDetour`
- `getTrampoline` (compiled at DLL+0x3250) reads `file_hook.trampoline` field from `.data` section at `0x1015c0d8`, checks non-NULL, calls through it
**Root cause**: `LoadFileWithTextureResourceFallback` (0x648620) internally calls `openFileWithOptions` (0x6477c0). If `file_hook` is installed FIRST (copying the 0x648620 prologue to its trampoline), and THEN we patch 0x6477c0, the trampoline's execution context is disrupted. The `file_hook` trampoline runs the original 0x648620 prologue which eventually calls 0x6477c0 — but if 0x6477c0 was patched after the trampoline was built, there may be page-level or VirtualProtect interactions that corrupt the trampoline's allocated memory.
**Root cause**: `LoadFileWithTextureResourceFallback` (0x648620) internally calls `openFileWithOptions` (0x6477c0). If `file_hook` is installed FIRST (copying the 0x648620 prologue to its trampoline), and THEN we patch 0x6477c0, the trampoline's execution context is disrupted. The `file_hook` trampoline runs the original 0x648620 prologue which eventually calls 0x6477c0 - but if 0x6477c0 was patched after the trampoline was built, there may be page-level or VirtualProtect interactions that corrupt the trampoline's allocated memory.
**Fix**: Install Storm I/O hooks (`installFileHooks`) BEFORE `file_hook` at 0x648620. Remove in reverse order.
@@ -184,20 +184,20 @@ There are TWO independent async systems for file I/O:
**1. High-level async task system** (used by M2 model + texture loading):
- Queue: `AsyncTask_QueueForExecution` (0x443ae0)
- Worker: `AsyncTaskWorkerThread` (0x443360) — calls **ReadFileFromMultipleSources** (our hook 3!)
- Main thread: `ProcessAsyncTasksWithTimeLimit` (0x443E70) — calls completion callbacks
- Worker: `AsyncTaskWorkerThread` (0x443360) - calls **ReadFileFromMultipleSources** (our hook 3!)
- Main thread: `ProcessAsyncTasksWithTimeLimit` (0x443E70) - calls completion callbacks
- Task structure: `[0]=file_ctx, [1]=buffer, [2]=size, [3]=callback_ctx, [4]=callback_fn`
- For M2 models: callback = `onModelLoadComplete` (0x71d5e0), executor = `asyncFileReader` (0x71d610)
- For textures: callback = `TextureLoadCallback` (0x44a500), queued from `LoadTextureFromPath` (0x44a310)
**2. Low-level file async system** (NOT used for texture/M2 loading):
- `processAsyncFileOperation` (0x647350) — calls **fileReadWithLock** (0x740c97) directly
- `processAsyncFileOperation` (0x647350) - calls **fileReadWithLock** (0x740c97) directly
- Request: `+0x08=file_ctx, +0x0C=dest_buf, +0x10=read_size, +0x14=seek/event`
- Type-dispatched: type 0→fileReadWithLock, type 1→decompressFileData, type 2/3→stream, type 4→archive
- Has close-after-read flag at ctx+0x58, refcount at ctx+0x5c
- NOT installed as a hook — not needed since texture loading uses the high-level system
- NOT installed as a hook - not needed since texture loading uses the high-level system
### onModelLoadComplete (0x71d5e0) — Verified Decompile
### onModelLoadComplete (0x71d5e0) - Verified Decompile
```c
void __fastcall onModelLoadComplete(void *modelObject) {
CleanupFileHandleResources(**(int **)(modelObject + 0xc)); // free file handle via task
@@ -208,7 +208,7 @@ void __fastcall onModelLoadComplete(void *modelObject) {
```
**Critical**: cleanup file handle BEFORE processLoadedModelData. Our hook matches this order.
### cleanupFileContext (0x6472d0) — Verified Decompile
### cleanupFileContext (0x6472d0) - Verified Decompile
```c
void __fastcall cleanupFileContext(int param_1) {
if (*(param_1 + 0x1c)) { cleanupInflateContext(*(param_1+0x1c)); FreeMemory(*(param_1+0x1c)); }
@@ -220,7 +220,7 @@ void __fastcall cleanupFileContext(int param_1) {
```
NOTE: cleanupFileContext DOES free +0x0C (path string). Do NOT free it manually.
### TextureLoadCallback (0x44a500) — Verified Decompile
### TextureLoadCallback (0x44a500) - Verified Decompile
```c
void __fastcall TextureLoadCallback(int texture_obj) {
puVar1 = ProcessTextureData(texture_obj);
@@ -233,7 +233,7 @@ void __fastcall TextureLoadCallback(int texture_obj) {
```
Called by ProcessAsyncTasksWithTimeLimit on the main thread after worker completes.
### LoadTextureFromPath (0x44a310) — Texture Async Task Setup
### LoadTextureFromPath (0x44a310) - Texture Async Task Setup
```c
puVar5 = openFileWithOptions(NULL, path, flags, &file_handle); // hook 1 creates fake
puVar7 = AllocateAsyncTaskObject();
@@ -251,7 +251,7 @@ AsyncTask_QueueForExecution(task);
- Crash at EIP=0x00000000 after processLoadedModelData returns 1
- Stack shows TextureLoadCallback (0x44A526 = after CALL CleanupFileHandleResources)
- File context 0x36596088 on stack (was M2 ctx, freed, reused as BLP ctx)
- EDX=0x36596080 (ctx-8), EBP=04AA3212 (in weirdutils.dll — corrupted frame ptr)
- EDX=0x36596080 (ctx-8), EBP=04AA3212 (in weirdutils.dll - corrupted frame ptr)
- Crash appears to be inside our cleanupFileHandleDetour during BLP context cleanup
- BLP data IS served correctly (AsyncTaskWorkerThread → ReadFileFromMultipleSources → hook 3)
- Investigation ongoing: possible stack corruption in callCleanupFileContext or freeGameBuffer
@@ -274,7 +274,7 @@ WorldFrameUpdate(this, deltaTime):
if hitType == 2: HandleTargetSelection(this, &localResult) // object hover
```
### UpdateHitTest (0x481F00) — __fastcall(ECX=worldFrame)
### UpdateHitTest (0x481F00) - __fastcall(ECX=worldFrame)
Called on **click events** (not every frame). Performs the same raycast but stores
the result persistently at `worldFrame + 0x350`:
```c
@@ -301,14 +301,14 @@ void __fastcall UpdateHitTest(void *worldFrame) {
| +0x388 | 4 | f32 | Ray distance |
### AoE Targeting Reticle Globals (only during spell targeting)
- `0x00B4B3A0` Vec3 — terrain position under cursor (written by HandleGroundTargeting)
- `0x00B4B3B0` f32 — spell targeting radius
- `0x0083DC2C` u32 — validity: 0=valid, 1=out-of-range, 3=updating
- `0x00CECAC0` u16 — spell targeting state flags (0x20=terrain, 0x40=secondary)
- `0x00B4B3A0` Vec3 - terrain position under cursor (written by HandleGroundTargeting)
- `0x00B4B3B0` f32 - spell targeting radius
- `0x0083DC2C` u32 - validity: 0=valid, 1=out-of-range, 3=updating
- `0x00CECAC0` u16 - spell targeting state flags (0x20=terrain, 0x40=secondary)
### Click-to-Move Destination
- `0x00C4D890` Vec3 — destination (only when CTM initiated)
- `0x00C4D888` u32 — movement mode
- `0x00C4D890` Vec3 - destination (only when CTM initiated)
- `0x00C4D888` u32 - movement mode
### Key Functions
| Address | Name | Convention | Params |
@@ -326,10 +326,10 @@ void __fastcall UpdateHitTest(void *worldFrame) {
### Approach for Markers
### HitTestPoint / WorldIntersectionTest Return Values
`WorldIntersectionTest(rayStart, rayEnd, gameStateFlags, result)` returns:
- `0` — no intersection (sky) — coords NOT written to result
- `gameStateFlags & 1` — terrain hit — coords written. Returns 1 only during
- `0` - no intersection (sky) - coords NOT written to result
- `gameStateFlags & 1` - terrain hit - coords written. Returns 1 only during
AoE targeting (bit 0 set), otherwise returns 0 even on valid terrain hit
- `2` — object hit (closer than terrain)
- `2` - object hit (closer than terrain)
So hitType=0 is ambiguous: either "terrain hit in normal mode" or "no hit at all".
To distinguish: zero the result coords before calling, then check if they were written.
@@ -337,7 +337,7 @@ To distinguish: zero the result coords before calling, then check if they were w
### Approach for Markers
Call `UpdateHitTest(worldFrame)` to perform the raycast and store result at
`worldFrame+0x350`. Zero intersection coords before the call, then check if
they were populated. This is safe from Lua callbacks — `HitTestPoint`
they were populated. This is safe from Lua callbacks - `HitTestPoint`
saves/restores view matrices. The persistent result at `worldFrame+0x358` is
normally only click-updated, but overwriting it is harmless.
@@ -553,19 +553,19 @@ cause the visual glitch. Need to identify which bone tracks use global sequences
---
## World Teardown — Entity Cleanup Crash Investigation
## World Teardown - Entity Cleanup Crash Investigation
### Crash Details
- **Crash function**: 0x687220 — generic linked-list unlink operation
- First crash at 0x687243: `mov [edx], esi` — write to freed memory
- Second crash at 0x687221: `mov esi, [ecx]` — read from freed memory
- **When**: Logout to character select, map transitions — NOT during normal gameplay
- **Crash function**: 0x687220 - generic linked-list unlink operation
- First crash at 0x687243: `mov [edx], esi` - write to freed memory
- Second crash at 0x687221: `mov esi, [ecx]` - read from freed memory
- **When**: Logout to character select, map transitions - NOT during normal gameplay
- **Thread**: Background/worker thread (very short stack: WoW.exe → kernel32 → ntdll)
- **Root cause**: WDOODADDEF heap teardown iterates linked list, hits freed or corrupt node
### Decompiled Crash Function (0x687220)
```c
// Linked-list unlink — removes node from intrusive doubly-linked list
// Linked-list unlink - removes node from intrusive doubly-linked list
void __fastcall UnlinkFromList(int *param_1) {
int prev = *param_1; // param_1[0] = prev pointer
if (prev != 0) {
@@ -597,7 +597,7 @@ void __fastcall UnlinkFromList(int *param_1) {
### World Teardown Chain (Ghidra-verified)
```
CleanupWorldAndEntities (0x66fc40) — void(), no params, __stdcall
CleanupWorldAndEntities (0x66fc40) - void(), no params, __stdcall
├── CleanupEntityList_ProcessAll() ← iterates UNKNOWN list
└── CleanupWorldAndReleaseResources (0x697ac0)
├── ClearWorldObjectsAndResetState (0x6a6710) ← iterates linked list at PTR_00c96088
@@ -608,13 +608,13 @@ CleanupWorldAndEntities (0x66fc40) — void(), no params, __stdcall
```
### Callers of CleanupWorldAndEntities (0x66fc40)
- `InitializeWorldScene` (0x401bc0) — **map change** (cleans old world before loading new)
- `ShutdownClientSystems` (0x401ee0) — **full game exit**
- `InitializeWorldScene` (0x401bc0) - **map change** (cleans old world before loading new)
- `ShutdownClientSystems` (0x401ee0) - **full game exit**
### Callers of ClearWorldObjectsAndResetState (0x6a6710)
- `LoadWorldMap` (0x6941f0) — map loading
- `UpdateWorldAndGameObjects` (0x698390) — periodic world update (chunk unloading?)
- `CleanupWorldAndReleaseResources` (0x697ac0) — full teardown
- `LoadWorldMap` (0x6941f0) - map loading
- `UpdateWorldAndGameObjects` (0x698390) - periodic world update (chunk unloading?)
- `CleanupWorldAndReleaseResources` (0x697ac0) - full teardown
- `SimpleWorldUpdate` (0x694920)
### Entity Type Dispatch in CleanupEntity_ProcessAttachments (0x670d50)
@@ -636,7 +636,7 @@ void __fastcall CleanupEntity_ProcessAttachments(entity) {
```
- M2 entities (CreateWorldUnit → WDOODADDEF heap) have flag 0x40
- WMO entities (CreateGameObject → WMAPOBJDEF heap) have flag 0x8
- Ghidra names are misleading — `cleanupGameObject` handles M2/WDOODADDEF, `destroyWorldEnvironment` handles WMO/WMAPOBJDEF
- Ghidra names are misleading - `cleanupGameObject` handles M2/WDOODADDEF, `destroyWorldEnvironment` handles WMO/WMAPOBJDEF
### CleanupEntity_ProcessAttachments Callers (ONLY 3 in entire binary)
- `processCinematicExit` (0x6e4940)
@@ -645,10 +645,10 @@ void __fastcall CleanupEntity_ProcessAttachments(entity) {
- **NOT called by CleanupEntityList_ProcessAll** or any teardown function
### WDOODADDEF Heap (0xCA7E20) References
- `AllocateRenderableObject` (0x6a07f7) — allocates from heap
- `CleanupVisualEffectAndRelease` (0x6a0916) — frees to heap
- `InitializeWorldSystem` (0x691f4f) — initializes heap
- `CleanupWorldSystem` (0x692241) — tears down heap
- `AllocateRenderableObject` (0x6a07f7) - allocates from heap
- `CleanupVisualEffectAndRelease` (0x6a0916) - frees to heap
- `InitializeWorldSystem` (0x691f4f) - initializes heap
- `CleanupWorldSystem` (0x692241) - tears down heap
- Called by `ShutdownAllGameSystems` (0x66fb00)
### Other Key Addresses
@@ -656,9 +656,9 @@ void __fastcall CleanupEntity_ProcessAttachments(entity) {
- `CleanupWorldSystem` (0x6920c0) → called by ShutdownAllGameSystems
- `cleanupSecondaryResources` (0x6a6c70) → called from CleanupWorldAndEntities + CleanupWorldSystem
- This calls `cleanupGameObject` (0x6a67a0) and `destroyWorldEnvironment` (0x6a6870) on entities
- `gameQuit` (0x41f9b0) — fires on disconnect/quit (ref: UnitXP_SP3)
- `gameQuit` (0x41f9b0) - fires on disconnect/quit (ref: UnitXP_SP3)
### World Unit Hash Table (0xCA7DC0) — The Crash Structure
### World Unit Hash Table (0xCA7DC0) - The Crash Structure
The crash occurs during teardown of a **hash table at 0xCA7DC0** that tracks all world units
(doodads created via `CreateWorldUnit`). Our entities ARE in this table.
@@ -682,7 +682,7 @@ The crash occurs during teardown of a **hash table at 0xCA7DC0** that tracks all
- `FindOrCreateWorldUnit` (0x694e90): reads 0xCA7DC4, 0xCA7DDC
- `complexListInitializerWithCleanup` (0x69f670): reads/writes ALL (init + teardown)
### CreateWorldUnit (0x694980) — Entity Registration (Decompiled)
### CreateWorldUnit (0x694980) - Entity Registration (Decompiled)
`CreateWorldUnit` inserts entities into **three** linked lists:
```c
@@ -722,7 +722,7 @@ int *CreateWorldUnit(char *modelPath, float *pos, float facing, int param4) {
**Critical**: Entity is in 3 lists. All 3 must be unlinked during cleanup.
### CleanupVisualEffectAndRelease (0x6a0840) — What It Unlinks
### CleanupVisualEffectAndRelease (0x6a0840) - What It Unlinks
`CleanupVisualEffectAndRelease` unlinks from up to **three** linked lists:
```c
@@ -807,21 +807,21 @@ Only 3 CALLs visible in the function body: `FindSubstringInString`, `CreateGameO
`ModelAttachment_CreateNode`. The M2 path (`CreateWorldUnit`) must be via tail-call or
the decompiler inlined it.
### CleanupWorldAndEntities (0x66fc40) — Full Chain (Decompiled)
### CleanupWorldAndEntities (0x66fc40) - Full Chain (Decompiled)
```c
void CleanupWorldAndEntities(void) {
CleanupEntityList_ProcessAll(); // 0x672c40 — iterates PTR_00c7b2dc (NOT hash table)
CleanupEntityList_ProcessAll(); // 0x672c40 - iterates PTR_00c7b2dc (NOT hash table)
CleanupWorldAndReleaseResources(); // 0x697ac0
PTR_00c7b748 = 0;
}
```
### CleanupEntityList_ProcessAll (0x672c40) — Decompiled
### CleanupEntityList_ProcessAll (0x672c40) - Decompiled
Iterates the linked list at `PTR_00c7b2dc` (NOT the hash table at 0xCA7DC0).
For each entry, calls `CleanupObjectAttachments_FreeMemory` which ends with
`DestroyWorldObjectAndRelease`. Our entities are NOT in `PTR_00c7b2dc` —
`DestroyWorldObjectAndRelease`. Our entities are NOT in `PTR_00c7b2dc` -
they're only registered in the hash table. So this function doesn't touch them.
```c
@@ -838,24 +838,24 @@ void CleanupEntityList_ProcessAll(void) {
```
**PTR_00c7b2dc xrefs** (who manages this list):
- `CleanupEntityList_ProcessAll` (0x672c40) — reads/iterates
- `UpdateFadeEffects_ProcessTimers` (0x672efe) — reads
- `DestroyFileMapping` (0x66f460) — reads + writes (teardown)
- `SetFileAttributes` (0x66f440) — writes (initialization)
- `CreateFadeEffect_EntityManagement` (0x672e76) — reads (entity insertion?)
- `CleanupEntityList_ProcessAll` (0x672c40) - reads/iterates
- `UpdateFadeEffects_ProcessTimers` (0x672efe) - reads
- `DestroyFileMapping` (0x66f460) - reads + writes (teardown)
- `SetFileAttributes` (0x66f440) - writes (initialization)
- `CreateFadeEffect_EntityManagement` (0x672e76) - reads (entity insertion?)
Our entities created via `CreateEntityInstance_WithAttachment` are NOT added to this
list. The callers (`CastSpellByID_Extended`, `CreateGameObjectPathEffect`) probably add
the returned entity to PTR_00c7b2dc themselves. We don't.
### Vtable at 0x0081089c — Hash Table Entry Destructors
### Vtable at 0x0081089c - Hash Table Entry Destructors
```
[0] 0x006a1170 DestroyMapDoodadDefinition — WDOODADDEF destructor
[0] 0x006a1170 DestroyMapDoodadDefinition - WDOODADDEF destructor
[1] 0x006a11a0 LoadAndAddMapDoodadToList
[2] 0x006a14d0 MapDoodadDestructor
[3] 0x006a1260 CleanupMapDoodadContainer
[4] 0x006a1320 DestroyMapObjectDefinition — WMAPOBJDEF destructor
[4] 0x006a1320 DestroyMapObjectDefinition - WMAPOBJDEF destructor
[5] 0x006a1350 LoadAndAddMapObjectToList
[6] 0x006a1590 MapObjectDestructor
[7] 0x006a1410 CleanupMapObjectContainer
@@ -869,9 +869,9 @@ void DestroyMapDoodadDefinition(undefined **param_1) {
}
```
This is a simple destructor: calls entity vtable[0](0) then frees via SMemFree.
NOT the same as CleanupVisualEffectAndRelease — doesn't unlink from lists.
NOT the same as CleanupVisualEffectAndRelease - doesn't unlink from lists.
### hashTableTeardownLoop (0x69f740) — atexit Handler (Decompiled)
### hashTableTeardownLoop (0x69f740) - atexit Handler (Decompiled)
Registered via `validateMemoryOperation` (atexit) at 0x69f730. Runs during process exit.
```c
@@ -912,7 +912,7 @@ void hashTableTeardownLoop(void) {
If an entity was freed (by our cleanup) but not unlinked from these lists, the teardown
follows dangling pointers into freed heap memory.
### cleanupGameObject (0x6a67a0) — Decompiled
### cleanupGameObject (0x6a67a0) - Decompiled
```c
void __fastcall cleanupGameObject(undefined **param_1) {
@@ -939,7 +939,7 @@ void __fastcall cleanupGameObject(undefined **param_1) {
}
```
### CleanupVisualEffectAndRelease (0x6a0840) — What It Actually Unlinks
### CleanupVisualEffectAndRelease (0x6a0840) - What It Actually Unlinks
```c
void __fastcall CleanupVisualEffectAndRelease(entity) {
@@ -963,7 +963,7 @@ void __fastcall CleanupVisualEffectAndRelease(entity) {
**CONFIRMED**: `CleanupVisualEffectAndRelease` unlinks entity[4]/[5] (global list)
and conditionally entity[0x2e-0x31]. It does NOT unlink from the **hash bucket list**.
### ManageLinkedList (0x695ef0) — Intrusive List Insertion
### ManageLinkedList (0x695ef0) - Intrusive List Insertion
```c
void __thiscall ManageLinkedList(void *this, int *entity, int mode, int insert_point) {
@@ -1063,29 +1063,29 @@ So the 3 intrusive list node offsets within a WDOODADDEF entity are:
After `ManageLinkedList` insertion, entity[0x2F] should always be non-zero
(sentinel has bit 0 set = odd address).
### InitializeRenderableObject (0x6a7d00) — Entity Initialization
### InitializeRenderableObject (0x6a7d00) - Entity Initialization
Called from `AllocateRenderableObject`. Zeroes most fields including:
- entity[0x2E] = 0, entity[0x2F] = 0 (bucket list node — zeroed before insertion)
- entity[0x30] = 0, entity[0x31] = 0 (global list node — zeroed before insertion)
- entity[0x2E] = 0, entity[0x2F] = 0 (bucket list node - zeroed before insertion)
- entity[0x30] = 0, entity[0x31] = 0 (global list node - zeroed before insertion)
- entity[2] |= 0x40 (sets the M2/WDOODADDEF flag)
- entity[0] = vtable PTR_DestroyRenderableObject_00810a74
### Callers of CreateEntityInstance_WithAttachment — What They Do After
### Callers of CreateEntityInstance_WithAttachment - What They Do After
Only 3 callers in entire binary:
1. **`CreateGameObjectPathEffect` (0x5f8030)** — `__thiscall` on a game object
1. **`CreateGameObjectPathEffect` (0x5f8030)** - `__thiscall` on a game object
- Does NOT save the return value! Fire-and-forget.
- Passes `(path, pos, facing, 0, 0, param_1, param_2)` — param_6/7 are parent refs
- Passes `(path, pos, facing, 0, 0, param_1, param_2)` - param_6/7 are parent refs
2. **`CastSpellByID_Extended` (0x6e4b60)** — spell casting
2. **`CastSpellByID_Extended` (0x6e4b60)** - spell casting
- Stores in global `PTR_00ceca8c`
- Calls `SetEntityFlag_ToggleBit(entity, 0)` = sets `entity[0xD] |= 1`
- Cleaned up by `processCinematicExit` → `CleanupEntity_ProcessAttachments(PTR_00ceca8c)`
- Passes `(path, pos, 0.0, 0, 0, 0, 0)` — update_now=0!
- Passes `(path, pos, 0.0, 0, 0, 0, 0)` - update_now=0!
3. **Unknown (0x6e5a6e)** — likely another spell effect
3. **Unknown (0x6e5a6e)** - likely another spell effect
**Key differences from our call**:
- Both native callers pass `update_now=0` (param_5). We pass `update_now=1`.
@@ -1098,7 +1098,7 @@ Only 3 callers in entire binary:
Map doodads use `FindOrCreateWorldUnit` (0x694e90) called from `AttachDoodadObjects` (0x695b1e).
These are separate creation paths that both register in the hash table but through different code.
### CleanupWorldAndReleaseResources (0x697ac0) — Full Chain
### CleanupWorldAndReleaseResources (0x697ac0) - Full Chain
```c
void CleanupWorldAndReleaseResources(void) {
@@ -1119,7 +1119,7 @@ atexit teardown at 0x691830 (separate from hash table teardown). Base offset is
Disabled ALL our cleanup (no CleanupEntity_ProcessAttachments, no world_cleanup_hook,
no removeHooks cleanup). Created 5 markers, replaced with 5 more, closed game.
**Still crashed.** This means the crash is NOT caused by our cleanup — the game's
**Still crashed.** This means the crash is NOT caused by our cleanup - the game's
own atexit handler can't handle our entities even when they're fully intact.
The WDOODADDEF heap is destroyed by `CleanupWorldSystem` (called from
@@ -1138,22 +1138,22 @@ and are NOT registered in any cleanup tracking list. They're cleaned up explicit
map change/exit, it would have the same crash problem as our entities.
The game avoids this because spells always end before map transitions. But we don't
have that guarantee — our markers persist across frames until explicitly cleared.
have that guarantee - our markers persist across frames until explicitly cleared.
### TODO
- [x] Decompile 0x672c40 (CleanupEntityList_ProcessAll) — iterates PTR_00c7b2dc, not hash table
- [x] Decompile hashTableTeardownLoop (0x69f740) — atexit handler, iterates all hash entries
- [x] Check vtable at 0x0081089c — DestroyMapDoodadDefinition, simple free
- [x] Decompile ContainerLookup (0x687960) — converts between list spaces
- [x] Decompile cleanupGameObject + CleanupVisualEffectAndRelease — handles all 3 lists IF entity[0x2F]!=0
- [x] Decompile ManageLinkedList (0x695ef0) — intrusive list with base offset
- [x] Decompile 0x672c40 (CleanupEntityList_ProcessAll) - iterates PTR_00c7b2dc, not hash table
- [x] Decompile hashTableTeardownLoop (0x69f740) - atexit handler, iterates all hash entries
- [x] Check vtable at 0x0081089c - DestroyMapDoodadDefinition, simple free
- [x] Decompile ContainerLookup (0x687960) - converts between list spaces
- [x] Decompile cleanupGameObject + CleanupVisualEffectAndRelease - handles all 3 lists IF entity[0x2F]!=0
- [x] Decompile ManageLinkedList (0x695ef0) - intrusive list with base offset
- [x] Confirm bucket base offset = 0xB8 from InitializeHashTable
- [x] TEST: no-cleanup build still crashes — crash is NOT from our cleanup
- [x] Decompile native callers — neither registers in extra lists
- [x] TEST: no-cleanup build still crashes - crash is NOT from our cleanup
- [x] Decompile native callers - neither registers in extra lists
- [x] **Investigate spell-spawned game objects (e.g. mailbox summon) as reference**
- Spell entities are NOT "persistent game objects" — they're client-side visual effects only
- Spell entities are NOT "persistent game objects" - they're client-side visual effects only
- `processCinematicExit` (0x6e4940) explicitly cleans them: `CleanupEntity_ProcessAttachments(entity); entity = NULL;`
- Called before every new spell cast — spell entities NEVER survive to teardown
- Called before every new spell cast - spell entities NEVER survive to teardown
- If a spell entity survived to atexit, it would crash too (same bug as ours)
- [x] Check what `ClearWorldObjectsAndResetState` (PTR_00c96088) contains
- PTR_00c96088 is a **terrain chunk list**, NOT a WDOODADDEF entity list
@@ -1161,12 +1161,12 @@ have that guarantee — our markers persist across frames until explicitly clear
- `LoadWorldTerrainChunk` writes to PTR_00c96084 (the list head)
- Map doodads are attached to parent chunks via `ModelAttachment_CreateNode` in `AttachDoodadObjects`
- `destroyPrimaryGameObject` (0x6a69f0) destroys the parent chunk, which walks attachment children
- We CANNOT participate in this list — it's for terrain chunks, not standalone entities
- We CANNOT participate in this list - it's for terrain chunks, not standalone entities
- [x] Determine proper entity lifecycle for persistent world objects
- **There is no native path for standalone persistent WDOODADDEF entities**
- All native callers either: (a) attach to parent chunks, or (b) explicitly clean up before teardown
- Correct approach: explicit cleanup via `CleanupEntity_ProcessAttachments` before teardown
- Hook point: `CleanupWorldAndEntities` (0x66fc40) PRE-hook — fires for exit, logout, AND map change
- Hook point: `CleanupWorldAndEntities` (0x66fc40) PRE-hook - fires for exit, logout, AND map change
## Entity Lifecycle Solution (Confirmed)
@@ -1201,7 +1201,7 @@ Hook `CleanupWorldAndEntities` (0x66fc40). Before calling the original:
1. Call `CleanupEntity_ProcessAttachments` on all active marker entities
2. Call `CleanupEntity_ProcessAttachments` on all despawning entities
3. Null all entity pointers / reset state
4. Call original — hash table no longer contains our entries → no crash
4. Call original - hash table no longer contains our entries → no crash
This handles ALL scenarios: game exit, logout, map change.
@@ -1215,7 +1215,7 @@ void __fastcall destroyPrimaryGameObject(undefined **param_1) {
}
```
#### processCinematicExit (0x6e4940) — Spell Entity Cleanup
#### processCinematicExit (0x6e4940) - Spell Entity Cleanup
```c
// After handling cinematic/targeting state...
if (PTR_00ceca8c != NULL) {
@@ -1224,7 +1224,7 @@ if (PTR_00ceca8c != NULL) {
}
```
#### CreateEntityInstance_WithAttachment (0x6707c0) — M2 Path
#### CreateEntityInstance_WithAttachment (0x6707c0) - M2 Path
```c
int * __fastcall CreateEntityInstance_WithAttachment(
char *modelPath, float *pos, float facing, int flags, int updateNow, int p6, int p7) {
@@ -1242,18 +1242,18 @@ int * __fastcall CreateEntityInstance_WithAttachment(
}
```
#### hashTableTeardownLoop (0x69f740) — The Crash Site
#### hashTableTeardownLoop (0x69f740) - The Crash Site
```c
void hashTableTeardownLoop(void) {
if ((DAT_00ca7cf0 & 1) == 0) {
DAT_00ca7cf0 |= 1;
_DAT_00ca7dc0 = &PTR_DestroyMapDoodadDefinition_0081089c;
// Walk global list — crashes here if entries point to freed heap
// Walk global list - crashes here if entries point to freed heap
while (PTR_00ca7dcc is valid) {
piVar2 = ValidateLinkedList(&PTR_00ca7dc4, PTR_00ca7dcc);
CalculateDistance3D(piVar2); // reads from freed entity memory
}
// Walk each hash bucket — also crashes
// Walk each hash bucket - also crashes
for each bucket in PTR_00ca7ddc {
while (bucket entry is valid) {
piVar2 = ValidateLinkedList(bucket, entry);
@@ -1288,32 +1288,32 @@ void ClearWorldObjectsAndResetState(void) {
**Every native M2 entity created via `CreateEntityInstance_WithAttachment` is explicitly cleaned up
by a parent.** The atexit handler (hashTableTeardownLoop) is a safety net for the hash table data
structure — it should NEVER encounter live entities in normal operation.
structure - it should NEVER encounter live entities in normal operation.
#### All 3 callers of CreateEntityInstance_WithAttachment:
1. **CastSpellByID_Extended (0x6e4b60)** — spell targeting reticle
1. **CastSpellByID_Extended (0x6e4b60)** - spell targeting reticle
- Stores entity in global `PTR_00ceca8c`
- Cleaned up by `processCinematicExit` before every new spell cast
- Also cleaned up by `executeSpellOrItem` (0x6e54f0)
2. **CreateGameObjectPathEffect (0x5f8030)** — game object visual effect
2. **CreateGameObjectPathEffect (0x5f8030)** - game object visual effect
- Called by `CreatePathObjectByUnitType` (0x5f4970)
- Return value saved at parent+0x10 (despite Ghidra typing it as void)
- Cleaned up by `DestroyPathObjectIfPresent` (0x5f4950) → `CleanupEntity_ProcessAttachments`
- Parent is a spell effect object (SpellEffectWithPath class at ~0x5f4800)
- `SpellEffectWithPathDestructor` (0x5f48d0) destroys parent during spell teardown
3. **Unknown caller (0x6e5a6e)** — likely in `executeSpellOrItem` (0x6e54f0)
3. **Unknown caller (0x6e5a6e)** - likely in `executeSpellOrItem` (0x6e54f0)
- Same pattern as #1
#### All 3 callers of CleanupEntity_ProcessAttachments (0x670d50):
- `processCinematicExit` (0x6e4940) — spell cleanup
- `executeSpellOrItem` (0x6e54f0) — spell/item cleanup
- `DestroyPathObjectIfPresent` (0x5f4950) — path effect cleanup
- `processCinematicExit` (0x6e4940) - spell cleanup
- `executeSpellOrItem` (0x6e54f0) - spell/item cleanup
- `DestroyPathObjectIfPresent` (0x5f4950) - path effect cleanup
#### CleanupVisualEffectAndRelease has only ONE caller:
- `cleanupGameObject` (0x6a67a0) — the M2/WDOODADDEF cleanup function
- `cleanupGameObject` (0x6a67a0) - the M2/WDOODADDEF cleanup function
So the full cleanup chain is always:
```
@@ -1332,7 +1332,7 @@ Parent destroyed
|------|--------|----------|-----------------|------|
| PTR_00c96088 | 0xC | Terrain chunk wrappers → doodad parents | destroyPrimaryGameObject | various |
| PTR_00c9e358 | via PTR_00c9e350 | WMO entity wrappers (from CreateEntityInstance_WithAttachment WMO path) | destroyWorldEnvironment | WMAPOBJDEF |
| PTR_00c962bc | 0xC | **Empty in 1.12.1** — no insertion code found, only init+atexit | cleanupGameObject (conditional) | WDOODADDEF |
| PTR_00c962bc | 0xC | **Empty in 1.12.1** - no insertion code found, only init+atexit | cleanupGameObject (conditional) | WDOODADDEF |
| PTR_00ca8044 | via PTR_00ca803c | Map manager objects | CleanupAndReleaseMemoryBlock | PTR_00ca7e10 (4th heap) |
| PTR_00c7b2dc | 0xC | WENTITY fade effect wrappers | CleanupObjectAttachments_FreeMemory → DestroyWorldObjectAndRelease | WENTITY |
+8 -8
View File
@@ -18,12 +18,12 @@ end
-- Delimiter is ":" (pipe "|" is WoW's escape char for color codes)
--
-- Messages:
-- P:idx:x:y:z:area — live placement (everyone processes)
-- C:idx — clear one marker
-- CA — clear all markers
-- SR — sync request (non-leader asking leader to send defs)
-- LSR — leader sync request (leader asking anyone to send defs)
-- SF:idx:x:y:z:area — sync fill (only processed by requester in sync mode)
-- P:idx:x:y:z:area - live placement (everyone processes)
-- C:idx - clear one marker
-- CA - clear all markers
-- SR - sync request (non-leader asking leader to send defs)
-- LSR - leader sync request (leader asking anyone to send defs)
-- SF:idx:x:y:z:area - sync fill (only processed by requester in sync mode)
--
-- Permission model: ALL permission checks are enforced DLL-side.
-- WorldMarker/ClearWorldMarker: DLL checks local player is leader/assist.
@@ -265,7 +265,7 @@ local function onAddonMessage(prefix, message, channel, sender)
return
end
-- P, C, CA — DLL checks sender permission via SetMarkerDef/ClearMarkerDef
-- P, C, CA - DLL checks sender permission via SetMarkerDef/ClearMarkerDef
if cmd == "P" then
local idx, x, y, z, areaId = parseMarkerFields(parts)
if idx then
@@ -315,7 +315,7 @@ local function scheduleSyncRequest()
end
-- =============================================================================
-- Roster change tracking — retriggerable debounce timer
-- Roster change tracking - retriggerable debounce timer
-- Each roster event that increases group size adds 1s to the timer (starts at
-- 5s, caps at 10s). When the timer expires, one broadcast fires. This
-- guarantees delivery after the storm of events settles.
+48 -64
View File
@@ -6,17 +6,17 @@
//! Entities are respawned automatically when the player approaches within 200y.
//!
//! Lua API (globals):
//! WorldMarker(index, x, y, z) — place marker at coordinates
//! WorldMarker(index, "unit") — place marker at unit's position
//! WorldMarker(index) — place marker at cursor terrain position
//! ClearWorldMarker(index) — remove specific marker (1-5)
//! ClearWorldMarker() — remove all markers
//! CanSetWorldMarkers() — returns 1 if leader/assist, nil otherwise
//! WorldMarker(index, x, y, z) - place marker at coordinates
//! WorldMarker(index, "unit") - place marker at unit's position
//! WorldMarker(index) - place marker at cursor terrain position
//! ClearWorldMarker(index) - remove specific marker (1-5)
//! ClearWorldMarker() - remove all markers
//! CanSetWorldMarkers() - returns 1 if leader/assist, nil otherwise
//!
//! Lua API (WorldMarkers table — internal, used by addon):
//! Lua API (WorldMarkers table - internal, used by addon):
//! WorldMarkers.SetMarkerDef(i, x, y, z, area, sender)
//! WorldMarkers.ClearMarkerDef([index,] sender)
//! WorldMarkers.GetMarkerDef(index) — returns x, y, z, areaId or nil
//! WorldMarkers.GetMarkerDef(index) - returns x, y, z, areaId or nil
const std = @import("std");
const hook = @import("zhook");
@@ -33,6 +33,9 @@ extern "kernel32" fn GetLastError() callconv(WINAPI) u32;
extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32;
extern "kernel32" fn GetTickCount() callconv(WINAPI) u32;
const ERROR_ALREADY_EXISTS: u32 = 183;
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "worldmarkers";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
@@ -77,7 +80,7 @@ pub const Vec3 = struct {
// State
// =============================================================================
/// Persistent marker definition — survives zone transitions.
/// Persistent marker definition - survives zone transitions.
const MarkerDef = struct {
pos: Vec3,
area_id: u32,
@@ -88,7 +91,7 @@ const EMPTY_DEF: MarkerDef = .{ .pos = .{ .x = 0, .y = 0, .z = 0 }, .area_id = 0
/// Marker definitions persist across zone transitions (NOT cleared in worldCleanupDetour).
var marker_defs: [NUM_MARKERS]MarkerDef = .{EMPTY_DEF} ** NUM_MARKERS;
/// Transient entity state — cleared on zone transition / teardown.
/// Transient entity state - cleared on zone transition / teardown.
var marker_entities: [NUM_MARKERS]?*anyopaque = .{null} ** NUM_MARKERS;
var marker_created_tick: [NUM_MARKERS]u32 = .{0} ** NUM_MARKERS;
var hold_queued: [NUM_MARKERS]bool = .{false} ** NUM_MARKERS;
@@ -115,7 +118,7 @@ const fc = std.builtin.CallingConvention{ .x86_fastcall = .{} };
const sc = std.builtin.CallingConvention{ .x86_stdcall = .{} };
// =============================================================================
// Permission check — leader or raid officer required
// Permission check - leader or raid officer required
// =============================================================================
/// Get local player GUID via GetPlayerGUID (0x468550).
@@ -132,7 +135,7 @@ fn getPlayerGUID() u64 {
}
/// Look up a player name from the name cache by GUID.
/// Calls RetrieveNPCDataFromCache — __thiscall(ECX=cache), 6 stack params, RET 0x18.
/// Calls RetrieveNPCDataFromCache - __thiscall(ECX=cache), 6 stack params, RET 0x18.
fn getNameFromGUID(guid_lo: u32, guid_hi: u32) ?[*:0]const u8 {
if (guid_lo == 0 and guid_hi == 0) return null;
var name_buf: [2]u32 = .{ 0, 0 };
@@ -140,7 +143,9 @@ fn getNameFromGUID(guid_lo: u32, guid_hi: u32) ?[*:0]const u8 {
guid_lo,
guid_hi,
@intFromPtr(&name_buf),
0, 0, 0,
0,
0,
0,
};
const result: u32 = asm volatile (
\\ push 20(%[a])
@@ -159,7 +164,7 @@ fn getNameFromGUID(guid_lo: u32, guid_hi: u32) ?[*:0]const u8 {
}
/// Check if the local player has permission to place/clear markers.
/// Uses direct memory reads — no Lua state required.
/// Uses direct memory reads - no Lua state required.
/// Requires: party leader, raid leader, or raid officer (assist).
fn canSetMarkers() bool {
const player_guid = getPlayerGUID();
@@ -191,7 +196,7 @@ fn canSetMarkers() bool {
}
/// Check if a named sender has permission (leader or raid officer).
/// Used to authenticate incoming addon messages — sender name comes from
/// Used to authenticate incoming addon messages - sender name comes from
/// CHAT_MSG_ADDON arg4 (server-verified, can't be spoofed).
fn senderHasPermission(sender: [*:0]const u8) bool {
const sender_span = std.mem.span(sender);
@@ -224,7 +229,6 @@ fn senderHasPermission(sender: [*:0]const u8) bool {
return std.mem.eql(u8, std.mem.span(leader_name), sender_span);
}
// =============================================================================
// Position helpers
// =============================================================================
@@ -261,14 +265,14 @@ fn getCursorTerrainPosition() ?Vec3 {
if (world_frame == 0 or world_frame < 0x10000) return null;
// Zero the intersection point before raycasting so we can detect "no hit"
// (HitTestPoint returns 0 for both "terrain hit" and "no hit" in normal mode —
// (HitTestPoint returns 0 for both "terrain hit" and "no hit" in normal mode -
// WorldIntersectionTest returns gameStateFlags & 1, which is 0 outside AoE targeting.
// On a real hit the coords are overwritten; on sky/no-hit they stay zeroed.)
@as(*align(1) u32, @ptrFromInt(world_frame + o.WF_HIT_TERRAIN_X)).* = 0;
@as(*align(1) u32, @ptrFromInt(world_frame + o.WF_HIT_TERRAIN_Y)).* = 0;
@as(*align(1) u32, @ptrFromInt(world_frame + o.WF_HIT_TERRAIN_Z)).* = 0;
// UpdateHitTest — __fastcall(ECX=worldFrame)
// UpdateHitTest - __fastcall(ECX=worldFrame)
hook.fastcall(void, o.FN_UPDATE_HIT_TEST, world_frame, 0);
const x = hook.readMem(f32, world_frame + o.WF_HIT_TERRAIN_X);
@@ -284,7 +288,7 @@ fn getCursorTerrainPosition() ?Vec3 {
// Game function wrappers
// =============================================================================
/// CreateEntityInstance_WithAttachment — __fastcall, RET 0x14.
/// CreateEntityInstance_WithAttachment - __fastcall, RET 0x14.
fn createEntityInstance(path: [*:0]const u8, pos: *[3]f32, facing: f32, flags: u32, update_now: u32) ?*anyopaque {
const facing_bits: u32 = @bitCast(facing);
const stack_args = [5]u32{
@@ -312,7 +316,7 @@ fn createEntityInstance(path: [*:0]const u8, pos: *[3]f32, facing: f32, flags: u
return if (result != 0) @ptrFromInt(result) else null;
}
/// CleanupEntity_ProcessAttachments — __fastcall(ECX=entity), no stack params.
/// CleanupEntity_ProcessAttachments - __fastcall(ECX=entity), no stack params.
fn cleanupEntity(obj: *anyopaque) void {
asm volatile ("call *%[func]"
:
@@ -326,7 +330,7 @@ fn cleanupEntity(obj: *anyopaque) void {
// =============================================================================
/// Play an animation on an entity's M2 model render context (entity+0x88).
/// CM2Model__PlayBoneAnimation — __thiscall(ECX=model), RET 0x1c.
/// CM2Model__PlayBoneAnimation - __thiscall(ECX=model), RET 0x1c.
fn playAnimation(entity: *anyopaque, anim_id: u32, queue: bool) void {
const entity_addr = @intFromPtr(entity);
const model = hook.readMem(u32, entity_addr + 0x88);
@@ -393,7 +397,7 @@ fn beginDespawn(entity: *anyopaque) void {
return;
}
}
// All slots full — force-cleanup the oldest and reuse slot 0
// All slots full - force-cleanup the oldest and reuse slot 0
if (despawning[0]) |old| {
cleanupEntity(old.entity);
}
@@ -410,7 +414,7 @@ fn beginDespawn(entity: *anyopaque) void {
fn placeMarker(index: usize, pos: Vec3) bool {
if (index >= NUM_MARKERS) return false;
// Clear existing entity in this slot (but not the def — we're about to overwrite it)
// Clear existing entity in this slot (but not the def - we're about to overwrite it)
clearEntity(index);
// Store persistent definition
@@ -486,7 +490,7 @@ fn clearAllMarkers() void {
pub fn luaWorldMarker(L: lua.State) callconv(.c) u32 {
if (!canSetMarkers()) {
con.print("[worldmarkers] WorldMarker: no permission\n");
return 0; // nil — addon shows user message
return 0; // nil - addon shows user message
}
const nargs = lua.gettop(L);
@@ -542,7 +546,7 @@ pub fn luaClearWorldMarker(L: lua.State) callconv(.c) u32 {
const lua_type: *const fn (lua.State, i32) callconv(fc) i32 = @ptrFromInt(0x6F3400);
if (nargs == 0 or lua_type(L, 1) == 0) {
// No args or nil — clear all
// No args or nil - clear all
clearAllMarkers();
lua.pushnumber(L, 1.0);
return 1;
@@ -590,7 +594,7 @@ fn tickAnimations() void {
hold_queued[i] = true;
}
// Zombie detection — entity exists but refcount dropped to 1 (culled by game).
// Zombie detection - entity exists but refcount dropped to 1 (culled by game).
// Check every 3 seconds to avoid per-frame overhead.
if (now -% last_zombie_tick >= ZOMBIE_CHECK_INTERVAL_MS) {
last_zombie_tick = now;
@@ -607,7 +611,7 @@ fn tickAnimations() void {
}
}
// Proximity respawn — check every ~1 second
// Proximity respawn - check every ~1 second
if (now -% last_respawn_tick >= RESPAWN_CHECK_INTERVAL_MS) {
last_respawn_tick = now;
@@ -638,7 +642,7 @@ fn tickAnimations() void {
/// Lua: SetMarkerDef(index, x, y, z, areaId, senderName)
/// Store a marker definition without immediately spawning. Used by the addon
/// when receiving remote marker data — proximity respawn handles entity creation.
/// when receiving remote marker data - proximity respawn handles entity creation.
/// senderName is verified against the group roster for leader/officer permission.
pub fn luaSetMarkerDef(L: lua.State) callconv(.c) u32 {
const nargs = lua.gettop(L);
@@ -673,15 +677,15 @@ pub fn luaSetMarkerDef(L: lua.State) callconv(.c) u32 {
return 0;
}
/// Lua: ClearMarkerDef(senderName) — clear all
/// Lua: ClearMarkerDef(index, senderName) — clear one
/// Lua: ClearMarkerDef(senderName) - clear all
/// Lua: ClearMarkerDef(index, senderName) - clear one
/// senderName is verified against the group roster for leader/officer permission.
pub fn luaClearMarkerDef(L: lua.State) callconv(.c) u32 {
const nargs = lua.gettop(L);
if (nargs < 1) return 0;
if (lua.isstring(L, 1) and (nargs == 1 or !lua.isnumber(L, 1))) {
// ClearMarkerDef(senderName) — clear all
// ClearMarkerDef(senderName) - clear all
const sender = lua.tostring(L, 1) orelse return 0;
if (!senderHasPermission(sender)) {
con.fmt("[worldmarkers] ClearMarkerDef: sender '{s}' denied\n", .{std.mem.span(sender)});
@@ -692,7 +696,7 @@ pub fn luaClearMarkerDef(L: lua.State) callconv(.c) u32 {
}
if (nargs >= 2 and lua.isnumber(L, 1) and lua.isstring(L, 2)) {
// ClearMarkerDef(index, senderName) — clear one
// ClearMarkerDef(index, senderName) - clear one
const sender = lua.tostring(L, 2) orelse return 0;
if (!senderHasPermission(sender)) {
con.fmt("[worldmarkers] ClearMarkerDef: sender '{s}' denied\n", .{std.mem.span(sender)});
@@ -737,8 +741,6 @@ pub fn luaCanSetMarkers(L: lua.State) callconv(.c) u32 {
return 0;
}
// =============================================================================
// World teardown hook
// =============================================================================
@@ -746,7 +748,7 @@ pub fn luaCanSetMarkers(L: lua.State) callconv(.c) u32 {
var world_cleanup_hook: hook.Detour(fn () callconv(sc) void) = .{};
var world_update_hook: hook.Detour(fn (u32) callconv(fc) void) = .{};
/// OnWorldUpdate hook — per-frame tick while world is active.
/// OnWorldUpdate hook - per-frame tick while world is active.
/// Drives animation state (Hold queue after Stand, despawn cleanup).
fn worldUpdateDetour(frame: u32) callconv(fc) void {
tickAnimations();
@@ -755,7 +757,7 @@ fn worldUpdateDetour(frame: u32) callconv(fc) void {
/// Pre-hook on CleanupWorldAndEntities (0x66fc40).
/// Destroys all our entities via CleanupEntity_ProcessAttachments before the
/// game's teardown runs — the same pattern every native caller uses (e.g.
/// game's teardown runs - the same pattern every native caller uses (e.g.
/// processCinematicExit, DestroyPathObjectIfPresent). This unlinks them from
/// the WDOODADDEF hash table so the atexit handler never touches freed memory.
fn worldCleanupDetour() callconv(sc) void {
@@ -765,8 +767,8 @@ fn worldCleanupDetour() callconv(sc) void {
}
/// Destroy all tracked entities (active markers + despawning).
/// Does NOT clear marker_defs — definitions persist for respawn.
/// Idempotent — safe to call multiple times.
/// Does NOT clear marker_defs - definitions persist for respawn.
/// Idempotent - safe to call multiple times.
fn destroyAllEntities() void {
var count: u32 = 0;
for (&marker_entities, 0..) |*slot, i| {
@@ -803,21 +805,10 @@ fn destroyAllEntities() void {
pub fn installHooks() void {
con.print("[worldmarkers] Module loaded\n");
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_WorldMarkersHook_{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("[worldmarkers] Another DLL owns world markers (mutex taken), skipping\n");
return;
}
g_is_hook_owner = true;
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
// Hook OnWorldUpdate for per-frame animation tick (runs every frame while world is active).
if (world_update_hook.attach(o.FN_ON_WORLD_UPDATE, &worldUpdateDetour) != .ok) {
@@ -827,7 +818,7 @@ pub fn installHooks() void {
}
// Hook CleanupWorldAndEntities to destroy our entities before world teardown.
// This fires on map change, logout, AND exit — before heaps are destroyed.
// This fires on map change, logout, AND exit - before heaps are destroyed.
if (world_cleanup_hook.attach(o.FN_CLEANUP_WORLD_AND_ENTITIES, &worldCleanupDetour) != .ok) {
con.print("[worldmarkers] FAILED to hook CleanupWorldAndEntities!\n");
} else {
@@ -835,7 +826,7 @@ pub fn installHooks() void {
}
}
/// Called from CGGameUI_Shutdown (logout/exit) — wipes marker definitions.
/// Called from CGGameUI_Shutdown (logout/exit) - wipes marker definitions.
/// Does NOT touch hooks or mutex. Entities are destroyed separately by
/// worldCleanupDetour which fires after shutdown.
pub fn onShutdown() void {
@@ -845,19 +836,12 @@ pub fn onShutdown() void {
pub fn removeHooks() void {
if (g_is_hook_owner) {
// destroyAllEntities is idempotent — if worldCleanupDetour already ran,
// destroyAllEntities is idempotent - if worldCleanupDetour already ran,
// all slots are null and this is a no-op.
destroyAllEntities();
world_update_hook.detach();
world_cleanup_hook.detach();
}
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+32 -32
View File
@@ -20,7 +20,7 @@ pub const MOVEMENT_POS_Z: usize = 0x18;
// Entity creation (high-level API)
// =============================================================================
/// CreateEntityInstance_WithAttachment — __fastcall, RET 0x14 (5 stack params).
/// CreateEntityInstance_WithAttachment - __fastcall, RET 0x14 (5 stack params).
/// ECX = modelPath (char*), EDX = position (float[3]*)
/// Stack: facing (float), flags (int), updateNow (int), param6 (int), param7 (int)
/// Returns: entity pointer (int*).
@@ -37,7 +37,7 @@ pub const FN_CREATE_ENTITY_INSTANCE: usize = 0x006707c0;
// World teardown (map unload / logout / exit)
// =============================================================================
/// CleanupWorldAndEntities — void(), no params, __stdcall.
/// CleanupWorldAndEntities - void(), no params, __stdcall.
/// Top-level world teardown: calls CleanupEntityList_ProcessAll, then
/// CleanupWorldAndReleaseResources (which iterates heaps and force-frees).
/// Called from InitializeWorldScene (map change) and ShutdownClientSystems (exit).
@@ -52,21 +52,21 @@ pub const FN_CLEANUP_WORLD_AND_ENTITIES: usize = 0x0066fc40;
/// __fastcall returns void**
pub const FN_ALLOCATE_WORLD_OBJECT: usize = 0x006a0930;
/// DestroyWorldObjectAndRelease(object) — __fastcall, ECX=obj, no stack params.
/// DestroyWorldObjectAndRelease(object) - __fastcall, ECX=obj, no stack params.
/// Unlinks from world object list (+0x10/+0x14), calls virtual destructor, frees heap.
/// ONLY for objects on WENTITY heap (from AllocateAndInitializeWorldObject).
pub const FN_DESTROY_WORLD_OBJECT: usize = 0x006a0a70;
/// CleanupEntity_ProcessAttachments(entity) — __fastcall, ECX=entity, no stack params.
/// CleanupEntity_ProcessAttachments(entity) - __fastcall, ECX=entity, no stack params.
/// High-level destructor counterpart to CreateEntityInstance_WithAttachment.
/// Walks and frees attachment children, decrements refcount at +0x0E, then
/// dispatches to type-specific destructor based on flags at +0x8:
/// flag 0x8 (M2): destroyWorldEnvironment (0x6a6870) — scene graph removal + free
/// flag 0x40 (WMO): cleanupGameObject (0x6a67a0) — render detach + spatial unlink + free
/// flag 0x8 (M2): destroyWorldEnvironment (0x6a6870) - scene graph removal + free
/// flag 0x40 (WMO): cleanupGameObject (0x6a67a0) - render detach + spatial unlink + free
/// Only actually frees when refcount reaches 0.
pub const FN_CLEANUP_ENTITY: usize = 0x00670d50;
/// DecrementReferenceCount(obj) — __fastcall, ECX=obj, no stack params.
/// DecrementReferenceCount(obj) - __fastcall, ECX=obj, no stack params.
/// Decrements ref count; when it reaches 0, calls virtual destructor to free.
pub const FN_DECREMENT_REFCOUNT: usize = 0x007103a0;
@@ -74,10 +74,10 @@ pub const FN_DECREMENT_REFCOUNT: usize = 0x007103a0;
// Cursor terrain position
// =============================================================================
/// WorldFrame global pointer — *(u32*)PTR = worldFrame object.
/// WorldFrame global pointer - *(u32*)PTR = worldFrame object.
pub const PTR_WORLD_FRAME: usize = 0x00B4B2BC;
/// UpdateHitTest — __fastcall(ECX=worldFrame), no stack params.
/// UpdateHitTest - __fastcall(ECX=worldFrame), no stack params.
/// Raycasts from camera through mouse cursor, stores result at worldFrame+0x350:
/// +0x350: hit type (0=none, 1=terrain, 2=object)
/// +0x360: terrain intersection X (f32)
@@ -95,7 +95,7 @@ pub const WF_HIT_TERRAIN_Z: usize = 0x368;
// Zone / area identification
// =============================================================================
/// Current zone area ID — numeric, locale-safe zone identifier.
/// Current zone area ID - numeric, locale-safe zone identifier.
/// Updated by the game as the player moves between areas.
pub const ZONE_AREA_ID: usize = 0x00B4E314;
@@ -103,7 +103,7 @@ pub const ZONE_AREA_ID: usize = 0x00B4E314;
// Per-frame world update
// =============================================================================
/// OnWorldUpdate — __fastcall(ECX=worldFrame), no stack params, void return.
/// OnWorldUpdate - __fastcall(ECX=worldFrame), no stack params, void return.
/// Called every frame while the world is active (in-game, not login screen).
/// Part of CGWorldFrame update pipeline.
pub const FN_ON_WORLD_UPDATE: usize = 0x00482EA0;
@@ -123,7 +123,7 @@ pub const FN_CM2_CREATE_FOR_MODEL_OBJECT: usize = 0x00695100;
// Animation
// =============================================================================
/// CM2Model__PlayBoneAnimation — __thiscall(ECX=modelRenderCtx), RET 0x1c (7 stack params).
/// CM2Model__PlayBoneAnimation - __thiscall(ECX=modelRenderCtx), RET 0x1c (7 stack params).
/// (boneIndex, animId, sequenceIndex, animData*, speed, blendMode, queueAnimation)
/// boneIndex: 0xFFFFFFFF = all bones
/// animId: M2 animation ID (0=Stand, 158=Hold, 159=Decay for Raid_UI_FX)
@@ -138,75 +138,75 @@ pub const FN_PLAY_BONE_ANIMATION: usize = 0x007121a0;
// Transform and position
// =============================================================================
/// UpdateObjectTransform_CalculateBounds — __fastcall, RET 0x0C
/// UpdateObjectTransform_CalculateBounds - __fastcall, RET 0x0C
/// ECX = world object, EDX = 4x4 transform matrix (float[16])
/// Stack: bounds (float[6] min/max), halfExtents (float[3]), forceUpdate (int)
pub const FN_UPDATE_OBJECT_TRANSFORM: usize = 0x006717d0;
// =============================================================================
// File I/O (Storm) — for in-memory file serving
// File I/O (Storm) - for in-memory file serving
// =============================================================================
/// openFileWithOptions — __stdcall(4), RET 0x10, prologue=9
/// openFileWithOptions - __stdcall(4), RET 0x10, prologue=9
/// (archive_ptr, path, flags, handle_out) → type_code (0=fail, 1-4=success)
pub const FN_OPEN_FILE_WITH_OPTIONS: usize = 0x006477c0;
/// GetFileSizeFromHandle — __stdcall(2), RET 0x08, prologue=6
/// GetFileSizeFromHandle - __stdcall(2), RET 0x08, prologue=6
/// (file_context, high_size_out) → size
pub const FN_GET_FILE_SIZE: usize = 0x006487f0;
/// ReadFileFromMultipleSources — __stdcall(6), RET 0x18, prologue=6
/// ReadFileFromMultipleSources - __stdcall(6), RET 0x18, prologue=6
/// (context, buffer, size, bytes_read_out, async_ptr, param6) → bool
/// async_ptr==NULL: synchronous read. Non-NULL: queues async operation.
pub const FN_READ_FILE: usize = 0x00648460;
/// CleanupFileHandleResources — __stdcall(1), RET 0x04, prologue=7
/// CleanupFileHandleResources - __stdcall(1), RET 0x04, prologue=7
/// (file_context) → 1
pub const FN_CLEANUP_FILE_HANDLE: usize = 0x00648730;
/// processAsyncFileOperation — __fastcall(ECX=request), plain RET, prologue=7
/// processAsyncFileOperation - __fastcall(ECX=request), plain RET, prologue=7
/// Request structure: +0x08=file_ctx, +0x0C=dest_buf, +0x10=read_size,
/// +0x14=seek/event_struct (*(+0x14)+4 = event handle)
pub const FN_PROCESS_ASYNC_FILE_OP: usize = 0x00647350;
/// initializeFileContext — __thiscall(ECX=ctx, type)
/// initializeFileContext - __thiscall(ECX=ctx, type)
/// Sets context type, initializes critical section at +0x24, zeroes fields.
pub const FN_INIT_FILE_CONTEXT: usize = 0x00647290;
/// cleanupFileContext — __thiscall(ECX=ctx)
/// cleanupFileContext - __thiscall(ECX=ctx)
/// Destroys critical section, cleanup companion to initializeFileContext.
pub const FN_CLEANUP_FILE_CONTEXT: usize = 0x006472d0;
/// FreeMemory (SMemFree) — __stdcall(3): (ptr, src_str, flags)
/// FreeMemory (SMemFree) - __stdcall(3): (ptr, src_str, flags)
pub const FN_FREE_MEMORY: usize = 0x00646430;
// =============================================================================
// M2 model loading (async pipeline)
// =============================================================================
/// loadModelFromFileAsync — __thiscall(ECX=model_obj), 2 stack params, RET 0x08
/// loadModelFromFileAsync - __thiscall(ECX=model_obj), 2 stack params, RET 0x08
/// (fileHandle: **ctx, shouldUseCallback: int) → 1
/// Prologue: 55 8b ec 8b 55 0c 56 8b f1 — safe sizes: [6, 7, 9]
/// Prologue: 55 8b ec 8b 55 0c 56 8b f1 - safe sizes: [6, 7, 9]
/// Allocates async task to read file and call onModelLoadComplete when done.
/// The async executor at 0x71d610 calls fileReadWithLock directly, bypassing
/// our ReadFileFromMultipleSources hook — hence this hook fills the buffer
/// our ReadFileFromMultipleSources hook - hence this hook fills the buffer
/// synchronously for fake file contexts.
pub const FN_LOAD_MODEL_ASYNC: usize = 0x0071d4e0;
/// processLoadedModelData — __fastcall(ECX=model), no stack params, plain RET
/// processLoadedModelData - __fastcall(ECX=model), no stack params, plain RET
/// Parses model header from buffer at model+0x130 (ptr) / model+0x134 (size),
/// initializes model resources, sets bit 0 of model+8 when done.
pub const FN_PROCESS_LOADED_MODEL_DATA: usize = 0x0071d640;
// =============================================================================
// Permission check — leader / raid officer
// Permission check - leader / raid officer
// =============================================================================
/// Group leader GUID (64-bit): low u32 at +0, high u32 at +4.
/// Valid for both party leader and raid leader.
pub const LEADER_GUID: usize = 0x00bc75f8;
/// Raid roster — array of 40 pointers to roster entry structs.
/// Raid roster - array of 40 pointers to roster entry structs.
/// Entry layout: +0x00/+0x04 = GUID (u64), +0x08 = subgroup, +0x0C = rank.
/// Rank: 0 = member, 1 = assistant, 2 = leader.
pub const RAID_ROSTER_ARRAY: usize = 0x00b712a8;
@@ -217,18 +217,18 @@ pub const RAID_MEMBER_COUNT: usize = 0x00b713e0;
/// Offset within a roster entry to the rank field (i32).
pub const ROSTER_ENTRY_RANK: usize = 0x0C;
/// Party member GUIDs — 4 slots, 8 bytes each (lo/hi u32 pairs).
/// Party member GUIDs - 4 slots, 8 bytes each (lo/hi u32 pairs).
/// Contains other party members (not local player). Stride = 8.
pub const PARTY_MEMBER_GUIDS: usize = 0x00bc6f48;
/// GetPlayerGUID — __fastcall(), no params, returns EAX(low):EDX(high).
/// GetPlayerGUID - __fastcall(), no params, returns EAX(low):EDX(high).
pub const FN_GET_PLAYER_GUID: usize = 0x00468550;
/// RetrieveNPCDataFromCache — __thiscall(ECX=cache_obj), 6 stack params, RET 0x18.
/// RetrieveNPCDataFromCache - __thiscall(ECX=cache_obj), 6 stack params, RET 0x18.
/// (guid_low, guid_high, name_buf_ptr, 0, 0, 0) → char* name or NULL.
/// Used by GetRaidRosterInfo to resolve player names from GUIDs.
pub const FN_NAME_CACHE_LOOKUP: usize = 0x0055f080;
/// Name cache object — static instance at this address. Passed as ECX (this)
/// Name cache object - static instance at this address. Passed as ECX (this)
/// to RetrieveNPCDataFromCache.
pub const NAME_CACHE_OBJ: usize = 0x00c0e228;
+11 -29
View File
@@ -8,48 +8,30 @@
//!
//! TODO: Hook minimap icon rendering, classify NPCs by type, overlay custom icons.
const std = @import("std");
const con = @import("../console.zig");
const mod_mutex = @import("../mutex.zig");
const WINAPI = std.builtin.CallingConvention.winapi;
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;
pub const module_name: [*:0]const u8 = "minimapicons";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn isActive() bool {
return g_is_hook_owner;
}
pub fn installHooks() void {
con.print("[minimapicons] Module loaded (stub)\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_MinimapiconsHook_{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("[minimapicons] Another DLL owns hooks (mutex taken), skipping\n");
return;
}
g_is_hook_owner = true;
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+57
View File
@@ -0,0 +1,57 @@
const std = @import("std");
const con = @import("console.zig");
const WINAPI = std.builtin.CallingConvention.winapi;
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;
pub const Result = struct {
handle: ?*anyopaque,
is_owner: bool,
};
/// Try to acquire the per-process module mutex.
/// Format: `Local\WeirdUtils_<module_name>_<PID>`
pub fn acquire(module_name: [*:0]const u8) Result {
var buf: [80]u8 = undefined;
const name_span = std.mem.span(module_name);
const formatted = std.fmt.bufPrint(&buf, "Local\\WeirdUtils_{s}_{d}", .{ name_span, GetCurrentProcessId() }) catch return .{ .handle = null, .is_owner = false };
return doAcquire(&buf, formatted.len, name_span);
}
/// Acquire with a legacy mutex name format: `Local\<legacy_prefix>_<PID>`
/// Used by transmogfix which shipped before the naming convention was established.
pub fn acquireLegacy(legacy_prefix: [*:0]const u8, module_name: [*:0]const u8) Result {
var buf: [80]u8 = undefined;
const formatted = std.fmt.bufPrint(&buf, "Local\\{s}_{d}", .{ std.mem.span(legacy_prefix), GetCurrentProcessId() }) catch return .{ .handle = null, .is_owner = false };
return doAcquire(&buf, formatted.len, std.mem.span(module_name));
}
fn doAcquire(buf: *[80]u8, len: usize, log_name: []const u8) Result {
buf[len] = 0;
const mutex = CreateMutexA(null, 1, @ptrCast(buf[0..len :0]));
if (mutex == null) return .{ .handle = null, .is_owner = false };
if (GetLastError() == ERROR_ALREADY_EXISTS) {
_ = CloseHandle(mutex.?);
con.fmt("[{s}] Another DLL owns hooks (mutex taken), skipping\n", .{log_name});
return .{ .handle = null, .is_owner = false };
}
return .{ .handle = mutex, .is_owner = true };
}
/// Release and close a module mutex.
pub fn release(mutex: *?*anyopaque) void {
if (mutex.*) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
mutex.* = null;
}
}
+17 -17
View File
@@ -8,9 +8,9 @@ that runs entirely in EndScene, composited on top of the final backbuffer.
The outline system has three main phases per frame:
1. **Object scan** (EndScene start) — identify which game objects should be outlined
2. **DIP hook** (during game rendering) — cache draw calls and write stencil marks
3. **JFA pipeline** (EndScene, after game rendering) — produce and composite outlines
1. **Object scan** (EndScene start) - identify which game objects should be outlined
2. **DIP hook** (during game rendering) - cache draw calls and write stencil marks
3. **JFA pipeline** (EndScene, after game rendering) - produce and composite outlines
## Files
@@ -44,17 +44,17 @@ state to determine outline visibility.
`CM2SceneRenderDraw` receives a flat array of M2 batch indices. The hook partitions
them into 3 groups before calling the original function:
- **Group 1 — depth-priority models**: game object M2s and the local player's M2s.
- **Group 1 - depth-priority models**: game object M2s and the local player's M2s.
These render first so their depth is in the buffer when stencil marks are written.
The local player occludes outlines (they're the camera reference point).
If the local player IS an outline target, their models go in group 2 instead
(the outline check takes priority in the partition logic).
- **Group 2 — outline targets**: models belonging to tracked entities (current target,
- **Group 2 - outline targets**: models belonging to tracked entities (current target,
raid-marked units, dead friendly players). The DIP hook intercepts these draws to
cache parameters and write stencil=1 where they pass the depth test.
- **Group 3 — everything else**: other players, their gear, NPCs, creatures.
- **Group 3 - everything else**: other players, their gear, NPCs, creatures.
These render last. Their depth is NOT in the buffer when stencil marks are written,
so outlines show through them. The outline composites on top in EndScene regardless.
@@ -73,14 +73,14 @@ them into 3 groups before calling the original function:
The DIP hook writes stencil marks during outline target rendering (group 2):
- `STENCILFUNC = ALWAYS`, `STENCILPASS = REPLACE`, `STENCILREF = 1`
- `STENCILZFAIL = KEEP` — pixels behind depth-tested geometry keep stencil=0
- `STENCILZFAIL = KEEP` - pixels behind depth-tested geometry keep stencil=0
- After each outline DIP, `STENCILWRITEMASK` is set to 0 to protect marks from
subsequent draws (group 3 models could otherwise overwrite them)
- Exception: dead players skip stencil entirely (`STENCILENABLE = 0`) so their
outlines are visible through walls for corpse finding
EndScene Phase 1 uses `STENCILFUNC = EQUAL`, `STENCILREF = 1` to gate the
silhouette replay — only pixels marked as visible get silhouette color.
silhouette replay - only pixels marked as visible get silhouette color.
Stencil is cleared to 0 after Phase 1 to avoid affecting the next frame.
@@ -89,16 +89,16 @@ Stencil is cleared to 0 after Phase 1 to avoid affecting the next frame.
After Phase 1 produces the silhouette RT (A8R8G8B8), the JFA pipeline generates
outlines via distance field:
1. **JFA Init** — seed the distance field from the silhouette. Pixels with
1. **JFA Init** - seed the distance field from the silhouette. Pixels with
silhouette content (alpha >= 0.002) output their own UV as a seed.
Empty pixels output sentinel (-1, -1) which is outside UV space [0,1]
so it never wins distance comparisons.
2. **JFA Propagation** — 4 passes at step sizes [8, 4, 2, 1], ping-ponging
2. **JFA Propagation** - 4 passes at step sizes [8, 4, 2, 1], ping-ponging
between two G16R16F render targets. Each pass does a 9-tap sample
(self + 8 neighbors at step distance) and keeps the nearest seed UV.
3. **JFA Decode + Composite** — compute pixel-space distance from each pixel
3. **JFA Decode + Composite** - compute pixel-space distance from each pixel
to its nearest seed. If distance < outline width AND the pixel is outside
the silhouette interior, output the outline color with alpha blending.
@@ -121,7 +121,7 @@ Width is encoded as `alpha = pixels / 4.0` in the silhouette, decoded as
| `rt_jfa_a_tex` | G16R16F | JFA ping buffer (seed UV coordinates) |
| `rt_jfa_b_tex` | G16R16F | JFA pong buffer |
## DIP Hook — Draw Caching
## DIP Hook - Draw Caching
The DIP hook does NOT draw silhouettes inline (that corrupts WoW's GxDevice
internal render state). Instead it:
@@ -147,7 +147,7 @@ Each frame, `scanObjects()` iterates the WoW object manager and collects:
When `CM2Model_ManageRenderListNode` fires for each model being added to the
render list, `classifyModel()` reads the model's owner back-pointers
(`model+0x28` direct, `model+0x3C0` callback) and matches them against the
collected object pointers. No pointer dereferencing of unknown memory — just
collected object pointers. No pointer dereferencing of unknown memory - just
value comparison against the validated set from the object manager.
## Outline Categories
@@ -162,7 +162,7 @@ value comparison against the validated set from the object manager.
1. `api.init()` installs model hooks immediately (ManageRenderListNode,
DrawBatchProjected, CM2SceneRenderDraw)
2. D3D9 hooks are **deferred** until the first model hook fires — creating a
2. D3D9 hooks are **deferred** until the first model hook fires - creating a
dummy D3D9 device during engine init corrupts the proxy's state
3. `api.initD3D9Deferred()` patches the D3D9 vtable (EndScene, DIP, Reset)
4. Reset hook forces D24S8 depth/stencil format (8 stencil bits required)
@@ -189,7 +189,7 @@ OutlineCommand("off") -- disable outlines
- **Local player outline (planned)**: a Gaussian blur outline mode for the local
player to improve visibility in combat when surrounded by mobs. Separate from
the JFA pipeline — will use blur difference (blur silhouette, subtract original,
the JFA pipeline - will use blur difference (blur silhouette, subtract original,
threshold) for a softer glow effect and only apply to other players.
- **Death tracking needs improvement**: currently uses `UNIT_FLAG_DEAD` which is
@@ -197,10 +197,10 @@ OutlineCommand("off") -- disable outlines
- Dead players should be outlined (works now)
- Released bodies (corpse objects) should also be outlined (partially works via `.corpse`
type, but needs verification that released-but-not-skeleton corpses are caught)
- Feign Death must NOT trigger the dead outline — feign death sets the dead flag
- Feign Death must NOT trigger the dead outline - feign death sets the dead flag
but the player is alive. Need to check for the feign death aura/buff or use a
more specific death condition
- The local player should never get a death outline on themselves — the purpose
- The local player should never get a death outline on themselves - the purpose
of the death outline is to help the player find and resurrect others, not to
highlight their own corpse
- Marker outlines should not persist on units after they die
+13 -31
View File
@@ -11,16 +11,17 @@ const model_hook = @import("model_hook.zig");
const d3d9_hook = @import("d3d9_hook.zig");
const WINAPI = std.builtin.CallingConvention.winapi;
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;
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "outline";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn isActive() bool {
return g_is_hook_owner;
}
/// Install model hooks immediately. D3D9 hooks are deferred until the first
/// model hook fires (i.e. the game is actively rendering), because creating a
/// dummy D3D9 device during engine init corrupts the d3d9 proxy's state and
@@ -28,29 +29,17 @@ var g_is_hook_owner: bool = false;
pub fn init() bool {
con.print("[outline] Module loaded\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_OutlineHook_{d}", .{GetCurrentProcessId()}) catch return false;
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 false;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
_ = CloseHandle(g_mutex.?);
g_mutex = null;
g_is_hook_owner = false;
con.print("[outline] Another DLL owns hooks (mutex taken), skipping\n");
return true;
}
g_is_hook_owner = true;
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return true;
if (!model_hook.installHooks()) return false;
return true;
}
/// Called from the first model hook callback, once rendering is active.
/// Safe to create the dummy D3D9 device now — the game's real device is
/// Safe to create the dummy D3D9 device now - the game's real device is
/// fully initialised and the proxy's state is stable.
pub fn initD3D9Deferred() void {
_ = d3d9_hook.installHooks();
@@ -61,14 +50,7 @@ pub fn cleanup() void {
if (g_is_hook_owner) {
d3d9_hook.removeHooks();
model_hook.removeHooks();
}
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+143 -114
View File
@@ -126,7 +126,7 @@ const CachedDraw = struct {
num_verts: u32 = 0,
start_idx: u32 = 0,
prim_count: u32 = 0,
// GPU state (AddRef'd COM objects — released after replay)
// GPU state (AddRef'd COM objects - released after replay)
vb: ?*anyopaque = null,
vb_offset: u32 = 0,
vb_stride: u32 = 0,
@@ -136,7 +136,7 @@ const CachedDraw = struct {
// Per-model outline info
color: u32 = 0,
category: types.ModelCategory = .none,
// VS constants (bone matrices, world/view/proj) — copied by value
// VS constants (bone matrices, world/view/proj) - copied by value
vs_consts: [MAX_VS_CONST_REGS][4]f32 = undefined,
};
@@ -197,8 +197,7 @@ fn deviceSetPtrOrNull(dev: *anyopaque, idx: usize, ptr: ?*anyopaque) void {
:
: [self] "r" (@intFromPtr(dev)),
[func] "r" (func_addr),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
}
@@ -251,8 +250,7 @@ fn deviceSetTexture(dev: *anyopaque, stage: u32, tex: ?*anyopaque) void {
: [self] "r" (@intFromPtr(dev)),
[stage] "r" (stage),
[func] "r" (func_addr),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
}
@@ -302,8 +300,7 @@ fn deviceSetStreamSource(dev: *anyopaque, stream: u32, vb: ?*anyopaque, offset:
: [self] "r" (@intFromPtr(dev)),
[a] "r" (&args),
[func] "r" (func_addr),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
}
@@ -337,8 +334,7 @@ fn deviceSetIndices(dev: *anyopaque, ib: ?*anyopaque) void {
:
: [self] "r" (@intFromPtr(dev)),
[func] "r" (func_addr),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
}
@@ -397,7 +393,7 @@ fn argbToFloat4(argb: u32) [4]f32 {
// Terrain depth snapshot
// =============================================================================
// (Terrain DS snapshot removed — DXVK does not support StretchRect for
// (Terrain DS snapshot removed - DXVK does not support StretchRect for
// depth-stencil surfaces. Terrain occlusion is achieved via stencil marks
// written during the DIP hook, when the game's DS already has terrain depth.)
@@ -420,38 +416,55 @@ fn ensureResources(device: *anyopaque) void {
resource_height = vp.Height;
// Silhouette RT (A8R8G8B8)
if (deviceCreateTexture(device, vp.Width, vp.Height, 1,
types.D3DUSAGE_RENDERTARGET, types.D3DFMT_A8R8G8B8, types.D3DPOOL_DEFAULT,
&rt_silhouette_tex) < 0) { releaseResources(); return; }
if (deviceCreateTexture(device, vp.Width, vp.Height, 1, types.D3DUSAGE_RENDERTARGET, types.D3DFMT_A8R8G8B8, types.D3DPOOL_DEFAULT, &rt_silhouette_tex) < 0) {
releaseResources();
return;
}
rt_silhouette_surf = textureGetSurfaceLevel(rt_silhouette_tex.?);
if (rt_silhouette_surf == null) { releaseResources(); return; }
if (rt_silhouette_surf == null) {
releaseResources();
return;
}
// JFA A RT (G16R16F)
if (deviceCreateTexture(device, vp.Width, vp.Height, 1,
types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT,
&rt_jfa_a_tex) < 0) { releaseResources(); return; }
if (deviceCreateTexture(device, vp.Width, vp.Height, 1, types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT, &rt_jfa_a_tex) < 0) {
releaseResources();
return;
}
rt_jfa_a_surf = textureGetSurfaceLevel(rt_jfa_a_tex.?);
if (rt_jfa_a_surf == null) { releaseResources(); return; }
if (rt_jfa_a_surf == null) {
releaseResources();
return;
}
// JFA B RT (G16R16F)
if (deviceCreateTexture(device, vp.Width, vp.Height, 1,
types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT,
&rt_jfa_b_tex) < 0) { releaseResources(); return; }
if (deviceCreateTexture(device, vp.Width, vp.Height, 1, types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT, &rt_jfa_b_tex) < 0) {
releaseResources();
return;
}
rt_jfa_b_surf = textureGetSurfaceLevel(rt_jfa_b_tex.?);
if (rt_jfa_b_surf == null) { releaseResources(); return; }
if (rt_jfa_b_surf == null) {
releaseResources();
return;
}
}
fn releaseResources() void {
inline for (.{
&rt_silhouette_surf, &rt_jfa_a_surf, &rt_jfa_b_surf,
}) |surf_ptr| {
if (surf_ptr.*) |s| { comRelease(s); surf_ptr.* = null; }
if (surf_ptr.*) |s| {
comRelease(s);
surf_ptr.* = null;
}
}
inline for (.{
&rt_silhouette_tex, &rt_jfa_a_tex, &rt_jfa_b_tex,
}) |tex_ptr| {
if (tex_ptr.*) |t| { comRelease(t); tex_ptr.* = null; }
if (tex_ptr.*) |t| {
comRelease(t);
tex_ptr.* = null;
}
}
resource_width = 0;
resource_height = 0;
@@ -461,7 +474,7 @@ fn releaseResources() void {
// Shader source strings
// =============================================================================
/// Flat colour pixel shader — outputs PS constant c0.
/// Flat colour pixel shader - outputs PS constant c0.
const ps_flat_src = "ps_3_0\nmov oC0, c0\n";
/// JFA init: sample silhouette, output own UV as seed or sentinel (-1,-1).
@@ -491,7 +504,7 @@ const jfa_prop_src =
"def c9, 1.0, 1.0, 0.0, 0.0\n" ++
"dcl_2d s0\n" ++
"dcl_texcoord0 v0\n" ++
// Self sample — initialize best seed and distance
// Self sample - initialize best seed and distance
"texld r0, v0, s0\n" ++
"sub r2.xy, v0.xy, r0.xy\n" ++
"dp2add r9.x, r2, r2, c1.x\n" ++ // best dist²
@@ -672,7 +685,10 @@ fn assemblePS(device: *anyopaque, assemble: D3DXAssembleShaderFn, src: [*]const
fn releaseShaders() void {
inline for (.{ &outline_ps, &jfa_init_ps, &jfa_prop_ps, &jfa_decode_ps, &debug_sil_ps }) |ps| {
if (ps.*) |p| { comRelease(p); ps.* = null; }
if (ps.*) |p| {
comRelease(p);
ps.* = null;
}
}
shaders_attempted = false;
}
@@ -731,7 +747,7 @@ fn hkEndScene(device: *anyopaque) callconv(sc) i32 {
}
// =============================================================================
// Reset hook — force D24S8 depth/stencil format, release resources
// Reset hook - force D24S8 depth/stencil format, release resources
// =============================================================================
fn hkReset(device: *anyopaque, pp: *types.D3DPRESENT_PARAMETERS) callconv(sc) i32 {
@@ -754,7 +770,7 @@ fn hkReset(device: *anyopaque, pp: *types.D3DPRESENT_PARAMETERS) callconv(sc) i3
}
// =============================================================================
// DrawIndexedPrimitive hook — cache outline draws for EndScene replay
// DrawIndexedPrimitive hook - cache outline draws for EndScene replay
// =============================================================================
// When an outline target is being drawn, we cache the draw parameters and
// current GPU state (VB, IB, vertex decl, VS, VS constants) so they can be
@@ -808,7 +824,7 @@ fn hkDIP(
// Capture current GPU state (AddRef COM objects to keep them alive)
deviceGetStreamSource(device, 0, &draw.vb, &draw.vb_offset, &draw.vb_stride);
// GetStreamSource AddRef's the VB — we keep the ref until replay
// GetStreamSource AddRef's the VB - we keep the ref until replay
draw.ib = deviceGetIndices(device);
// GetIndices AddRef's the IB
draw.vertex_decl = deviceGetPtr(device, types.VT.GetVertexDeclaration);
@@ -832,7 +848,7 @@ fn hkDIP(
const s_enable = deviceGetRS(device, types.D3DRS.STENCILENABLE);
const s_func = deviceGetRS(device, types.D3DRS.STENCILFUNC);
const s_ref = deviceGetRS(device, types.D3DRS.STENCILREF);
// (STENCILWRITEMASK not saved — intentionally set to 0 on restore)
// (STENCILWRITEMASK not saved - intentionally set to 0 on restore)
const s_pass = deviceGetRS(device, types.D3DRS.STENCILPASS);
const s_fail = deviceGetRS(device, types.D3DRS.STENCILFAIL);
const s_zfail = deviceGetRS(device, types.D3DRS.STENCILZFAIL);
@@ -856,7 +872,7 @@ fn hkDIP(
deviceSetRS(device, types.D3DRS.STENCILPASS, s_pass);
deviceSetRS(device, types.D3DRS.STENCILFAIL, s_fail);
deviceSetRS(device, types.D3DRS.STENCILZFAIL, s_zfail);
// Write mask 0 instead of restoring original — prevents any
// Write mask 0 instead of restoring original - prevents any
// subsequent DIP from overwriting our stencil=1 marks.
// Restored properly in EndScene before the JFA pipeline.
deviceSetRS(device, types.D3DRS.STENCILWRITEMASK, 0);
@@ -871,10 +887,22 @@ fn hkDIP(
fn clearCachedDraws() void {
for (0..cached_draw_count) |i| {
var draw = &cached_draws[i];
if (draw.vb) |obj| { comRelease(obj); draw.vb = null; }
if (draw.ib) |obj| { comRelease(obj); draw.ib = null; }
if (draw.vertex_decl) |obj| { comRelease(obj); draw.vertex_decl = null; }
if (draw.vertex_shader) |obj| { comRelease(obj); draw.vertex_shader = null; }
if (draw.vb) |obj| {
comRelease(obj);
draw.vb = null;
}
if (draw.ib) |obj| {
comRelease(obj);
draw.ib = null;
}
if (draw.vertex_decl) |obj| {
comRelease(obj);
draw.vertex_decl = null;
}
if (draw.vertex_shader) |obj| {
comRelease(obj);
draw.vertex_shader = null;
}
}
cached_draw_count = 0;
}
@@ -898,7 +926,7 @@ fn runJfaPipeline(device: *anyopaque) void {
// Save ALL state that replay + JFA will modify (manual, no state blocks)
// =====================================================================
// COM objects (Get* AddRefs — must Release after restore)
// COM objects (Get* AddRefs - must Release after restore)
const saved_rt0 = deviceGetRenderTarget(device, 0);
const saved_ps = deviceGetPtr(device, types.VT.GetPixelShader);
const saved_vs = deviceGetPtr(device, types.VT.GetVertexShader);
@@ -969,7 +997,7 @@ fn runJfaPipeline(device: *anyopaque) void {
deviceSetRenderTarget(device, 0, rt_silhouette_surf.?);
clearRenderTarget(device, 0x00000000);
// Keep game's DS bound — it has stencil marks from DIP hook where
// Keep game's DS bound - it has stencil marks from DIP hook where
// outline targets passed the terrain depth test (stencil=1 = visible).
// Don't write depth or stencil during replay.
deviceSetRS(device, types.D3DRS.ZWRITEENABLE, 0);
@@ -1006,8 +1034,7 @@ fn runJfaPipeline(device: *anyopaque) void {
deviceSetRS(device, types.D3DRS.STENCILPASS, types.D3DSTENCILOP_KEEP);
}
_ = origFn(device, draw.prim_type, draw.base_vtx, draw.min_vtx,
draw.num_verts, draw.start_idx, draw.prim_count);
_ = origFn(device, draw.prim_type, draw.base_vtx, draw.min_vtx, draw.num_verts, draw.start_idx, draw.prim_count);
}
clearCachedDraws();
@@ -1048,89 +1075,88 @@ fn runJfaPipeline(device: *anyopaque) void {
const quad = buildFullscreenQuad(vp.Width, vp.Height);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), @sizeOf(QuadVertex));
}
// Skip JFA — jump straight to state restore
// Skip JFA - jump straight to state restore
} else {
// =====================================================================
// Phase 2: JFA pipeline (silhouette → outline composite)
// =====================================================================
// =====================================================================
// Phase 2: JFA pipeline (silhouette → outline composite)
// =====================================================================
deviceSetPtrOrNull(device, types.VT.SetDepthStencilSurface, null);
deviceSetPtrOrNull(device, types.VT.SetVertexShader, null);
deviceSetFVF(device, types.D3DFVF_XYZRHW | types.D3DFVF_TEX1);
deviceSetRS(device, types.D3DRS.ZENABLE, types.D3DZB_FALSE);
deviceSetRS(device, types.D3DRS.ZWRITEENABLE, 0);
deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 0);
deviceSetRS(device, types.D3DRS.CULLMODE, types.D3DCULL_NONE);
deviceSetRS(device, types.D3DRS.ALPHATESTENABLE, 0);
deviceSetRS(device, types.D3DRS.COLORWRITEENABLE, 0x0F);
deviceSetPtrOrNull(device, types.VT.SetDepthStencilSurface, null);
deviceSetPtrOrNull(device, types.VT.SetVertexShader, null);
deviceSetFVF(device, types.D3DFVF_XYZRHW | types.D3DFVF_TEX1);
deviceSetRS(device, types.D3DRS.ZENABLE, types.D3DZB_FALSE);
deviceSetRS(device, types.D3DRS.ZWRITEENABLE, 0);
deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 0);
deviceSetRS(device, types.D3DRS.CULLMODE, types.D3DCULL_NONE);
deviceSetRS(device, types.D3DRS.ALPHATESTENABLE, 0);
deviceSetRS(device, types.D3DRS.COLORWRITEENABLE, 0x0F);
deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 0, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 0, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 0, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE);
deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 1, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 1, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 1, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE);
deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 0, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 0, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 0, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE);
deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 1, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 1, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 1, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE);
const quad = buildFullscreenQuad(vp.Width, vp.Height);
const qstride: u32 = @sizeOf(QuadVertex);
const fw = @as(f32, @floatFromInt(@max(vp.Width, 1)));
const fh = @as(f32, @floatFromInt(@max(vp.Height, 1)));
const quad = buildFullscreenQuad(vp.Width, vp.Height);
const qstride: u32 = @sizeOf(QuadVertex);
const fw = @as(f32, @floatFromInt(@max(vp.Width, 1)));
const fh = @as(f32, @floatFromInt(@max(vp.Height, 1)));
// Pass 1: JFA Init (silhouette → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_silhouette_tex);
deviceSetPtr(device, types.VT.SetPixelShader, jfa_init_ps.?);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// Pass 1: JFA Init (silhouette → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_silhouette_tex);
deviceSetPtr(device, types.VT.SetPixelShader, jfa_init_ps.?);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// JFA Propagation: steps [8, 4, 2, 1] ping-ponging between A and B.
deviceSetPtr(device, types.VT.SetPixelShader, jfa_prop_ps.?);
var c0: [4]f32 = undefined;
// JFA Propagation: steps [8, 4, 2, 1] ping-ponging between A and B.
deviceSetPtr(device, types.VT.SetPixelShader, jfa_prop_ps.?);
var c0: [4]f32 = undefined;
// step=8 (JFA_A → JFA_B)
deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?);
deviceSetTexture(device, 0, rt_jfa_a_tex);
c0 = .{ 8.0 / fw, 8.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=8 (JFA_A → JFA_B)
deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?);
deviceSetTexture(device, 0, rt_jfa_a_tex);
c0 = .{ 8.0 / fw, 8.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=4 (JFA_B → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_jfa_b_tex);
c0 = .{ 4.0 / fw, 4.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=4 (JFA_B → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_jfa_b_tex);
c0 = .{ 4.0 / fw, 4.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=2 (JFA_A → JFA_B)
deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?);
deviceSetTexture(device, 0, rt_jfa_a_tex);
c0 = .{ 2.0 / fw, 2.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=2 (JFA_A → JFA_B)
deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?);
deviceSetTexture(device, 0, rt_jfa_a_tex);
c0 = .{ 2.0 / fw, 2.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=1 (JFA_B → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_jfa_b_tex);
c0 = .{ 1.0 / fw, 1.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// Pass 4: Decode + Composite (JFA_A + silhouette → backbuffer)
if (saved_rt0) |rt| deviceSetRenderTarget(device, 0, rt);
deviceSetTexture(device, 0, rt_jfa_a_tex);
deviceSetTexture(device, 1, rt_silhouette_tex);
c0 = [4]f32{ fw, fh, 4.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceSetPtr(device, types.VT.SetPixelShader, jfa_decode_ps.?);
deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 1);
deviceSetRS(device, types.D3DRS.SRCBLEND, types.D3DBLEND_SRCALPHA);
deviceSetRS(device, types.D3DRS.DESTBLEND, types.D3DBLEND_INVSRCALPHA);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=1 (JFA_B → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_jfa_b_tex);
c0 = .{ 1.0 / fw, 1.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// Pass 4: Decode + Composite (JFA_A + silhouette → backbuffer)
if (saved_rt0) |rt| deviceSetRenderTarget(device, 0, rt);
deviceSetTexture(device, 0, rt_jfa_a_tex);
deviceSetTexture(device, 1, rt_silhouette_tex);
c0 = [4]f32{ fw, fh, 4.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceSetPtr(device, types.VT.SetPixelShader, jfa_decode_ps.?);
deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 1);
deviceSetRS(device, types.D3DRS.SRCBLEND, types.D3DBLEND_SRCALPHA);
deviceSetRS(device, types.D3DRS.DESTBLEND, types.D3DBLEND_INVSRCALPHA);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
} // end else (normal JFA path)
// =====================================================================
@@ -1174,7 +1200,10 @@ fn runJfaPipeline(device: *anyopaque) void {
deviceSetVSConstF(device, 0, &saved_vs_consts, MAX_VS_CONST_REGS);
// COM objects (restore binding then release our ref)
if (saved_rt0) |rt| { deviceSetRenderTarget(device, 0, rt); comRelease(rt); } // RT0
if (saved_rt0) |rt| {
deviceSetRenderTarget(device, 0, rt);
comRelease(rt);
} // RT0
deviceSetPtrOrNull(device, types.VT.SetDepthStencilSurface, saved_ds); // DS
if (saved_ds) |ds| comRelease(ds);
deviceSetPtrOrNull(device, types.VT.SetPixelShader, saved_ps); // PS
+10 -10
View File
@@ -1,12 +1,12 @@
//! WoW model rendering pipeline hooks.
//!
//! Hooks three WoW functions to integrate outline rendering:
//! - CM2SceneRenderDraw — reorders batches so outline targets render last.
//! - CM2Model_ManageRenderListNode — classifies models on render-list add.
//! - CM2Scene_DrawBatchProjected — flags the DIP hook for outline rendering.
//! - CM2SceneRenderDraw - reorders batches so outline targets render last.
//! - CM2Model_ManageRenderListNode - classifies models on render-list add.
//! - CM2Scene_DrawBatchProjected - flags the DIP hook for outline rendering.
//!
//! Calling conventions:
//! - RenderDraw & ManageRender use callconv(.x86_thiscall) — direct native detours.
//! - RenderDraw & ManageRender use callconv(.x86_thiscall) - direct native detours.
//! - DrawBatchProj uses a callconv(.naked) entry point because Zig 0.15 has a
//! codegen bug with callconv(.x86_fastcall) that generates wrong ret instructions
//! for functions with ≤2 register params. The naked wrapper bridges to a cdecl
@@ -64,12 +64,12 @@ var reordered_indices: [MAX_REORDER]i32 = undefined;
// CM2SceneRenderDraw hook
// =============================================================================
// __thiscall(this, viewMatrix, batchData, batchIndices, batchCount)
// Native thiscall detour — no thunk needed.
// Native thiscall detour - no thunk needed.
//
// Reorders batch indices into 3 groups:
// 1. Game objects/doodads — render first, write depth so outlines respect them
// 2. Outline targets — render second, DIP hook writes stencil against depth
// 3. Other players, gear, NPCs — render last, draw over targets normally
// 1. Game objects/doodads - render first, write depth so outlines respect them
// 2. Outline targets - render second, DIP hook writes stencil against depth
// 3. Other players, gear, NPCs - render last, draw over targets normally
//
// This gives outlines that are occluded by world/WMO/game objects but show
// through other players and gear (since those aren't in depth when stencil
@@ -145,7 +145,7 @@ fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices:
// CM2Model_ManageRenderListNode hook
// =============================================================================
// __thiscall(model_ECX, addToList_stack)
// Native thiscall detour — no thunk needed.
// Native thiscall detour - no thunk needed.
fn manageRenderDetour(model: u32, add_to_list: u32) callconv(tc) void {
// Classify the model when it's being ADDED to the render list
@@ -180,7 +180,7 @@ fn drawBatchProjDetour(ctx: u32) callconv(tc) void {
const entry = if (model_ptr != 0) tracker.findOutlineEntry(model_ptr) else null;
if (entry != null) {
// This batch is an outline target — signal the DIP hook
// This batch is an outline target - signal the DIP hook
rendering_outline = true;
current_model = model_ptr;
+4 -4
View File
@@ -93,7 +93,7 @@ pub const IS_IN_WORLD: usize = 0xB4B424;
// =============================================================================
/// __stdcall(guidLo_stack, guidHi_stack) → object pointer (EAX). Returns 0 on miss.
/// Callee cleans 8 bytes (RET 8). NOT __fastcall — params on stack, not registers.
/// Callee cleans 8 bytes (RET 8). NOT __fastcall - params on stack, not registers.
pub const FN_GET_OBJECT_BY_GUID: usize = 0x464870;
/// __fastcall(unitIdStr_ECX) → GUID in EAX:EDX. Accepts "player", "target", etc.
@@ -106,14 +106,14 @@ pub const FN_UNIT_REACTION: usize = 0x6061E0;
// Hooked function addresses (model rendering pipeline)
// =============================================================================
/// CM2SceneRenderDraw — main batch rendering entry point.
/// CM2SceneRenderDraw - main batch rendering entry point.
/// __thiscall(this, viewMatrix, batchData, batchIndices, batchCount)
pub const FN_CM2SCENE_RENDER_DRAW: usize = 0x0070b360;
/// CM2Model_ManageRenderListNode — called for every model added/removed from render list.
/// CM2Model_ManageRenderListNode - called for every model added/removed from render list.
/// __thiscall(model_ECX, addToList_stack)
pub const FN_CM2MODEL_MANAGE_RENDER_LIST: usize = 0x00710b90;
/// CM2Scene_DrawModelBatchProjected — called per batch (type 0) during rendering.
/// CM2Scene_DrawModelBatchProjected - called per batch (type 0) during rendering.
/// __fastcall(renderContext_ECX)
pub const FN_DRAW_BATCH_PROJ: usize = 0x0070cb30;
+10 -7
View File
@@ -2,12 +2,12 @@
//!
//! Maintains fixed-size arrays of tracked object pointers (target, raid marks,
//! dead players) and the per-frame set of outline model entries.
//! All state is single-threaded (main WoW thread) — no synchronisation needed.
//! All state is single-threaded (main WoW thread) - no synchronisation needed.
//!
//! Model classification uses forward mapping: scanObjects stores object pointers
//! from the object manager, then classifyModel (ManageRenderListNode) reads the
//! model's back-pointers (model+0x28, model+0x3C0) and compares them against
//! known object pointers. No dereferencing of unknown memory — just value
//! known object pointers. No dereferencing of unknown memory - just value
//! comparison against the object manager's validated set.
const std = @import("std");
@@ -27,7 +27,7 @@ const MAX_OUTLINE_MODELS = 256;
// Tracked object set (populated by scanObjects from the object manager)
// =============================================================================
// Stores obj_ptr + category for entities we want to outline.
// classifyModel matches model back-pointers against these — no pointer chasing.
// classifyModel matches model back-pointers against these - no pointer chasing.
const TrackedObj = struct {
obj_ptr: u32,
@@ -46,7 +46,7 @@ var frame_outlines: [MAX_OUTLINE_MODELS]types.OutlineEntry = undefined;
var frame_outline_count: usize = 0;
// =============================================================================
// Per-frame game object tracking (for render ordering — game objects first)
// Per-frame game object tracking (for render ordering - game objects first)
// =============================================================================
// Game object M2 models need to render before outline targets so their depth
// is in the buffer when stencil marks are written. Tracked separately from
@@ -127,7 +127,7 @@ pub fn getOutlinePixels(cat: types.ModelCategory) f32 {
/// Classify a model by comparing its back-pointers against known object pointers.
/// Safe: only reads from the model struct (which WoW just handed us via the
/// ManageRenderListNode __thiscall), then compares values — never dereferences
/// ManageRenderListNode __thiscall), then compares values - never dereferences
/// the back-pointer values as pointers.
pub fn classifyModel(model_ptr: u32) void {
if (model_ptr == 0 or !enabled) return;
@@ -155,7 +155,10 @@ pub fn classifyModel(model_ptr: u32) void {
// Deduplicate
var found = false;
for (game_obj_models[0..game_obj_model_count]) |m| {
if (m == model_ptr) { found = true; break; }
if (m == model_ptr) {
found = true;
break;
}
}
if (!found) {
game_obj_models[game_obj_model_count] = model_ptr;
@@ -214,7 +217,7 @@ pub fn scanObjects() void {
// Cache raid target GUIDs
wow.cacheRaidTargets();
// Resolve target to object pointer (highest priority — added first)
// Resolve target to object pointer (highest priority - added first)
const target_guid = wow.getTargetGUID();
if (target_guid != 0) {
const target_obj = wow.getObjectByGUID(target_guid);
+9 -9
View File
@@ -41,7 +41,7 @@ pub const OutlineEntry = struct {
};
// =============================================================================
// Outline colours — D3DCOLOR ARGB format (0xAARRGGBB)
// Outline colours - D3DCOLOR ARGB format (0xAARRGGBB)
// =============================================================================
/// Dead player / corpse outline (cyan).
@@ -53,14 +53,14 @@ pub const COLOR_TARGET: u32 = 0xFFFFC800;
/// Raid mark colours, indexed 0-8. Index 0 = fallback cyan.
pub const RAID_MARK_COLORS = [9]u32{
0xFF00FFFF, // 0: fallback
0xFFFFFF00, // 1: Star — Yellow
0xFFFF8000, // 2: Circle — Orange
0xFFCC44FF, // 3: Diamond — Purple
0xFF00FF00, // 4: Triangle — Green
0xFFC0C0FF, // 5: Moon — Silver/Pale Blue
0xFF4040FF, // 6: Square — Blue
0xFFFF2828, // 7: Cross — Soft Red
0xFFFFF5DC, // 8: Skull — Bone White
0xFFFFFF00, // 1: Star - Yellow
0xFFFF8000, // 2: Circle - Orange
0xFFCC44FF, // 3: Diamond - Purple
0xFF00FF00, // 4: Triangle - Green
0xFFC0C0FF, // 5: Moon - Silver/Pale Blue
0xFF4040FF, // 6: Square - Blue
0xFFFF2828, // 7: Cross - Soft Red
0xFFFFF5DC, // 8: Skull - Bone White
};
// =============================================================================
+6 -9
View File
@@ -20,7 +20,7 @@ extern "kernel32" fn IsBadReadPtr(
ucb: usize,
) callconv(WINAPI) i32;
/// Quick sanity check — reject null, low-address, and kernel-space pointers.
/// Quick sanity check - reject null, low-address, and kernel-space pointers.
pub fn isValidPtr(addr: u32) bool {
return addr >= 0x10000 and addr < 0x7F000000;
}
@@ -118,7 +118,7 @@ pub fn resolveModelOwner(model: u32) u32 {
// Need to read up to model+0x3C0+4
if (!isReadablePtr(model, o.MODEL_OWNER_CALLBACK + 4)) return 0;
// Try callback owner first — more reliable for units
// Try callback owner first - more reliable for units
const candidate_cb = hook.readMem(u32, model + o.MODEL_OWNER_CALLBACK);
if (candidate_cb != 0 and isReadablePtr(candidate_cb, 0x40)) {
const guid_lo = hook.readMem(u32, candidate_cb + o.OBJECT_GUID_OFFSET);
@@ -149,14 +149,13 @@ pub fn unitGUID(unit_id: [*:0]const u8) u64 {
[_] "={edx}" (hi),
: [_] "{ecx}" (@intFromPtr(unit_id)),
[func] "r" (@as(u32, o.FN_UNIT_GUID)),
: .{ .memory = true, .cc = true }
);
: .{ .memory = true, .cc = true });
return (@as(u64, hi) << 32) | lo;
}
/// Resolve a GUID → object pointer via the object manager hash table.
/// Ghidra-verified: __stdcall(guidLow, guidHigh) with RET 8.
/// NOT __fastcall — params are read from stack, not registers.
/// NOT __fastcall - params are read from stack, not registers.
pub fn getObjectByGUID(guid: u64) u32 {
if (guid == 0) return 0;
const lo: u32 = @truncate(guid);
@@ -169,8 +168,7 @@ pub fn getObjectByGUID(guid: u64) u32 {
: [lo] "r" (lo),
[hi] "r" (hi),
[func] "r" (@as(u32, o.FN_GET_OBJECT_BY_GUID)),
: .{ .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .ecx = true, .edx = true, .memory = true, .cc = true });
}
/// Get the local player's object pointer.
@@ -197,8 +195,7 @@ pub fn isUnitFriendly(unit: u32, local_player: u32) bool {
: [_] "{ecx}" (local_player),
[unit] "r" (unit),
[func] "r" (@as(u32, o.FN_UNIT_REACTION)),
: .{ .edx = true, .memory = true, .cc = true }
);
: .{ .edx = true, .memory = true, .cc = true });
return reaction >= 4;
}
+6 -6
View File
@@ -1,5 +1,5 @@
//! Minimal PNG encoder with deflate compression.
//! No large struct literals or lookup tables — entire module adds ~2KB to .rdata.
//! No large struct literals or lookup tables - entire module adds ~2KB to .rdata.
//! Supports store blocks (level 0) and fixed-Huffman encoding (levels 1-9).
const std = @import("std");
@@ -72,7 +72,7 @@ pub fn encode(
}
// =============================================================================
// Stream wrapper — tracks CRC and Adler inline
// Stream wrapper - tracks CRC and Adler inline
// =============================================================================
fn Stream(comptime Ctx: type) type {
@@ -140,7 +140,7 @@ fn Stream(comptime Ctx: type) type {
}
// =============================================================================
// Store blocks (level 0) — no compression, byte-aligned
// Store blocks (level 0) - no compression, byte-aligned
// =============================================================================
fn writeIdatStore(s: anytype, pixels: [*]const u8, w: u32, h: u32) void {
@@ -204,7 +204,7 @@ fn writeIdatStore(s: anytype, pixels: [*]const u8, w: u32, h: u32) void {
}
// =============================================================================
// Fixed Huffman + LZ77 — RFC 1951 §3.2.6 fixed codes with back-references
// Fixed Huffman + LZ77 - RFC 1951 §3.2.6 fixed codes with back-references
//
// Sub filter makes adjacent pixel differences small (often zero in flat areas).
// LZ77 with hash-table matching finds repeated byte sequences within a 32KB
@@ -339,7 +339,7 @@ fn writeIdatFixedHuffman(s: anytype, pixels: [*]const u8, w: u32, h: u32, level:
};
defer std.heap.page_allocator.free(out_buf);
// Hash chains for LZ77 matching — chain depth = 2 * level (stb approach)
// Hash chains for LZ77 matching - chain depth = 2 * level (stb approach)
const ZHASH: u32 = 16384;
const chain_depth: u32 = @as(u32, level) * 2;
const chain_mem = std.heap.page_allocator.alloc(u32, ZHASH * chain_depth) catch {
@@ -425,7 +425,7 @@ fn writeIdatFixedHuffman(s: anytype, pixels: [*]const u8, w: u32, h: u32, level:
const prev2 = chain_mem[base2 + j];
if ((i + 1) -% prev2 <= WINDOW) {
if (countMatch(filtered[0..raw_size], prev2, i + 1) > best_len) {
best_dist = 0; // cancel — next position is better
best_dist = 0; // cancel - next position is better
break;
}
}
+22 -35
View File
@@ -61,7 +61,7 @@ var tga_hook: hook.Detour(TgaWriteFn) = .{};
var screenshot_dir: [260]u8 = undefined;
var screenshot_dir_len: usize = 0;
var screenshot_counter: u8 = 0;
var last_screenshot_time: u64 = 0; // packed YMDHMS — resets counter on new second
var last_screenshot_time: u64 = 0; // packed YMDHMS - resets counter on new second
// =============================================================================
// Ring buffer queue (max 8 pending screenshots)
@@ -83,9 +83,17 @@ var queue_tail: usize = 0;
var queue_count: usize = 0;
var mutex: std.atomic.Mutex = .unlocked;
var worker_running: bool = false;
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "screenshot";
var g_mutex: ?HANDLE = null;
var g_is_hook_owner: bool = false;
pub fn isActive() bool {
return g_is_hook_owner;
}
fn enqueue(shot: PendingScreenshot) bool {
if (queue_count >= MAX_PENDING) return false;
queue[queue_tail] = shot;
@@ -103,7 +111,7 @@ fn dequeue() ?PendingScreenshot {
}
// =============================================================================
// Directory extraction — capture path prefix from game's first TGA filename
// Directory extraction - capture path prefix from game's first TGA filename
// =============================================================================
fn extractDir(filename_ptr: u32) void {
@@ -120,7 +128,7 @@ fn extractDir(filename_ptr: u32) void {
}
// =============================================================================
// Call original CTgaFile::Write — __thiscall(self_ECX, filename_stack) ret 4
// Call original CTgaFile::Write - __thiscall(self_ECX, filename_stack) ret 4
// =============================================================================
fn callOriginal(self: u32, filename: u32) i32 {
@@ -133,7 +141,6 @@ fn callOriginal(self: u32, filename: u32) i32 {
// =============================================================================
fn tgaWriteDetour(self: u32, filename: u32) callconv(tc) i32 {
if (!enabled) return callOriginal(self, filename);
// Validate TGA header fields
@@ -182,7 +189,7 @@ fn tgaWriteDetour(self: u32, filename: u32) callconv(tc) i32 {
}
// =============================================================================
// Worker thread — dequeues shots, converts BGR→RGB, writes PNG
// Worker thread - dequeues shots, converts BGR→RGB, writes PNG
// =============================================================================
fn workerThread() void {
@@ -220,7 +227,7 @@ fn processScreenshot(shot: PendingScreenshot) void {
var st: SYSTEMTIME = undefined;
GetLocalTime(&st);
// Pack timestamp into a single comparable value — reset counter on new second
// Pack timestamp into a single comparable value - reset counter on new second
const now: u64 = @as(u64, st.wYear) << 32 | @as(u64, st.wMonth) << 24 |
@as(u64, st.wDay) << 16 | @as(u64, st.wHour) << 10 |
@as(u64, st.wMinute) << 4 | @as(u64, st.wSecond);
@@ -288,8 +295,7 @@ fn luaPushNumber(L_ptr: usize, n: f64) void {
[lo] "r" (raw[0]),
[hi] "r" (raw[1]),
[func] "r" (@as(u32, 0x6F3810)),
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }
);
: .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true });
}
// =============================================================================
@@ -303,7 +309,7 @@ fn luaPushNumber(L_ptr: usize, n: f64) void {
pub fn screenshotCommand(L: *anyopaque) callconv(.c) u32 {
const L_ptr = @intFromPtr(L);
// lua_gettop(L) — __fastcall(L_ECX), EDX unused
// lua_gettop(L) - __fastcall(L_ECX), EDX unused
const nargs = hook.fastcall(i32, 0x6F3070, L_ptr, @as(u32, 0));
if (nargs == 0) {
@@ -314,7 +320,7 @@ pub fn screenshotCommand(L: *anyopaque) callconv(.c) u32 {
return 2;
}
// lua_tostring(L, 1) — __fastcall(L_ECX, index_EDX)
// lua_tostring(L, 1) - __fastcall(L_ECX, index_EDX)
const raw_str = hook.fastcall(usize, 0x6F3690, L_ptr, @as(i32, 1));
if (raw_str != 0) {
const str: [*:0]const u8 = @ptrFromInt(raw_str);
@@ -326,7 +332,7 @@ pub fn screenshotCommand(L: *anyopaque) callconv(.c) u32 {
enabled = false;
} else if (std.mem.eql(u8, arg, "quality")) {
if (nargs >= 2) {
// lua_tonumber(L, 2) — __fastcall(L_ECX, index_EDX), returns f64 in ST(0)
// lua_tonumber(L, 2) - __fastcall(L_ECX, index_EDX), returns f64 in ST(0)
const level = hook.fastcall(f64, 0x6F3620, L_ptr, @as(i32, 2));
compression_level = std.math.clamp(@as(i32, @intFromFloat(level)), 0, 9);
}
@@ -343,22 +349,10 @@ pub fn screenshotCommand(L: *anyopaque) callconv(.c) u32 {
pub fn installHook() void {
con.print("[screenshot] Module loaded\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_ScreenshotHook_{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("[screenshot] Another DLL owns hooks (mutex taken), skipping\n");
return;
}
g_is_hook_owner = true;
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
// CTgaFile::Write at 0x5a4810
// __thiscall(self, filename) ret 4
@@ -374,14 +368,7 @@ pub fn installHook() void {
pub fn removeHook() void {
if (g_is_hook_owner) {
tga_hook.detach();
}
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+24 -33
View File
@@ -154,11 +154,19 @@ const CachedPlayerState = struct {
var g_cache: CachedPlayerState = .{};
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "transmogfix";
var g_enabled: bool = true;
var g_initialized: bool = false;
var g_is_hook_owner: bool = false;
var g_mutex: ?*anyopaque = null;
pub fn isActive() bool {
return g_is_hook_owner;
}
// =============================================================================
// Hooks
// =============================================================================
@@ -409,7 +417,7 @@ fn processTimeouts(now: u32) void {
found_count += 1;
const elapsed = now -% g_other_pending[i].timestamp;
if (elapsed >= OTHER_PLAYER_TIMEOUT_MS) {
// Re-resolve the GUID to a live object pointer — the cached
// Re-resolve the GUID to a live object pointer - the cached
// unit_ptr may be stale if the player despawned since capture.
const unit = getObjectByGUID(g_other_pending[i].guid);
con.fmt("[other] TIMEOUT slot={d:2} guid=0x{X:0>16} {d}ms unit=0x{X:0>8}\n", .{ g_other_pending[i].slot, g_other_pending[i].guid, elapsed, unit });
@@ -458,7 +466,7 @@ fn processTimeouts(now: u32) void {
if (display_table != 0) {
const box_model_data = hook.readMem(u32, display_table + DISPLAY_ID_BOX * 4);
if (box_model_data != 0) {
// Set cached ModelData to BOX model — forces ShouldUpdateDisplayInfo = true
// Set cached ModelData to BOX model - forces ShouldUpdateDisplayInfo = true
const dest: *u32 = @ptrFromInt(unit + UNIT_CACHED_MODELDATA_OFFSET);
dest.* = box_model_data;
@@ -502,7 +510,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 {
_ = cachePlayerState();
}
if (val == 0 and g_cached_visible_item[slot] != 0) {
// CLEAR detected — check if INV_SLOT is already empty (real unequip)
// CLEAR detected - check if INV_SLOT is already empty (real unequip)
if (g_cache.valid and g_cache.equipped_guids[slot] == 0) {
g_cached_visible_item[slot] = 0;
return callOriginalSetBlock(obj, index, value);
@@ -518,7 +526,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 {
return 1; // Block the clear
} else if (val != 0 and g_local_pending[slot].active) {
if (val == g_local_pending[slot].original_visible_item) {
// RESTORE with same value — transmog pattern confirmed
// RESTORE with same value - transmog pattern confirmed
if (g_local_pending[slot].has_durability) {
const dur = g_local_pending[slot].captured_durability;
@@ -526,7 +534,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 {
writeItemDurabilityDirect(slot, dur);
con.fmt("[local] APPLY dur slot={d:2} dur={d}\n", .{ slot, dur });
} else {
// Don't block — broken items need visual update
// Don't block - broken items need visual update
con.fmt("[local] PASS broken slot={d:2}\n", .{slot});
g_local_pending[slot].active = false;
g_local_pending[slot].has_durability = false;
@@ -543,7 +551,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 {
con.fmt("[local] BLOCK restore slot={d:2} item=0x{X:0>8} -- coalesced!\n", .{ slot, val });
return 1; // Block the restore
} else {
// Different item value — real gear change
// Different item value - real gear change
g_local_pending[slot].active = false;
g_local_pending[slot].has_durability = false;
g_local_pending_count -= 1;
@@ -581,7 +589,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 {
}
} else if (idx >= 0 and g_other_pending[@intCast(idx)].active) {
const ui: u32 = @intCast(idx);
// Restore — check timeout and same item
// Restore - check timeout and same item
const elapsed = now -% g_other_pending[ui].timestamp;
const current_val = readUnitVisibleItem(obj, slot);
if (elapsed < OTHER_PLAYER_TIMEOUT_MS and val == current_val) {
@@ -599,7 +607,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 {
}
}
// DURABILITY writes — capture for pending local player slots
// DURABILITY writes - capture for pending local player slots
if (g_enabled and index == ITEM_FIELD_DURABILITY) {
const slot = findSlotForItemObject(obj);
if (slot >= 0) {
@@ -609,12 +617,12 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 {
g_local_pending[s].has_durability = true;
g_local_pending[s].timestamp = GetTickCount();
con.fmt("[local] CATCH dur slot={d:2} dur={d}\n", .{ s, val });
return 1; // Block — captured
return 1; // Block - captured
}
}
}
// INV_SLOT writes — detect gear changes and update cache
// INV_SLOT writes - detect gear changes and update cache
if (index >= PLAYER_FIELD_INV_SLOT_HEAD and
index < PLAYER_FIELD_INV_SLOT_HEAD + 48)
{
@@ -777,24 +785,14 @@ fn hookSceneEnd(device: u32) callconv(tc) void {
pub fn installHooks() void {
con.print("[transmogfix] Module loaded\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
// muted name scheme not quite the same because this dll exists in the wild and we want to match it
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\TransmogCoalesceHook_{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;
// Legacy mutex name - this DLL existed in the wild before the naming convention
const result = mod_mutex.acquireLegacy("TransmogCoalesceHook", module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) {
g_initialized = true;
con.print("[transmogfix] Another DLL owns hooks (mutex taken), skipping\n");
return;
}
g_is_hook_owner = true;
// Initialize state (already zero-initialized by Zig defaults)
g_local_pending = [1]LocalPending{.{}} ** 19;
@@ -830,14 +828,7 @@ pub fn removeHooks() void {
scene_end_hook.detach();
refresh_hook.detach();
set_block_hook.detach();
}
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_initialized = false;