Add assetfix, transmogfix, minimapicons modules; fix assetfix disk-vs-MPQ priority
Assetfix: hook CheckFileExistence to serve loose Data\ files. The original flags|1 approach failed because game paths contain backslashes, causing CheckFileExistence to skip BuildFilePath and check the raw path (no Data\ prefix). Fix: write the correct Data\-prefixed disk path to the output buffer directly and return 1, bypassing the original function for hash map hits. This preserves hook chaining (filename argument is never transformed). Also adds transmogfix (transmog update coalescing), minimapicons (stub), new build options for all three modules, mutex-based multi-DLL safety, embed .skin data into .m2 models, and various module improvements.
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
// =============================================================================
|
||||
// assetfix - Loose file loading & permissive patch glob
|
||||
// =============================================================================
|
||||
//
|
||||
// 1. Patches patch-?.MPQ → patch-*.MPQ so multi-char patch names work
|
||||
// 2. NOPs two gates in File_FindInArchive so CheckFileExistence runs for
|
||||
// all files, not just Interface/AddOns
|
||||
// 3. Hooks CheckFileExistence with an O(1) hash set of pre-indexed loose
|
||||
// files so non-existent files skip GetFileAttributesA entirely
|
||||
//
|
||||
// =============================================================================
|
||||
|
||||
const std = @import("std");
|
||||
const hook = @import("hook");
|
||||
const con = @import("../console.zig");
|
||||
|
||||
// =============================================================================
|
||||
// Windows API (project-specific — not in hook lib)
|
||||
// =============================================================================
|
||||
|
||||
const WINAPI = std.builtin.CallingConvention.winapi;
|
||||
|
||||
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
|
||||
const INVALID_FILE_ATTRIBUTES: u32 = 0xFFFFFFFF;
|
||||
const INVALID_HANDLE: usize = 0xFFFFFFFF;
|
||||
const MAX_PATH: usize = 260;
|
||||
|
||||
extern "kernel32" fn GetModuleFileNameA(hModule: ?*anyopaque, lpFilename: [*]u8, nSize: u32) callconv(WINAPI) u32;
|
||||
extern "kernel32" fn GetFileAttributesA(lpFileName: [*:0]const u8) callconv(WINAPI) u32;
|
||||
extern "kernel32" fn FindFirstFileA(lpFileName: [*:0]const u8, lpFindFileData: *WIN32_FIND_DATAA) callconv(WINAPI) usize;
|
||||
extern "kernel32" fn FindNextFileA(hFindFile: usize, lpFindFileData: *WIN32_FIND_DATAA) callconv(WINAPI) i32;
|
||||
extern "kernel32" fn FindClose(hFindFile: usize) callconv(WINAPI) i32;
|
||||
extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: [*:0]const u8) callconv(WINAPI) ?*anyopaque;
|
||||
extern "kernel32" fn ReleaseMutex(hMutex: *anyopaque) callconv(WINAPI) i32;
|
||||
extern "kernel32" fn CloseHandle(hObject: *anyopaque) callconv(WINAPI) i32;
|
||||
extern "kernel32" fn GetLastError() callconv(WINAPI) u32;
|
||||
extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32;
|
||||
const ERROR_ALREADY_EXISTS: u32 = 183;
|
||||
|
||||
const FILETIME = extern struct { low: u32, high: u32 };
|
||||
|
||||
const WIN32_FIND_DATAA = extern struct {
|
||||
dwFileAttributes: u32,
|
||||
ftCreationTime: FILETIME,
|
||||
ftLastAccessTime: FILETIME,
|
||||
ftLastWriteTime: FILETIME,
|
||||
nFileSizeHigh: u32,
|
||||
nFileSizeLow: u32,
|
||||
dwReserved0: u32,
|
||||
dwReserved1: u32,
|
||||
cFileName: [MAX_PATH]u8,
|
||||
cAlternateFileName: [14]u8,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Loose file hash map
|
||||
// =============================================================================
|
||||
|
||||
var arena: std.heap.ArenaAllocator = undefined;
|
||||
var loose_files: std.StringHashMapUnmanaged([]const u8) = .empty;
|
||||
var wow_dir_len: usize = 0;
|
||||
|
||||
fn normalizeCopy(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
|
||||
const out = try allocator.alloc(u8, path.len);
|
||||
for (path, 0..) |c, i| {
|
||||
out[i] = if (c == '/') '\\' else if (c >= 'A' and c <= 'Z') c + 32 else c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
fn normalizeInPlace(buf: []u8) void {
|
||||
for (buf) |*c| {
|
||||
if (c.* == '/') c.* = '\\' else if (c.* >= 'A' and c.* <= 'Z') c.* += 32;
|
||||
}
|
||||
}
|
||||
|
||||
fn isMpq(name: []const u8) bool {
|
||||
if (name.len < 4) return false;
|
||||
var ext: [4]u8 = undefined;
|
||||
for (name[name.len - 4 ..], 0..) |c, i| {
|
||||
ext[i] = if (c >= 'A' and c <= 'Z') c + 32 else c;
|
||||
}
|
||||
return std.mem.eql(u8, &ext, ".mpq");
|
||||
}
|
||||
|
||||
fn cStrLen(ptr: [*]const u8) usize {
|
||||
var i: usize = 0;
|
||||
while (ptr[i] != 0) : (i += 1) {}
|
||||
return i;
|
||||
}
|
||||
|
||||
fn scanDirectory(full_path: []const u8, base_dir_len: usize) void {
|
||||
const alloc = arena.allocator();
|
||||
|
||||
var search_buf: [MAX_PATH]u8 = undefined;
|
||||
const search = std.fmt.bufPrint(&search_buf, "{s}\\*", .{full_path}) catch return;
|
||||
search_buf[search.len] = 0;
|
||||
|
||||
var fd: WIN32_FIND_DATAA = undefined;
|
||||
const handle = FindFirstFileA(@ptrCast(search_buf[0..search.len :0]), &fd);
|
||||
if (handle == INVALID_HANDLE) return;
|
||||
defer _ = FindClose(handle);
|
||||
|
||||
while (true) {
|
||||
const name_len = cStrLen(&fd.cFileName);
|
||||
const name = fd.cFileName[0..name_len];
|
||||
|
||||
if (!(name.len == 1 and name[0] == '.') and
|
||||
!(name.len == 2 and name[0] == '.' and name[1] == '.'))
|
||||
{
|
||||
var child_buf: [MAX_PATH]u8 = undefined;
|
||||
const child = std.fmt.bufPrint(&child_buf, "{s}\\{s}", .{ full_path, name }) catch break;
|
||||
|
||||
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0) {
|
||||
scanDirectory(child, base_dir_len);
|
||||
} else if (!isMpq(name)) {
|
||||
const game_key = child[base_dir_len..];
|
||||
const disk_path = child[wow_dir_len..];
|
||||
|
||||
const norm_key = normalizeCopy(alloc, game_key) catch continue;
|
||||
const owned_disk = alloc.alloc(u8, disk_path.len + 1) catch continue;
|
||||
@memcpy(owned_disk[0..disk_path.len], disk_path);
|
||||
owned_disk[disk_path.len] = 0; // null-terminate for C interop
|
||||
|
||||
loose_files.put(alloc, norm_key, owned_disk) catch continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (FindNextFileA(handle, &fd) == 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
fn looseFilesInit() void {
|
||||
arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
|
||||
var dir_buf: [MAX_PATH]u8 = undefined;
|
||||
const len = GetModuleFileNameA(null, &dir_buf, MAX_PATH);
|
||||
if (len == 0) return;
|
||||
|
||||
// Truncate to directory (keep trailing backslash)
|
||||
var last_slash: usize = 0;
|
||||
for (dir_buf[0..len], 0..) |c, i| {
|
||||
if (c == '\\') last_slash = i;
|
||||
}
|
||||
wow_dir_len = last_slash + 1;
|
||||
const wow_dir = dir_buf[0..wow_dir_len];
|
||||
|
||||
var data_buf: [MAX_PATH]u8 = undefined;
|
||||
const data_path = std.fmt.bufPrint(&data_buf, "{s}Data", .{wow_dir}) catch return;
|
||||
data_buf[data_path.len] = 0;
|
||||
|
||||
const attr = GetFileAttributesA(@ptrCast(data_buf[0..data_path.len :0]));
|
||||
if (attr == INVALID_FILE_ATTRIBUTES or attr & FILE_ATTRIBUTE_DIRECTORY == 0) return;
|
||||
|
||||
const base_len = wow_dir_len + 5; // "Data\"
|
||||
scanDirectory(data_path, base_len);
|
||||
}
|
||||
|
||||
fn looseFilesCleanup() void {
|
||||
loose_files = .empty;
|
||||
arena.deinit();
|
||||
}
|
||||
|
||||
fn looseFilesLookup(game_path_ptr: u32) ?[*]const u8 {
|
||||
if (game_path_ptr == 0) return null;
|
||||
const raw: [*]const u8 = @ptrFromInt(game_path_ptr);
|
||||
const path = raw[0..cStrLen(raw)];
|
||||
|
||||
var norm_buf: [MAX_PATH]u8 = undefined;
|
||||
if (path.len > MAX_PATH) return null;
|
||||
@memcpy(norm_buf[0..path.len], path);
|
||||
normalizeInPlace(norm_buf[0..path.len]);
|
||||
|
||||
const result = loose_files.get(norm_buf[0..path.len]);
|
||||
if (result) |disk_path| {
|
||||
return disk_path.ptr;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook: CheckFileExistence (0x654DD0)
|
||||
// =============================================================================
|
||||
// __fastcall(ECX=filename, EDX=flags, stack=outputBuffer) → EAX
|
||||
// Prologue: 9 bytes (push ebp; mov ebp, esp; sub esp, 0x104) — no rel32 fixups
|
||||
|
||||
const CHECK_FILE_EXISTENCE: usize = 0x654DD0;
|
||||
|
||||
var cfe_hook = hook.Hook{};
|
||||
|
||||
fn hookImpl(filename_ptr: u32, flags: u32, output_buffer_ptr: u32) callconv(.c) u32 {
|
||||
if (filename_ptr != 0) {
|
||||
if (looseFilesLookup(filename_ptr)) |disk_path| {
|
||||
const raw: [*]const u8 = @ptrFromInt(filename_ptr);
|
||||
con.fmt("[assetfix] loose hit: \"{s}\"\n", .{raw[0..cStrLen(raw)]});
|
||||
|
||||
// Write disk path (e.g. "Data\Character\...") to output buffer.
|
||||
// The caller (File_FindInArchive) uses this to open the file from disk.
|
||||
// Game paths contain '\' which makes CheckFileExistence's bit-0 handler
|
||||
// skip BuildFilePath and use the raw path as-is — failing because the
|
||||
// game-relative path has no "Data\" prefix. By writing the correct
|
||||
// disk-relative path to the output buffer ourselves, we bypass that
|
||||
// bug without transforming the filename argument (preserving chaining).
|
||||
if (output_buffer_ptr != 0) {
|
||||
const disk_len = cStrLen(disk_path);
|
||||
const out: [*]u8 = @ptrFromInt(output_buffer_ptr);
|
||||
if (disk_len < MAX_PATH) {
|
||||
@memcpy(out[0..disk_len], disk_path[0..disk_len]);
|
||||
out[disk_len] = 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return callOriginal(filename_ptr, flags, output_buffer_ptr);
|
||||
}
|
||||
|
||||
fn callOriginal(filename: u32, flags: u32, output_buffer: u32) u32 {
|
||||
// __fastcall: ECX=filename, EDX=flags, push outputBuffer, callee cleans 4
|
||||
return asm volatile (
|
||||
\\push %[output]
|
||||
\\call *%[func]
|
||||
: [ret] "={eax}" (-> u32),
|
||||
: [_] "{ecx}" (filename),
|
||||
[_] "{edx}" (flags),
|
||||
[output] "r" (output_buffer),
|
||||
[func] "r" (cfe_hook.trampoline),
|
||||
: .{ .memory = true, .cc = true });
|
||||
}
|
||||
|
||||
fn installHook() bool {
|
||||
if (!cfe_hook.prepare(CHECK_FILE_EXISTENCE, 9, &.{})) return false;
|
||||
|
||||
// Build fastcall→cdecl thunk in the hook's alloc block (after trampoline)
|
||||
const thunk_buf = cfe_hook.mem.? + 32;
|
||||
_ = hook.buildFastcallToCdeclThunk(thunk_buf, @intFromPtr(&hookImpl), 1);
|
||||
|
||||
cfe_hook.activate(@intFromPtr(thunk_buf));
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Patch 1: Permissive MPQ glob (0x82edc2: '?' → '*')
|
||||
// =============================================================================
|
||||
|
||||
var old_glob_byte: u8 = 0;
|
||||
var glob_patched: bool = false;
|
||||
|
||||
fn applyGlobPatch() void {
|
||||
const addr: usize = 0x82edc2;
|
||||
if (hook.readMem(u8, addr) != 0x3F) return; // not '?'
|
||||
old_glob_byte = 0x3F;
|
||||
hook.writeProtected(addr, &[_]u8{0x2A}); // '*'
|
||||
glob_patched = true;
|
||||
}
|
||||
|
||||
fn revertGlobPatch() void {
|
||||
if (glob_patched) {
|
||||
hook.writeProtected(0x82edc2, &[_]u8{old_glob_byte});
|
||||
glob_patched = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Patches 2 & 3: NOP loose file gates in File_FindInArchive
|
||||
// =============================================================================
|
||||
|
||||
var old_jz: [2]u8 = undefined;
|
||||
var jz_patched: bool = false;
|
||||
var old_jnz: [2]u8 = undefined;
|
||||
var jnz_patched: bool = false;
|
||||
|
||||
fn applyLooseFilePatches() void {
|
||||
const nops = [2]u8{ 0x90, 0x90 };
|
||||
|
||||
// Gate 1: JZ at 0x654b5c (74 25)
|
||||
if (hook.readMem(u8, 0x654b5c) == 0x74 and hook.readMem(u8, 0x654b5d) == 0x25) {
|
||||
old_jz = .{ 0x74, 0x25 };
|
||||
hook.writeProtected(0x654b5c, &nops);
|
||||
jz_patched = true;
|
||||
}
|
||||
|
||||
// Gate 2: JNZ at 0x654b6a (75 17)
|
||||
if (hook.readMem(u8, 0x654b6a) == 0x75 and hook.readMem(u8, 0x654b6b) == 0x17) {
|
||||
old_jnz = .{ 0x75, 0x17 };
|
||||
hook.writeProtected(0x654b6a, &nops);
|
||||
jnz_patched = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn revertLooseFilePatches() void {
|
||||
if (jz_patched) {
|
||||
hook.writeProtected(0x654b5c, &old_jz);
|
||||
jz_patched = false;
|
||||
}
|
||||
if (jnz_patched) {
|
||||
hook.writeProtected(0x654b6a, &old_jnz);
|
||||
jnz_patched = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Init / Cleanup
|
||||
// =============================================================================
|
||||
|
||||
var installed: bool = false;
|
||||
var g_mutex: ?*anyopaque = null;
|
||||
var g_is_hook_owner: bool = false;
|
||||
|
||||
pub fn installHooks() bool {
|
||||
con.print("[assetfix] 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\\AssetfixHook_{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("[assetfix] Another DLL owns hooks (mutex taken), skipping\n");
|
||||
return true;
|
||||
}
|
||||
g_is_hook_owner = true;
|
||||
|
||||
applyGlobPatch();
|
||||
applyLooseFilePatches();
|
||||
looseFilesInit();
|
||||
if (!installHook()) return false;
|
||||
installed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn removeHooks() void {
|
||||
if (g_is_hook_owner and installed) {
|
||||
cfe_hook.remove();
|
||||
revertLooseFilePatches();
|
||||
revertGlobPatch();
|
||||
looseFilesCleanup();
|
||||
installed = false;
|
||||
}
|
||||
|
||||
if (g_is_hook_owner) {
|
||||
if (g_mutex) |m| {
|
||||
_ = ReleaseMutex(m);
|
||||
_ = CloseHandle(m);
|
||||
g_mutex = null;
|
||||
}
|
||||
}
|
||||
g_is_hook_owner = false;
|
||||
}
|
||||
@@ -5,10 +5,48 @@
|
||||
//!
|
||||
//! TODO: Find and hook the combat log open/create function.
|
||||
|
||||
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;
|
||||
|
||||
var g_mutex: ?*anyopaque = null;
|
||||
var g_is_hook_owner: bool = false;
|
||||
|
||||
pub fn installHooks() void {
|
||||
con.print("[combatlog] 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\\CombatlogHook_{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("[combatlog] Another DLL owns hooks (mutex taken), skipping\n");
|
||||
return;
|
||||
}
|
||||
g_is_hook_owner = true;
|
||||
}
|
||||
|
||||
pub fn removeHooks() void {}
|
||||
pub fn removeHooks() void {
|
||||
if (g_is_hook_owner) {
|
||||
if (g_mutex) |m| {
|
||||
_ = ReleaseMutex(m);
|
||||
_ = CloseHandle(m);
|
||||
g_mutex = null;
|
||||
}
|
||||
}
|
||||
g_is_hook_owner = false;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
const std = @import("std");
|
||||
const hook = @import("hook");
|
||||
const con = @import("../console.zig");
|
||||
|
||||
const WINAPI = @import("std").builtin.CallingConvention.winapi;
|
||||
const WINAPI = std.builtin.CallingConvention.winapi;
|
||||
extern "kernel32" fn GetTickCount() callconv(WINAPI) u32;
|
||||
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;
|
||||
|
||||
// =============================================================================
|
||||
// Offsets
|
||||
@@ -267,6 +274,9 @@ pub fn interactNearest(L: *anyopaque) callconv(.c) u32 {
|
||||
|
||||
const MAX_LOOT_QUEUE: usize = 100;
|
||||
|
||||
var g_mutex: ?*anyopaque = null;
|
||||
var g_is_hook_owner: bool = false;
|
||||
|
||||
var loot_queue: [MAX_LOOT_QUEUE]u64 = .{0} ** MAX_LOOT_QUEUE;
|
||||
var loot_queue_count: usize = 0;
|
||||
var loot_queue_index: usize = 0;
|
||||
@@ -402,6 +412,24 @@ fn callOriginalSceneEnd(device: u32) 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\\InteractHook_{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("[interact] Another DLL owns hooks (mutex taken), skipping\n");
|
||||
return;
|
||||
}
|
||||
g_is_hook_owner = true;
|
||||
|
||||
// SceneEnd — per-frame loot queue processing
|
||||
// Uses thunk: __fastcall(ECX=device, EDX) → cdecl(device, edx)
|
||||
if (scene_end_hook.prepare(Offsets.ADDR_SceneEnd, 9, &.{})) {
|
||||
@@ -412,5 +440,16 @@ pub fn installHooks() void {
|
||||
}
|
||||
|
||||
pub fn removeHooks() void {
|
||||
scene_end_hook.remove();
|
||||
if (g_is_hook_owner) {
|
||||
scene_end_hook.remove();
|
||||
}
|
||||
|
||||
if (g_is_hook_owner) {
|
||||
if (g_mutex) |m| {
|
||||
_ = ReleaseMutex(m);
|
||||
_ = CloseHandle(m);
|
||||
g_mutex = null;
|
||||
}
|
||||
}
|
||||
g_is_hook_owner = false;
|
||||
}
|
||||
|
||||
+25
-15
@@ -10,6 +10,9 @@ const build_opts = struct {
|
||||
const markers = @import("build_options").enable_markers;
|
||||
const framecrash = @import("build_options").enable_framecrash;
|
||||
const combatlog = @import("build_options").enable_combatlog;
|
||||
const minimapicons = @import("build_options").enable_minimapicons;
|
||||
const transmogfix = @import("build_options").enable_transmogfix;
|
||||
const assetfix = @import("build_options").enable_assetfix;
|
||||
};
|
||||
|
||||
// Conditional module imports
|
||||
@@ -19,6 +22,9 @@ const outline = if (build_opts.outline) @import("outline/api.zig") else struct {
|
||||
const markers = if (build_opts.markers) @import("markers/markers.zig") else struct {};
|
||||
const framecrash = if (build_opts.framecrash) @import("framecrash/framecrash.zig") else struct {};
|
||||
const combatlog = if (build_opts.combatlog) @import("combatlog/combatlog.zig") else struct {};
|
||||
const minimapicons = if (build_opts.minimapicons) @import("minimapicons/minimapicons.zig") else struct {};
|
||||
const transmogfix = if (build_opts.transmogfix) @import("transmogfix/transmogfix.zig") else struct {};
|
||||
const assetfix = if (build_opts.assetfix) @import("assetfix/assetfix.zig") else struct {};
|
||||
|
||||
const WINAPI = std.builtin.CallingConvention.winapi;
|
||||
const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
|
||||
@@ -312,12 +318,6 @@ const markers_spells_assets = if (build_opts.markers) [_]FileEntry{
|
||||
.{ .name = "Raid_UI_FX_Green.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Green.m2") },
|
||||
.{ .name = "Raid_UI_FX_Purple.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Purple.m2") },
|
||||
.{ .name = "Raid_UI_FX_Red.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Red.m2") },
|
||||
// Skin files
|
||||
.{ .name = "Raid_UI_FX_Yellow00.skin", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Yellow00.skin") },
|
||||
.{ .name = "Raid_UI_FX_Cyan00.skin", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Cyan00.skin") },
|
||||
.{ .name = "Raid_UI_FX_Green00.skin", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Green00.skin") },
|
||||
.{ .name = "Raid_UI_FX_Purple00.skin", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Purple00.skin") },
|
||||
.{ .name = "Raid_UI_FX_Red00.skin", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Red00.skin") },
|
||||
// Per-model raid target icon textures
|
||||
.{ .name = "RaidTarget_Star.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_Star.blp") },
|
||||
.{ .name = "RaidTarget_Square.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_Square.blp") },
|
||||
@@ -687,18 +687,10 @@ fn cleanupFileHandleDetour(file_ctx: u32) callconv(sc) void {
|
||||
fn loadModelAsyncDetour(model: u32, _edx: u32, file_handle: u32, should_use_callback: u32) callconv(.c) u32 {
|
||||
_ = _edx;
|
||||
|
||||
// Debug: log every call to confirm the hook is firing
|
||||
con.fmt("[file] loadModelAsync: model=0x{x} fh=0x{x} cb={d}\n", .{ model, file_handle, should_use_callback });
|
||||
con.fmt("[file] fh type={d} handle=0x{x} embed=0x{x} isFake={}\n", .{
|
||||
hook.readMem(u32, file_handle),
|
||||
hook.readMem(u32, file_handle + 0x04),
|
||||
hook.readMem(u32, file_handle + 0x30),
|
||||
isFakeFileContext(file_handle),
|
||||
});
|
||||
|
||||
// file_handle IS the file context address directly (Ghidra shows pointer* but
|
||||
// the assembly pushes it directly to GetFileSizeFromHandle — no dereference)
|
||||
if (isFakeFileContext(file_handle)) {
|
||||
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);
|
||||
const data_size = hook.readMem(u32, file_handle + 0x34);
|
||||
con.fmt("[file] embed_ptr=0x{x} embed_size={d}\n", .{ data_ptr, data_size });
|
||||
@@ -993,12 +985,21 @@ fn install() void {
|
||||
_ = file_hook.install(0x648620, 6, @intFromPtr(&loadFileDetour), &.{});
|
||||
_ = lsf_hook.install(0x490250, 6, @intFromPtr(&loadScriptFunctionsDetour), &.{1});
|
||||
|
||||
if (build_opts.assetfix) {
|
||||
_ = assetfix.installHooks();
|
||||
}
|
||||
if (build_opts.framecrash) {
|
||||
framecrash.installHooks();
|
||||
}
|
||||
if (build_opts.combatlog) {
|
||||
combatlog.installHooks();
|
||||
}
|
||||
if (build_opts.transmogfix) {
|
||||
_ = transmogfix.installHooks();
|
||||
}
|
||||
if (build_opts.minimapicons) {
|
||||
minimapicons.installHooks();
|
||||
}
|
||||
|
||||
if (load_addons_hook.prepare(0x51F600, 7, &.{})) {
|
||||
const thunk = load_addons_hook.mem.? + 32;
|
||||
@@ -1037,6 +1038,15 @@ fn uninstall() void {
|
||||
if (build_opts.combatlog) {
|
||||
combatlog.removeHooks();
|
||||
}
|
||||
if (build_opts.minimapicons) {
|
||||
minimapicons.removeHooks();
|
||||
}
|
||||
if (build_opts.transmogfix) {
|
||||
transmogfix.removeHooks();
|
||||
}
|
||||
if (build_opts.assetfix) {
|
||||
assetfix.removeHooks();
|
||||
}
|
||||
|
||||
load_addons_hook.remove();
|
||||
lsf_hook.remove();
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
# MPQ Filesystem Research: Alternatives to Multi-Hook File Serving
|
||||
|
||||
## Problem Statement
|
||||
|
||||
WeirdUtils currently hooks **6 functions** to serve ~20 embedded files (M2 models, skins, textures) from DLL memory:
|
||||
|
||||
| Hook | Target | Purpose |
|
||||
|------|--------|---------|
|
||||
| `openFileDetour` | 0x6477c0 | Create fake file context |
|
||||
| `getFileSizeDetour` | 0x6487f0 | Return embedded size |
|
||||
| `readFileDetour` | 0x648460 | memcpy embedded data |
|
||||
| `processAsyncDetour` | 0x647350 | Handle async executor path |
|
||||
| `cleanupFileHandleDetour` | 0x648730 | Free fake context |
|
||||
| `loadModelAsyncDetour` | 0x71d4e0 | Bypass M2 async loading |
|
||||
|
||||
Plus `loadFileDetour` at 0x648620 for addon files (.toc/.lua/.xml).
|
||||
|
||||
The async hooks (4 and 6) exist because the async file executor calls `fileReadWithLock` directly, bypassing our `readFileDetour`. This entire system is fragile, requires careful critical section management, refcount tracking, and event signaling. This document researches alternatives.
|
||||
|
||||
---
|
||||
|
||||
## WoW 1.12.1 File I/O Architecture (Verified via Ghidra)
|
||||
|
||||
### Storm Is Statically Linked
|
||||
|
||||
WoW 1.12.1 does **not** use Storm.dll. All MPQ/file code is compiled directly into wow.exe. The Storm source path string `"E:\build\buildWoW\WoW\Source\..."` at 0x865b94 confirms this. Our existing hooks at `0x6477xx`-`0x6487xx` ARE the Storm SFile functions.
|
||||
|
||||
### The File Open Pipeline
|
||||
|
||||
```
|
||||
openFileWithOptions(archive_ptr, path, flags, handle_out) [0x6477c0]
|
||||
│
|
||||
├─ if archive_ptr == NULL:
|
||||
│ └─ locateFileInDirectories(path, ...) [0x647e60]
|
||||
│ ├─ 1. Absolute path? → SearchFileInMultipleDirectories
|
||||
│ ├─ 2. Check disk file hash table (PTR_00c521e8)
|
||||
│ │ (populated once by scanDirectoriesForFiles @ 0x646ea0)
|
||||
│ ├─ 3. FindFileInArchive(0, path, flags, &archive_ref) [0x654920]
|
||||
│ │ └─ File_FindInArchive(0, path, ...) [0x6549a0]
|
||||
│ │ └─ Recursively searches archive chain
|
||||
│ └─ Returns: type code (0=disk, 3=archive) + resolved path
|
||||
│
|
||||
├─ Allocate 0x60-byte file context
|
||||
├─ initializeFileContext(ctx, type) [0x647290]
|
||||
│
|
||||
└─ switch(type):
|
||||
case 0: openFileHandle(path, ...) → disk file
|
||||
case 1: (validated disk)
|
||||
case 2/3: (storage/stream)
|
||||
case 4: readFromArchiveFile(...) → MPQ archive read
|
||||
case 5: return NULL (error)
|
||||
```
|
||||
|
||||
### The Global Archive Array
|
||||
|
||||
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)
|
||||
+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
|
||||
|
||||
### SArchive Object (Minimal Wrapper)
|
||||
|
||||
From `Archive_Close` (0x648ef0):
|
||||
```c
|
||||
void Archive_Close(int *archive) {
|
||||
if (archive[0] == 1) { // +0x00: type (1 = MPQ)
|
||||
MPQ_CloseArchive(archive[1]); // +0x04: internal handle
|
||||
} else {
|
||||
CheckStreamBounds(archive[1]);
|
||||
}
|
||||
FreeMemory(archive, "delete", -1);
|
||||
}
|
||||
```
|
||||
|
||||
`Archive_OpenUnified` (0x648dd0) allocates an 8-byte SArchive wrapper:
|
||||
```c
|
||||
uint Archive_OpenUnified(char *path, int *param_2, uint flags, uint **outArchive) {
|
||||
SArchive *archive = AllocateBuffer(8, ...);
|
||||
*outArchive = archive;
|
||||
archive->field_0x4 = 0;
|
||||
// ... opens file, validates MPQ, builds internal structures
|
||||
}
|
||||
```
|
||||
|
||||
### The Internal MPQ Archive Structure
|
||||
|
||||
From `InitializeArchiveStructure` (0x655bf0), the full MPQ handle (pointed to by `SArchive[1]`) is a large struct:
|
||||
|
||||
| Offset | Field | Description |
|
||||
|--------|-------|-------------|
|
||||
| +0x14C | `[0x53]` | Magic: `0x1a51504d` ("MPQ\x1a") |
|
||||
| +0x150 | `[0x54]` | Header size (must be > 0x1f) |
|
||||
| +0x158 | `[0x56]` | Hash table offset in file |
|
||||
| +0x15A | `[0x56+2]` | Sector size shift (byte) |
|
||||
| +0x164 | `[0x59]` | Version (< 0x10001) |
|
||||
| +0x168 | `[0x5a]` | Hash table entry count (< 0x10001) |
|
||||
| +0x140 | `[0x50]` | I/O vtable pointer (for reading archive data) |
|
||||
| +0x27C | `[0x9f]` | Computed sector size: `0x200 << (shift & 0x1f)` |
|
||||
| +0x280 | `[0xa0]` | Current file position |
|
||||
| +0x288 | `[0xa2]` | Read context |
|
||||
| +0x28C | `[0xa3]` | Allocated sector buffer |
|
||||
| +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))(...)`.
|
||||
|
||||
### Archive Registration (How MPQs Are Opened)
|
||||
|
||||
```
|
||||
MPQ_InitializeArchives (0x403740)
|
||||
│
|
||||
├─ 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"
|
||||
│ └─ Archive_OpenUnified(path, ..., &PTR_008826bc[index]) [0x648dd0]
|
||||
│ └─ OpenFileWithValidation(path, ..., &archive) [0x655690]
|
||||
│ └─ InitializeArchiveStructure(archive, ...) [0x655bf0]
|
||||
│ ├─ Read MPQ header, validate magic
|
||||
│ ├─ Allocate and read hash table
|
||||
│ ├─ Allocate and read block table
|
||||
│ └─ Archive_ReadAttributes(...)
|
||||
│
|
||||
├─ MPQArchiveEnumerator(...) [0x4039b0]
|
||||
│ └─ Discovers patch archives matching "patch-?.MPQ" pattern
|
||||
│ (glob pattern byte at 0x82edc2: '?' = single char)
|
||||
│
|
||||
└─ Opens patch.MPQ and discovered patch-X.MPQ archives
|
||||
```
|
||||
|
||||
### MPQ Filename Table (Verified)
|
||||
|
||||
```
|
||||
0x82e12c → "model.MPQ"
|
||||
0x82e130 → "texture.MPQ"
|
||||
0x82e134 → "terrain.MPQ"
|
||||
0x82e138 → "wmo.MPQ"
|
||||
0x82e13c → "sound.MPQ"
|
||||
0x82e140 → "misc.MPQ"
|
||||
0x82e144 → "interface.MPQ"
|
||||
0x82e148 → "fonts.MPQ"
|
||||
0x82e14c → "speech.MPQ"
|
||||
0x82e150 → "dbc.MPQ"
|
||||
0x82e154 → "speech2.MPQ"
|
||||
```
|
||||
|
||||
Patch glob pattern: `"patch.MPQ"` at 0x82edb0, `"patch-?.MPQ"` at 0x82edbc.
|
||||
Format string: `"Data\%s"` at 0x82edc8.
|
||||
|
||||
### Storm's I/O Provider System (The Extensibility Point)
|
||||
|
||||
Storm does NOT have a virtual filesystem plugin API. However, it does have a **two-layer I/O abstraction** for archive data access:
|
||||
|
||||
#### IO Object (0x118 bytes)
|
||||
|
||||
Created by `CreateIOObject` (0x66dfa0), initialized by `InitializeIOObject` (0x66dfe0):
|
||||
|
||||
```c
|
||||
struct IOObject { // 0x118 bytes total
|
||||
void** vtable; // +0x00: → IOVtable at 0x80fddc
|
||||
void* inner_provider; // +0x04: secondary vtable for actual I/O dispatch
|
||||
int flags; // +0x08: initialized to 1
|
||||
AsyncMgr async_mgr; // +0x0C: async I/O manager (large sub-struct)
|
||||
uint buffer_size; // +0x110: max read buffer size
|
||||
void* buffer; // +0x114: allocated read buffer (size = buffer_size)
|
||||
};
|
||||
```
|
||||
|
||||
#### IO Vtable (at 0x80fddc)
|
||||
|
||||
```
|
||||
[0] +0x00: 0x66e040 = Destructor_WithCleanup
|
||||
[1] +0x04: 0x66e0c0 = Resource_Load ← THE READ FUNCTION
|
||||
[2] +0x08: 0x66e130 = Resource_Unload
|
||||
[3] +0x0C: 0x66e1b0 = (unknown)
|
||||
[4] +0x10: 0x66e1c0 = IOManagerDestructor
|
||||
[5] +0x14: 0x66e1e0 = IOManagerCleanup
|
||||
[6] +0x18: 0x66e200 = (unknown)
|
||||
[7] +0x1C: 0x7f800000 = (float NaN — padding/sentinel)
|
||||
[8] +0x20: 0x66e2d0 = ValidateIOOperation
|
||||
[9] +0x24: 0x66e310 = GetIOResult
|
||||
[10]+0x28: 0x66e340 = CancelIOOperation
|
||||
[11]+0x2C: 0x66e360 = (unknown)
|
||||
```
|
||||
|
||||
#### How Archive Reads Dispatch
|
||||
|
||||
`InitializeArchiveStructure` reads data through the IO provider at `archive+0x140`:
|
||||
```c
|
||||
// archive[0x50] = *(archive + 0x140) = IO object pointer
|
||||
(**(code **)(*archive[0x50] + 4))(buffer, position, 0, 0x1000, &bytes_read);
|
||||
// ^-- IO vtable[1] = Resource_Load
|
||||
```
|
||||
|
||||
`Resource_Load` (0x66e0c0) then:
|
||||
1. Bounds-checks against `this->buffer_size` (+0x110)
|
||||
2. Copies source data into `this->buffer` (+0x114)
|
||||
3. Dispatches through the **inner provider** at `this->inner_provider` (+0x04):
|
||||
```c
|
||||
result = (*(*(this->inner_provider))[2])(this->buffer, dest, offset, size);
|
||||
```
|
||||
|
||||
So there are **two** levels of vtable indirection. The inner provider at `+0x04` holds the actual read-from-disk callbacks. For disk-backed MPQs, this eventually calls `ReadFile`/`SetFilePointer`.
|
||||
|
||||
#### Low-Level MPQ System (0x668xxx)
|
||||
|
||||
Separate from the high-level IO provider, the low-level `ZipFileArchive` system uses plain Windows file handles:
|
||||
|
||||
```c
|
||||
// MPQ_OpenArchive (0x668360) allocates 0x110-byte ZipFileArchive struct
|
||||
// openArchiveFile (0x667bd0):
|
||||
openArchiveFile(this, path):
|
||||
this->file_handle = openFileReadOnly(path); // → CreateFileA wrapper
|
||||
strcpy(this->path, path); // store path at +0x0C
|
||||
```
|
||||
|
||||
The `SArchive` wrapper at the top level (8 bytes: `{type, handle}`) bridges these:
|
||||
- `type == 1`: handle points to a `ZipFileArchive` (low-level MPQ)
|
||||
- `type != 1`: handle points to a stream object
|
||||
|
||||
#### Conclusion: No Virtual FS, But I/O Is Abstracted
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Approach E: Windows Temp File Handles (Recommended)
|
||||
|
||||
### Concept
|
||||
|
||||
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.
|
||||
|
||||
### Implementation
|
||||
|
||||
1. **DLL init** — for each embedded asset:
|
||||
```c
|
||||
// Get temp directory
|
||||
GetTempPath(MAX_PATH, tempDir);
|
||||
|
||||
// Create temp file with auto-delete
|
||||
handle = CreateFile(
|
||||
tempPath,
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_DELETE, // allow Storm to open it too
|
||||
NULL,
|
||||
CREATE_ALWAYS,
|
||||
FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE,
|
||||
NULL
|
||||
);
|
||||
|
||||
// Write embedded data
|
||||
WriteFile(handle, embedded_data, size, &written, NULL);
|
||||
|
||||
// Store mapping: "Spells\\WU_XYZ.m2" → tempPath
|
||||
```
|
||||
|
||||
2. **Hook `openFileWithOptions` (0x6477c0)** — single hook:
|
||||
```
|
||||
openFileDetour(archive, path, flags, handle_out):
|
||||
if path matches our asset map:
|
||||
// Redirect to temp file path
|
||||
return original(archive, temp_path, flags, handle_out)
|
||||
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:
|
||||
- `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.
|
||||
|
||||
### 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.
|
||||
|
||||
### Path Redirection Strategy
|
||||
|
||||
Two options for how the hook redirects paths:
|
||||
|
||||
**Option 1: Redirect in `openFileWithOptions`**
|
||||
- Intercept at 0x6477c0, check path against our map
|
||||
- If match, substitute the temp file path before calling original
|
||||
- Storm's `locateFileInDirectories` runs with the temp path, finds the file on disk (type 0)
|
||||
- Everything proceeds natively
|
||||
|
||||
**Option 2: Redirect in `locateFileInDirectories`**
|
||||
- Hook at 0x647e60 instead
|
||||
- When our path is requested, set output_path to the temp file's disk path
|
||||
- Set type_out = 0 (disk file)
|
||||
- Return success
|
||||
- `openFileWithOptions` calls `openFileHandle` on the temp path natively
|
||||
|
||||
Option 2 is slightly cleaner since it hooks at the search level rather than the open level.
|
||||
|
||||
### Temp File Location
|
||||
|
||||
Files should go in the system temp directory (`GetTempPath`), NOT the game directory:
|
||||
- No write permission issues
|
||||
- No clutter in the game folder
|
||||
- Windows manages cleanup
|
||||
- Unique naming avoids conflicts: `GetTempFileName(tempDir, "WU_", 0, tempPath)`
|
||||
|
||||
### Memory Behavior
|
||||
|
||||
`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
|
||||
- But for our use case (~2-3 MB total), the data effectively stays in RAM
|
||||
|
||||
### Key Addresses
|
||||
|
||||
Only one hook needed:
|
||||
|
||||
| Function | Address | Convention | Purpose |
|
||||
|----------|---------|------------|---------|
|
||||
| `openFileWithOptions` | 0x6477c0 | __stdcall(4) | Redirect path to temp file |
|
||||
|
||||
OR:
|
||||
|
||||
| Function | Address | Convention | Purpose |
|
||||
|----------|---------|------------|---------|
|
||||
| `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
|
||||
- No fake file contexts, no critical section management, no refcount tracking
|
||||
- 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
|
||||
|
||||
### Cons
|
||||
- Files technically touch disk (Windows may flush under memory pressure)
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## Approach A: Temp MPQ on Disk + Native Patch Loading
|
||||
|
||||
### Concept
|
||||
|
||||
Build a real MPQ archive at compile time containing all embedded assets. Write it to disk as `Data\patch-W.MPQ`. The game's native patch system discovers and loads it automatically, or we register it manually via `Archive_OpenUnified`.
|
||||
|
||||
### Implementation
|
||||
|
||||
1. **Build time**: Use a build step (or comptime Zig) to create a valid V1 MPQ archive containing all marker assets
|
||||
2. **DLL init**: Write the embedded MPQ blob to `Data\patch-W.MPQ` (single char matches the default `patch-?.MPQ` glob)
|
||||
3. **Registration**: Either:
|
||||
- (a) Hook early enough that `MPQ_InitializeArchives` picks it up naturally, OR
|
||||
- (b) Call `Archive_OpenUnified` directly to register it in the array after init
|
||||
|
||||
4. **Cleanup**: Delete temp file on DLL unload / game shutdown
|
||||
|
||||
### Key Addresses
|
||||
|
||||
| Function | Address | Signature |
|
||||
|----------|---------|-----------|
|
||||
| `Archive_OpenUnified` | 0x648dd0 | `__stdcall(path, param2, flags, &outArchive) → uint` |
|
||||
| `GrowArchiveArray` | 0x4045a0 | `__thiscall(ECX=&array_struct, count, grow_flag)` |
|
||||
| `Archive_Close` | 0x648ef0 | `(SArchive* archive)` |
|
||||
| Global array struct | 0x8826b4 | `{capacity, count, array_ptr, growth}` |
|
||||
|
||||
### Pros
|
||||
- **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
|
||||
- The `loadFileDetour` (0x648620) for addon files could remain unchanged
|
||||
|
||||
### Cons
|
||||
- Requires write access to game directory
|
||||
- Must clean up on exit (crash = orphaned file)
|
||||
- Must build valid MPQ at compile time (non-trivial format)
|
||||
- File on disk is visible to users and potentially other tools
|
||||
- Timing: if loaded after `MPQ_InitializeArchives`, must manually call `Archive_OpenUnified`
|
||||
|
||||
### MPQ Build Complexity
|
||||
|
||||
A minimal V1 MPQ for ~20 uncompressed files requires:
|
||||
- MPQ header (32 bytes)
|
||||
- File data blocks (sequential, uncompressed)
|
||||
- Hash table (encrypted, 4 entries per hash bucket × nearest power of 2)
|
||||
- Block table (encrypted, 16 bytes per file)
|
||||
|
||||
The hash table uses a specific encryption algorithm (documented in wowdev.wiki and StormLib source). Could implement in Zig comptime or use a build-time tool.
|
||||
|
||||
### Assetfix Synergy
|
||||
|
||||
The [assetfix project](/media/storage/projects/zig/assetfix/) already implements the glob pattern patch (`0x82edc2: '?' → '*'`) for multi-character patch names. If combined with assetfix, the temp file could use any name like `patch-weirdutils.MPQ`.
|
||||
|
||||
---
|
||||
|
||||
## Approach B: Register In-Memory MPQ (No Disk Write)
|
||||
|
||||
### Concept
|
||||
|
||||
Embed a valid MPQ archive in the DLL. Instead of writing it to disk, directly construct an SArchive object that references the in-memory MPQ data and insert it into the global archive array.
|
||||
|
||||
### The Challenge: I/O Vtable
|
||||
|
||||
The internal MPQ struct uses an I/O vtable at `+0x140 [0x50]` for all data access:
|
||||
```c
|
||||
(**(code **)(*archive[0x50] + 4))(buffer, position, 0, 0x1000, &bytes_read);
|
||||
```
|
||||
|
||||
For disk-based MPQs, this vtable points to functions that call `ReadFile`/`SetFilePointer` on a Windows file handle. For an in-memory MPQ, we'd need a **custom I/O vtable** that reads from our embedded data pointer instead.
|
||||
|
||||
### Implementation (Two Sub-Approaches)
|
||||
|
||||
#### 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
|
||||
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
|
||||
|
||||
**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.
|
||||
|
||||
**What changes**:
|
||||
- Fake file context needs a **file position** field for seeking (use unused offset, e.g. +0x38)
|
||||
- `readFileDetour` needs to read from `embedded_ptr + file_position` instead of always offset 0
|
||||
- `getFileSizeDetour` returns the total MPQ size (one value, not per-file)
|
||||
- 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`.
|
||||
|
||||
**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.
|
||||
|
||||
#### B2: Custom I/O Vtable (More Complex, Cleaner)
|
||||
|
||||
1. Embed MPQ in DLL
|
||||
2. Parse the MPQ header/hash/block tables at DLL init (in Zig)
|
||||
3. Allocate and populate the internal MPQ struct (~0x298+ bytes)
|
||||
4. Create a custom I/O vtable with a read function that memcpys from the embedded data
|
||||
5. Wrap in an 8-byte SArchive object (`{type=1, internal_handle}`)
|
||||
6. Insert into global archive array at `PTR_008826bc[count]`, increment count
|
||||
|
||||
**What this eliminates**: ALL file I/O hooks for asset loading. Storm finds files through the hash table, reads through our custom I/O vtable. No fake file contexts at all.
|
||||
|
||||
**Complexity**: The internal MPQ struct is ~0x298 bytes with many fields. Getting every field right (hash table decryption, block table setup, sector size computation, attribute reading) is extremely error-prone. Essentially reimplementing `InitializeArchiveStructure` (0x655bf0).
|
||||
|
||||
### Pros (Both B variants)
|
||||
- No disk writes
|
||||
- Assets stay embedded in DLL
|
||||
- B2 eliminates all file hooks entirely
|
||||
|
||||
### Cons
|
||||
- B1: Still needs ~4 hooks (open, size, read, cleanup) but for 1 file instead of 20; may still need async hooks
|
||||
- B2: Requires precise RE of the ~0x298-byte internal struct; fragile
|
||||
- Both: Must build valid MPQ at compile time
|
||||
- B2: Must reimplement hash table decryption in Zig
|
||||
|
||||
---
|
||||
|
||||
## Approach C: Hook locateFileInDirectories (Single Hook Point)
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
However, this could work in combination with disk writes (see Approach A) or with a minimal I/O hook.
|
||||
|
||||
### Variant: Hook locateFileInDirectories + Inject Into Disk Hash Table
|
||||
|
||||
The disk file hash table at `PTR_00c521e8`/`PTR_00c521f0` is checked BEFORE the archive chain:
|
||||
```c
|
||||
// In locateFileInDirectories:
|
||||
if (PTR_00c521f0 != 0xffffffff) {
|
||||
hash = String_ComputeHash(path);
|
||||
entry = hash_table[hash & PTR_00c521f0];
|
||||
if (entry.hash == hash && strcmp(entry.name, path) == 0) {
|
||||
strcpy(output_path, entry.disk_path);
|
||||
*type_out = entry.type; // type stored at entry[7]
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If we could insert entries into this hash table mapping our asset paths to disk locations, AND write the files to disk, files would be found without any archive hooks.
|
||||
|
||||
### Key Addresses
|
||||
|
||||
| Symbol | Address | Notes |
|
||||
|--------|---------|-------|
|
||||
| `locateFileInDirectories` | 0x647e60 | __fastcall, 6 params |
|
||||
| `scanDirectoriesForFiles` | 0x646ea0 | Populates disk hash table (called once) |
|
||||
| `String_ComputeHash` | 0x64b3f0 | Storm hash function |
|
||||
| `FindFileInArchive` | 0x654920 | Wrapper calling File_FindInArchive |
|
||||
| `File_FindInArchive` | 0x6549a0 | Recursive archive chain search |
|
||||
| Disk hash table ptr | 0xc521e8 | Hash table base pointer |
|
||||
| Disk hash table mask | 0xc521f0 | Hash mask (0xffffffff = disabled) |
|
||||
|
||||
### Pros
|
||||
- Single hook point for file discovery
|
||||
- Leverages existing game code for all I/O
|
||||
|
||||
### Cons
|
||||
- Still requires disk files OR additional I/O hooks
|
||||
- Hash table struct needs reverse engineering
|
||||
- Doesn't solve the fundamental problem (serving data from memory)
|
||||
|
||||
---
|
||||
|
||||
## Approach D: CheckFileExistence Hook (assetfix Pattern)
|
||||
|
||||
### Concept
|
||||
|
||||
The [assetfix project](/media/storage/projects/zig/assetfix/) takes a different approach entirely:
|
||||
|
||||
1. **NOP two gates** in `File_FindInArchive` (0x654b5c, 0x654b6a) that restrict `CheckFileExistence` to only "Interface/AddOns" paths
|
||||
2. **Hook `CheckFileExistence`** (0x654DD0) to check a hash map of loose disk files
|
||||
3. If found on disk, return the disk path; Storm loads it normally
|
||||
|
||||
This works for **disk-based** loose files but not for in-memory embedded data. However, combined with writing temp files to disk, it provides a clean single-hook solution.
|
||||
|
||||
### Key Addresses (from assetfix)
|
||||
|
||||
| Target | Address | Patch |
|
||||
|--------|---------|-------|
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Criteria | Current (6 hooks) | E: Temp File Handles | A: Temp MPQ | B1: In-mem MPQ + hooks | B2: In-mem MPQ + struct | D: Disk + CFE |
|
||||
|----------|-------------------|---------------------|-------------|----------------------|------------------------|--------------|
|
||||
| Hook count | 6 (+1 addon) | **1** (+1 addon) | 0 (+1 addon) | ~4 (+1 addon) | 0 (+1 addon) | 1 (+1 addon) + 2 NOPs |
|
||||
| Disk writes | None | ~20 temp files (cache-backed) | 1 MPQ file | None | None | ~20 files |
|
||||
| Fake contexts | ~20 | **0** | 0 | 1 | 0 | 0 |
|
||||
| Async handling | Manual | **Native** | Native | Uncertain | Native | Native |
|
||||
| Build complexity | Low | **Low** | Medium (MPQ builder) | Medium (MPQ builder) | High (struct RE) | Low |
|
||||
| RE work needed | Done | **Minimal** (verify path redirect) | Archive_OpenUnified conv | Same as A + async verify | Full struct layout | Already done (assetfix) |
|
||||
| Risk | Proven but fragile | **Low** (real OS handles) | Low (native I/O) | Medium (async path?) | High (struct mismatch) | Low |
|
||||
| Cleanup needed | None | **Auto** (DELETE_ON_CLOSE) | Delete temp file | None | Remove from array | Delete temp files |
|
||||
| Purely in-memory | Yes | Mostly (cache-backed) | No | Yes | Yes | No |
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
### 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
|
||||
|
||||
**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
|
||||
|
||||
### Runner-Up: Approach A (Temp MPQ on Disk)
|
||||
|
||||
If a single-file solution is preferred over ~20 temp files, building a real MPQ and registering it via `Archive_OpenUnified` eliminates all hooks entirely. The cost is implementing an MPQ V1 builder (hash table encryption, block table, header). Could be combined with Approach E: use temp files now, migrate to MPQ later.
|
||||
|
||||
### Worth Combining With: Assetfix Integration
|
||||
|
||||
The assetfix project's `CheckFileExistence` hook and glob pattern patch could complement either approach, especially for supporting user-provided loose asset files alongside our embedded ones.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Key Function Reference
|
||||
|
||||
### Archive Management
|
||||
| Function | Address | Convention | Notes |
|
||||
|----------|---------|------------|-------|
|
||||
| `MPQ_InitializeArchives` | 0x403740 | cdecl | Opens all base + patch MPQs |
|
||||
| `OpenMPQArchiveWithPaths` | 0x403b00 | __fastcall | Wrapper: format path + open |
|
||||
| `Archive_OpenUnified` | 0x648dd0 | __stdcall(4) | SFileOpenArchive equivalent |
|
||||
| `Archive_Close` | 0x648ef0 | (ptr) | Closes archive, frees SArchive |
|
||||
| `MPQ_CleanupAllArchives` | 0x403c70 | cdecl | Closes all in reverse order |
|
||||
| `MPQArchiveEnumerator` | 0x4039b0 | __fastcall | Discovers patch-?.MPQ files |
|
||||
| `GrowArchiveArray` | 0x4045a0 | __thiscall | Resizes archive pointer array |
|
||||
| `ResizeArchiveArray` | 0x4046f0 | | Initial array allocation |
|
||||
|
||||
### Archive Internals
|
||||
| Function | Address | Convention | Notes |
|
||||
|----------|---------|------------|-------|
|
||||
| `OpenFileWithValidation` | 0x655690 | (6 params) | Opens + validates MPQ file |
|
||||
| `InitializeArchiveStructure` | 0x655bf0 | __fastcall | Reads header, hash, block tables |
|
||||
| `Archive_ReadAttributes` | 0x6561c0 | | Reads archive attributes |
|
||||
| `InitializeLookupTables` | 0x654f60 | | Lazy init for hash lookups |
|
||||
|
||||
### File Search
|
||||
| Function | Address | Convention | Notes |
|
||||
|----------|---------|------------|-------|
|
||||
| `locateFileInDirectories` | 0x647e60 | __fastcall(6) | Top-level: disk → hash → archive |
|
||||
| `FindFileInArchive` | 0x654920 | (4 params) | Wrapper for File_FindInArchive |
|
||||
| `File_FindInArchive` | 0x6549a0 | __fastcall(7) | Recursive archive chain search |
|
||||
| `File_FindInStorage` | 0x656690 | | Storage-level file search |
|
||||
| `File_OpenAdvanced` | 0x656590 | | Advanced open with validation |
|
||||
| `CheckFileExistence` | 0x654dd0 | __fastcall(3) | Checks disk for loose files |
|
||||
| `scanDirectoriesForFiles` | 0x646ea0 | | Populates disk file hash table |
|
||||
| `String_ComputeHash` | 0x64b3f0 | | Storm string hash function |
|
||||
|
||||
### File I/O (Current Hooks)
|
||||
| Function | Address | Convention | Notes |
|
||||
|----------|---------|------------|-------|
|
||||
| `openFileWithOptions` | 0x6477c0 | __stdcall(4) | Top-level file open |
|
||||
| `GetFileSizeFromHandle` | 0x6487f0 | __stdcall(2) | Type-dispatched size query |
|
||||
| `ReadFileFromMultipleSources` | 0x648460 | __stdcall(5) | Type-dispatched read |
|
||||
| `processAsyncFileOperation` | 0x647350 | __fastcall(ECX) | Async executor low-level |
|
||||
| `CleanupFileHandleResources` | 0x648730 | __stdcall(1) | Close + free file context |
|
||||
| `initializeFileContext` | 0x647290 | __thiscall | Init critsec, set type |
|
||||
| `cleanupFileContext` | 0x6472d0 | __thiscall | Destroy critsec, free path |
|
||||
|
||||
### I/O Provider System
|
||||
| Function/Symbol | Address | Notes |
|
||||
|-----------------|---------|-------|
|
||||
| `CreateIOObject` | 0x66dfa0 | __cdecl, allocates 0x118-byte IO object |
|
||||
| `InitializeIOObject` | 0x66dfe0 | __thiscall, sets vtable + async manager |
|
||||
| `SetIOCallback` | 0x66e230 | __cdecl, creates callback-style IO object |
|
||||
| IO vtable | 0x80fddc | 12-entry function pointer table |
|
||||
| `Resource_Load` (vtable[1]) | 0x66e0c0 | __thiscall, the read dispatch function |
|
||||
| `Resource_Unload` (vtable[2]) | 0x66e130 | __thiscall, unload/release |
|
||||
| `ValidateIOOperation` (vtable[8]) | 0x66e2d0 | |
|
||||
| `GetIOResult` (vtable[9]) | 0x66e310 | |
|
||||
| `CancelIOOperation` (vtable[10]) | 0x66e340 | |
|
||||
|
||||
### Low-Level MPQ (ZipFileArchive)
|
||||
| Function | Address | Notes |
|
||||
|----------|---------|-------|
|
||||
| `MPQ_OpenArchive` | 0x668360 | __fastcall, allocates 0x110 bytes |
|
||||
| `MPQ_CloseArchive` | 0x668440 | |
|
||||
| `openArchiveFile` | 0x667bd0 | __thiscall, calls `openFileReadOnly` → `CreateFileA` |
|
||||
| `readArchiveHeader` | 0x667c10 | Reads + validates MPQ header |
|
||||
| `MPQ_ProcessFileTable` | 0x667da0 | Processes hash/block tables |
|
||||
| `readFromArchiveFile` | 0x668750 | __fastcall, sector-based read |
|
||||
| `seekInArchiveFile` | 0x668650 | |
|
||||
| `closeArchiveFile` | 0x668610 | |
|
||||
| `findFileInArchive` (low-level) | 0x668470 | Hash table lookup |
|
||||
| `extractFileFromArchive` | 0x668950 | Full file extraction |
|
||||
| MPQ archive linked list | 0xc5a32c | Head of ZipFileArchive linked list |
|
||||
| ZipFileArchive RTTI | 0x86709c | `".?AUZipFileArchive@@"` |
|
||||
|
||||
### Globals
|
||||
| Symbol | Address | Description |
|
||||
|--------|---------|-------------|
|
||||
| Archive array capacity | 0x8826b4 | Max archive slots |
|
||||
| Archive array count | 0x8826b8 | Current archive count |
|
||||
| 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) |
|
||||
| Archive search critsec | 0xc54008 | Critical section for archive ops |
|
||||
| Archive search state | 0xc53ff0 | Used by File_FindInArchive |
|
||||
| Patch glob "patch-?.MPQ" | 0x82edbc | Glob pattern for patch discovery |
|
||||
| Patch glob char | 0x82edc2 | The '?' byte (assetfix patches to '*') |
|
||||
| Data path format | 0x82edc8 | `"Data\%s"` format string |
|
||||
| SArchive RTTI | 0x82e248 | `".PAVSArchive@@"` |
|
||||
+77
-3
@@ -178,6 +178,80 @@ The trampoline memory (VirtualAlloc'd) contained zeros instead of the saved prol
|
||||
|
||||
**Fix**: Install Storm I/O hooks (`installFileHooks`) BEFORE `file_hook` at 0x648620. Remove in reverse order.
|
||||
|
||||
### Open Questions
|
||||
- Does M2 async loading go through `ReadFileFromMultipleSources` or bypass it via direct `fileReadWithLock`? The `processAsyncDetour` hook catches the latter path. If M2 still shows ErrorCube, may need hook 5 (`loadModelFromFileAsync` at 0x71d4e0).
|
||||
- `openFileWithOptions` return value: returns type code (0=fail, 1-4=success per type). We return 2 for fakes. Callers primarily check handle_out != NULL.
|
||||
### Two Async Systems (Confirmed)
|
||||
|
||||
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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
### onModelLoadComplete (0x71d5e0) — Verified Decompile
|
||||
```c
|
||||
void __fastcall onModelLoadComplete(void *modelObject) {
|
||||
CleanupFileHandleResources(**(int **)(modelObject + 0xc)); // free file handle via task
|
||||
ReturnAsyncTaskToPool(*(int *)(modelObject + 0xc)); // return task
|
||||
*(undefined4 *)(modelObject + 0xc) = 0; // clear task ptr
|
||||
processLoadedModelData(modelObject); // process M2 data
|
||||
}
|
||||
```
|
||||
**Critical**: cleanup file handle BEFORE processLoadedModelData. Our hook matches this order.
|
||||
|
||||
### cleanupFileContext (0x6472d0) — Verified Decompile
|
||||
```c
|
||||
void __fastcall cleanupFileContext(int param_1) {
|
||||
if (*(param_1 + 0x1c)) { cleanupInflateContext(*(param_1+0x1c)); FreeMemory(*(param_1+0x1c)); }
|
||||
if (*(param_1 + 0x0c)) { FreeMemory(*(param_1 + 0x0c)); } // path string
|
||||
if (*(param_1 + 0x10)) { FreeMemory(*(param_1 + 0x10)); }
|
||||
if (*(param_1 + 0x18)) { FreeMemory(*(param_1 + 0x18)); }
|
||||
DeleteCriticalSection(param_1 + 0x24);
|
||||
}
|
||||
```
|
||||
NOTE: cleanupFileContext DOES free +0x0C (path string). Do NOT free it manually.
|
||||
|
||||
### TextureLoadCallback (0x44a500) — Verified Decompile
|
||||
```c
|
||||
void __fastcall TextureLoadCallback(int texture_obj) {
|
||||
puVar1 = ProcessTextureData(texture_obj);
|
||||
if (puVar1 == NULL) CreateSolidColorTexture(&fallback, texture_obj);
|
||||
CleanupFileHandleResources(**(int **)(texture_obj + 0x138)); // task[0] = file ctx
|
||||
**(int **)(texture_obj + 0x138) = 0; // clear file ctx in task
|
||||
ReturnAsyncTaskToPool(*(int *)(texture_obj + 0x138));
|
||||
*(int *)(texture_obj + 0x138) = 0;
|
||||
}
|
||||
```
|
||||
Called by ProcessAsyncTasksWithTimeLimit on the main thread after worker completes.
|
||||
|
||||
### LoadTextureFromPath (0x44a310) — Texture Async Task Setup
|
||||
```c
|
||||
puVar5 = openFileWithOptions(NULL, path, flags, &file_handle); // hook 1 creates fake
|
||||
puVar7 = AllocateAsyncTaskObject();
|
||||
texture_obj[0x4e] = puVar7; // +0x138 = task ptr
|
||||
puVar7[3] = texture_obj; // task[3] = texture obj
|
||||
*(task + 0x10) = TextureLoadCallback; // task[4] = completion callback
|
||||
*(task + 0x00) = file_handle; // task[0] = file context
|
||||
size = GetFileSizeFromHandle(file_handle); // hook 2 returns size
|
||||
*(task + 0x08) = size; // task[2] = file size
|
||||
*(task + 0x04) = global_buffer_pool_ptr; // task[1] = buffer
|
||||
AsyncTask_QueueForExecution(task);
|
||||
```
|
||||
|
||||
### Current Crash: EIP=0 During TextureLoadCallback
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+43
-2
@@ -8,11 +8,23 @@
|
||||
//! through CreateWorldUnit which handles all spatial registration,
|
||||
//! render setup, and lifecycle management.
|
||||
|
||||
const std = @import("std");
|
||||
const hook = @import("hook");
|
||||
const o = @import("offsets.zig");
|
||||
const wow = @import("../outline/wow.zig");
|
||||
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;
|
||||
|
||||
var g_mutex: ?*anyopaque = null;
|
||||
var g_is_hook_owner: bool = false;
|
||||
|
||||
// =============================================================================
|
||||
// Position helpers
|
||||
// =============================================================================
|
||||
@@ -95,7 +107,7 @@ fn cleanupEntity(obj: *anyopaque) void {
|
||||
|
||||
var test_marker: ?*anyopaque = null;
|
||||
|
||||
const MODEL_PATH: [*:0]const u8 = "Spells\\WU_XYZ.m2";
|
||||
const MODEL_PATH: [*:0]const u8 = "Spells\\Raid_UI_FX_Yellow.m2";
|
||||
|
||||
/// Create a test marker at player position using the native entity creation API.
|
||||
pub fn createTestMarker() ?*anyopaque {
|
||||
@@ -219,9 +231,38 @@ pub fn luaGetPlayerPosition(L: u32) callconv(.c) u32 {
|
||||
|
||||
pub fn installHooks() void {
|
||||
con.print("[markers] Module loaded\n");
|
||||
|
||||
// Multi-DLL safety: only one instance per process should own markers
|
||||
var mutex_name_buf: [64]u8 = undefined;
|
||||
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\MarkersHook_{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("[markers] Another DLL owns markers (mutex taken), skipping\n");
|
||||
return;
|
||||
}
|
||||
g_is_hook_owner = true;
|
||||
|
||||
// Nothing to hook - markers are created via Lua commands
|
||||
}
|
||||
|
||||
pub fn removeHooks() void {
|
||||
destroyTestMarker();
|
||||
if (g_is_hook_owner) {
|
||||
destroyTestMarker();
|
||||
}
|
||||
|
||||
if (g_is_hook_owner) {
|
||||
if (g_mutex) |m| {
|
||||
_ = ReleaseMutex(m);
|
||||
_ = CloseHandle(m);
|
||||
g_mutex = null;
|
||||
}
|
||||
}
|
||||
g_is_hook_owner = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Custom minimap icons module.
|
||||
//!
|
||||
//! Adds distinct minimap icons for specific NPC types: flight masters,
|
||||
//! mailboxes, reagent vendors, class trainers, etc.
|
||||
//!
|
||||
//! See: VanillaHelpers addon (https://github.com/nicholasgasior/VanillaHelpers)
|
||||
//! for reference on NPC type detection and minimap icon overlay patterns.
|
||||
//!
|
||||
//! TODO: Hook minimap icon rendering, classify NPCs by type, overlay custom icons.
|
||||
|
||||
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;
|
||||
|
||||
var g_mutex: ?*anyopaque = null;
|
||||
var g_is_hook_owner: bool = false;
|
||||
|
||||
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\\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;
|
||||
}
|
||||
|
||||
pub fn removeHooks() void {
|
||||
if (g_is_hook_owner) {
|
||||
if (g_mutex) |m| {
|
||||
_ = ReleaseMutex(m);
|
||||
_ = CloseHandle(m);
|
||||
g_mutex = null;
|
||||
}
|
||||
}
|
||||
g_is_hook_owner = false;
|
||||
}
|
||||
+43
-2
@@ -3,18 +3,48 @@
|
||||
//! Exposes init/cleanup/config functions called from main.zig,
|
||||
//! and a Lua C callback for `/wu outline` commands.
|
||||
|
||||
const std = @import("std");
|
||||
const hook = @import("hook");
|
||||
const con = @import("../console.zig");
|
||||
const tracker = @import("tracker.zig");
|
||||
const model_hook = @import("model_hook.zig");
|
||||
const d3d9_hook = @import("d3d9_hook.zig");
|
||||
|
||||
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;
|
||||
|
||||
var g_mutex: ?*anyopaque = null;
|
||||
var g_is_hook_owner: bool = false;
|
||||
|
||||
/// 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
|
||||
/// causes model rendering to stutter at ~10fps.
|
||||
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\\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;
|
||||
|
||||
if (!model_hook.installHooks()) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -28,8 +58,19 @@ pub fn initD3D9Deferred() void {
|
||||
|
||||
/// Remove all outline hooks. Called during DLL_PROCESS_DETACH.
|
||||
pub fn cleanup() void {
|
||||
d3d9_hook.removeHooks();
|
||||
model_hook.removeHooks();
|
||||
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;
|
||||
}
|
||||
}
|
||||
g_is_hook_owner = false;
|
||||
}
|
||||
|
||||
/// Enable or disable outline rendering.
|
||||
|
||||
@@ -43,6 +43,11 @@ extern "kernel32" fn WriteFile(
|
||||
) callconv(WINAPI) i32;
|
||||
|
||||
extern "kernel32" fn CloseHandle(hObject: HANDLE) callconv(WINAPI) i32;
|
||||
extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: [*:0]const u8) callconv(WINAPI) ?HANDLE;
|
||||
extern "kernel32" fn ReleaseMutex(hMutex: HANDLE) callconv(WINAPI) i32;
|
||||
extern "kernel32" fn GetLastError() callconv(WINAPI) u32;
|
||||
extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32;
|
||||
const ERROR_ALREADY_EXISTS: u32 = 183;
|
||||
|
||||
// =============================================================================
|
||||
// State
|
||||
@@ -76,6 +81,8 @@ var queue_tail: usize = 0;
|
||||
var queue_count: usize = 0;
|
||||
var mutex: std.Thread.Mutex = .{};
|
||||
var worker_running: bool = false;
|
||||
var g_mutex: ?HANDLE = null;
|
||||
var g_is_hook_owner: bool = false;
|
||||
|
||||
fn enqueue(shot: PendingScreenshot) bool {
|
||||
if (queue_count >= MAX_PENDING) return false;
|
||||
@@ -342,6 +349,24 @@ 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\\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;
|
||||
|
||||
// CTgaFile::Write at 0x5a4810
|
||||
// __thiscall(self, filename) — prologue: 55 8B EC 83 EC 08 = 6 bytes, no fixups
|
||||
// Thunk: fastcall(ECX=self, EDX, stack: filename) → cdecl(self, edx, filename)
|
||||
@@ -359,5 +384,16 @@ pub fn installHook() void {
|
||||
}
|
||||
|
||||
pub fn removeHook() void {
|
||||
tga_hook.remove();
|
||||
if (g_is_hook_owner) {
|
||||
tga_hook.remove();
|
||||
}
|
||||
|
||||
if (g_is_hook_owner) {
|
||||
if (g_mutex) |m| {
|
||||
_ = ReleaseMutex(m);
|
||||
_ = CloseHandle(m);
|
||||
g_mutex = null;
|
||||
}
|
||||
}
|
||||
g_is_hook_owner = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
// =============================================================================
|
||||
// transmogfix - Transmog update coalescing
|
||||
// =============================================================================
|
||||
//
|
||||
// Fixes death frame drops in WoW 1.12.1 caused by the server's transmog
|
||||
// durability workaround (3-packet clear→dur→restore pattern per slot).
|
||||
//
|
||||
// Three hooks:
|
||||
// 1. SetBlock (0x6142E0) - intercepts all field writes, blocks VISIBLE_ITEM
|
||||
// clears/restores and captures durability writes
|
||||
// 2. RefreshVisualAppearance (0x5fb880) - skips expensive visual refresh
|
||||
// when transmog updates are coalesced
|
||||
// 3. SceneEnd (0x5a17a0) - processes pending timeouts every frame
|
||||
//
|
||||
// =============================================================================
|
||||
|
||||
const std = @import("std");
|
||||
const hook = @import("hook");
|
||||
const con = @import("../console.zig");
|
||||
|
||||
const WINAPI = std.builtin.CallingConvention.winapi;
|
||||
|
||||
extern "kernel32" fn GetTickCount() callconv(WINAPI) u32;
|
||||
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;
|
||||
|
||||
// =============================================================================
|
||||
// Game addresses
|
||||
// =============================================================================
|
||||
|
||||
const ADDR_UnitGUID: usize = 0x515970;
|
||||
const ADDR_GetObjectByGUID: usize = 0x464870;
|
||||
const ADDR_UpdateInvAlerts: usize = 0x4c7ee0;
|
||||
const ADDR_RefreshAppearance: usize = 0x60afb0;
|
||||
const ADDR_RefreshEquipmentDisplay: usize = 0x60ABE0;
|
||||
const ADDR_SetBlock: usize = 0x6142E0;
|
||||
const ADDR_RefreshVisualAppearance: usize = 0x5fb880;
|
||||
const ADDR_SceneEnd: usize = 0x5a17a0;
|
||||
|
||||
// =============================================================================
|
||||
// Constants (1.12.1 client)
|
||||
// =============================================================================
|
||||
|
||||
const PLAYER_VISIBLE_ITEM_1_0: u32 = 0x0F8;
|
||||
const VISIBLE_ITEM_STRIDE: u32 = 0x0C;
|
||||
const ITEM_FIELD_DURABILITY: u32 = 0x2E;
|
||||
const PLAYER_FIELD_INV_SLOT_HEAD: u32 = 0x1DA;
|
||||
const PLAYER_INV_SLOT_HEAD_BYTES: u32 = PLAYER_FIELD_INV_SLOT_HEAD * 4;
|
||||
const OTHER_PLAYER_TIMEOUT_MS: u32 = 100;
|
||||
const LOCAL_PLAYER_CLEANUP_MS: u32 = 5000;
|
||||
const DISPLAY_ID_BOX: u32 = 4;
|
||||
const UNIT_CACHED_MODELDATA_OFFSET: u32 = 0xb34;
|
||||
const DISPLAY_INFO_TABLE_PTR: usize = 0x00c0de90;
|
||||
|
||||
// =============================================================================
|
||||
// Game API functions (inline ASM)
|
||||
// =============================================================================
|
||||
|
||||
fn unitGUID(unit_id: [*:0]const u8) u64 {
|
||||
// __fastcall(ECX=str) → u64 in EDX:EAX
|
||||
var lo: u32 = undefined;
|
||||
var hi: u32 = undefined;
|
||||
asm volatile (
|
||||
\\call *%[func]
|
||||
: [lo] "={eax}" (lo),
|
||||
[hi] "={edx}" (hi),
|
||||
: [_] "{ecx}" (unit_id),
|
||||
[func] "r" (@as(u32, ADDR_UnitGUID)),
|
||||
: .{ .memory = true, .cc = true }
|
||||
);
|
||||
return @as(u64, hi) << 32 | lo;
|
||||
}
|
||||
|
||||
fn getObjectByGUID(guid: u64) u32 {
|
||||
const lo: u32 = @truncate(guid);
|
||||
const hi: u32 = @truncate(guid >> 32);
|
||||
return asm volatile (
|
||||
\\push %[hi]
|
||||
\\push %[lo]
|
||||
\\call *%[func]
|
||||
: [ret] "={eax}" (-> u32),
|
||||
: [lo] "r" (lo),
|
||||
[hi] "r" (hi),
|
||||
[func] "r" (@as(u32, ADDR_GetObjectByGUID)),
|
||||
: .{ .ecx = true, .edx = true, .memory = true, .cc = true }
|
||||
);
|
||||
}
|
||||
|
||||
fn updateInventoryAlertStates() void {
|
||||
const func: *const fn () callconv(.c) void = @ptrFromInt(ADDR_UpdateInvAlerts);
|
||||
func();
|
||||
}
|
||||
|
||||
fn refreshAppearanceAndEquipment(unit: u32) void {
|
||||
hook.fastcall(void, ADDR_RefreshAppearance, unit, @as(u32, 0));
|
||||
}
|
||||
|
||||
fn refreshEquipmentDisplay(unit: u32) void {
|
||||
hook.fastcall(void, ADDR_RefreshEquipmentDisplay, unit, @as(u32, 0));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State
|
||||
// =============================================================================
|
||||
|
||||
const LocalPending = struct {
|
||||
original_visible_item: u32 = 0,
|
||||
timestamp: u32 = 0,
|
||||
captured_durability: u32 = 0,
|
||||
active: bool = false,
|
||||
has_durability: bool = false,
|
||||
};
|
||||
|
||||
var g_local_pending: [19]LocalPending = [1]LocalPending{.{}} ** 19;
|
||||
var g_local_pending_count: i32 = 0;
|
||||
|
||||
const OTHER_PENDING_SIZE: u32 = 1031;
|
||||
|
||||
const OtherPending = struct {
|
||||
guid: u64 = 0,
|
||||
slot: i32 = 0,
|
||||
timestamp: u32 = 0,
|
||||
unit_ptr: u32 = 0,
|
||||
active: bool = false,
|
||||
};
|
||||
|
||||
var g_other_pending: [OTHER_PENDING_SIZE]OtherPending = [1]OtherPending{.{}} ** OTHER_PENDING_SIZE;
|
||||
var g_other_pending_count: i32 = 0;
|
||||
|
||||
var g_cached_visible_item: [19]u32 = .{0} ** 19;
|
||||
|
||||
const UNIT_CACHE_SIZE: u32 = 64;
|
||||
|
||||
const UnitVisualState = struct {
|
||||
guid: u64 = 0,
|
||||
last_seen: u32 = 0,
|
||||
visible_items: [19]u32 = .{0} ** 19,
|
||||
clear_timestamp: [19]u32 = .{0} ** 19,
|
||||
has_pending_clear: bool = false,
|
||||
};
|
||||
|
||||
var g_unit_cache: [UNIT_CACHE_SIZE]UnitVisualState = [1]UnitVisualState{.{}} ** UNIT_CACHE_SIZE;
|
||||
|
||||
const CachedPlayerState = struct {
|
||||
local_guid: u64 = 0,
|
||||
player_obj: u32 = 0,
|
||||
player_desc: u32 = 0,
|
||||
equipped_guids: [19]u64 = .{0} ** 19,
|
||||
visible_items: [19]u32 = .{0} ** 19,
|
||||
valid: bool = false,
|
||||
};
|
||||
|
||||
var g_cache: CachedPlayerState = .{};
|
||||
|
||||
var g_enabled: bool = true;
|
||||
var g_initialized: bool = false;
|
||||
var g_is_hook_owner: bool = false;
|
||||
var g_mutex: ?*anyopaque = null;
|
||||
|
||||
// =============================================================================
|
||||
// Hooks
|
||||
// =============================================================================
|
||||
|
||||
var set_block_hook = hook.Hook{};
|
||||
var refresh_hook = hook.Hook{};
|
||||
var scene_end_hook = hook.Hook{};
|
||||
|
||||
// =============================================================================
|
||||
// Object manager helpers
|
||||
// =============================================================================
|
||||
|
||||
fn cachePlayerState() bool {
|
||||
const local_guid = unitGUID("player");
|
||||
if (local_guid == 0) {
|
||||
g_cache.valid = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const player_obj = getObjectByGUID(local_guid);
|
||||
if (player_obj == 0 or (player_obj & 1) != 0) {
|
||||
g_cache.valid = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fast path: player object hasn't changed
|
||||
if (g_cache.valid and g_cache.player_obj == player_obj) return true;
|
||||
|
||||
g_cache.valid = false;
|
||||
g_cache.local_guid = local_guid;
|
||||
g_cache.player_obj = player_obj;
|
||||
|
||||
g_cache.player_desc = hook.readMem(u32, player_obj + 0x8);
|
||||
if (g_cache.player_desc == 0 or (g_cache.player_desc & 1) != 0) return false;
|
||||
|
||||
for (0..19) |i| {
|
||||
const slot: u32 = @intCast(i);
|
||||
const adjusted_slot = slot + 5;
|
||||
g_cache.equipped_guids[i] = hook.readMem(u64, g_cache.player_desc + PLAYER_INV_SLOT_HEAD_BYTES + adjusted_slot * 8);
|
||||
|
||||
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (slot * VISIBLE_ITEM_STRIDE);
|
||||
g_cache.visible_items[i] = hook.readMem(u32, g_cache.player_desc + field_index * 4);
|
||||
|
||||
g_cached_visible_item[i] = g_cache.visible_items[i];
|
||||
}
|
||||
|
||||
g_cache.valid = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
fn getCachedEquippedItemObject(slot: usize) u32 {
|
||||
if (slot >= 19) return 0;
|
||||
const guid = g_cache.equipped_guids[slot];
|
||||
if (guid == 0) return 0;
|
||||
return getObjectByGUID(guid);
|
||||
}
|
||||
|
||||
fn writeItemDurabilityDirect(slot: usize, durability: u32) void {
|
||||
const item_obj = getCachedEquippedItemObject(slot);
|
||||
if (item_obj == 0 or (item_obj & 1) != 0) return;
|
||||
const desc = hook.readMem(u32, item_obj + 0x8);
|
||||
if (desc == 0 or (desc & 1) != 0) return;
|
||||
const dest: [*]u32 = @ptrFromInt(desc);
|
||||
dest[ITEM_FIELD_DURABILITY] = durability;
|
||||
}
|
||||
|
||||
fn findSlotForItemGUID(item_guid: u64) i32 {
|
||||
if (!g_cache.valid or item_guid == 0) return -1;
|
||||
for (0..19) |i| {
|
||||
if (g_cache.equipped_guids[i] == item_guid) return @intCast(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
fn getUnitGuid(unit: u32) u64 {
|
||||
if (unit == 0) return 0;
|
||||
const desc_ptr = hook.readMem(u32, unit + 0x8);
|
||||
if (desc_ptr == 0 or (desc_ptr & 1) != 0) return 0;
|
||||
return hook.readMem(u64, desc_ptr);
|
||||
}
|
||||
|
||||
fn isPlayerGuid(guid: u64) bool {
|
||||
const high_type: u16 = @truncate(guid >> 48);
|
||||
return high_type == 0x0000;
|
||||
}
|
||||
|
||||
fn isLocalPlayerObject(obj: u32) bool {
|
||||
const local_guid = unitGUID("player");
|
||||
if (local_guid == 0) return false;
|
||||
return getUnitGuid(obj) == local_guid;
|
||||
}
|
||||
|
||||
fn findSlotForItemObject(obj: u32) i32 {
|
||||
const local_guid = unitGUID("player");
|
||||
if (local_guid == 0) return -1;
|
||||
const player_obj = getObjectByGUID(local_guid);
|
||||
if (player_obj == 0 or (player_obj & 1) != 0) return -1;
|
||||
const player_desc = hook.readMem(u32, player_obj + 0x8);
|
||||
if (player_desc == 0 or (player_desc & 1) != 0) return -1;
|
||||
|
||||
for (0..19) |i| {
|
||||
const adjusted_slot: u32 = @intCast(i + 5);
|
||||
const equipped_guid = hook.readMem(u64, player_desc + PLAYER_INV_SLOT_HEAD_BYTES + adjusted_slot * 8);
|
||||
if (equipped_guid == 0) continue;
|
||||
const equipped = getObjectByGUID(equipped_guid);
|
||||
if (equipped == obj) return @intCast(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
fn readUnitVisibleItem(unit: u32, slot: usize) u32 {
|
||||
if (unit == 0 or slot >= 19) return 0;
|
||||
const desc = hook.readMem(u32, unit + 0x8);
|
||||
if (desc == 0 or (desc & 1) != 0) return 0;
|
||||
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(slot)) * VISIBLE_ITEM_STRIDE);
|
||||
const desc_arr: [*]const u32 = @ptrFromInt(desc);
|
||||
return desc_arr[field_index];
|
||||
}
|
||||
|
||||
fn readVisibleItems(unit: u32, out: *[19]u32) void {
|
||||
const desc = hook.readMem(u32, unit + 0x8);
|
||||
if (desc == 0 or (desc & 1) != 0) {
|
||||
out.* = .{0} ** 19;
|
||||
return;
|
||||
}
|
||||
const desc_arr: [*]const u32 = @ptrFromInt(desc);
|
||||
for (0..19) |i| {
|
||||
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(i)) * VISIBLE_ITEM_STRIDE);
|
||||
out[i] = desc_arr[field_index];
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Unit visual state cache
|
||||
// =============================================================================
|
||||
|
||||
fn getUnitCache(guid: u64, allocate: bool) ?*UnitVisualState {
|
||||
var empty_slot: i32 = -1;
|
||||
var oldest_slot: u32 = 0;
|
||||
var oldest_time: u32 = 0xFFFFFFFF;
|
||||
|
||||
for (0..UNIT_CACHE_SIZE) |i| {
|
||||
if (g_unit_cache[i].guid == guid) return &g_unit_cache[i];
|
||||
if (g_unit_cache[i].guid == 0 and empty_slot < 0) {
|
||||
empty_slot = @intCast(i);
|
||||
}
|
||||
if (g_unit_cache[i].last_seen < oldest_time) {
|
||||
oldest_time = g_unit_cache[i].last_seen;
|
||||
oldest_slot = @intCast(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allocate) return null;
|
||||
|
||||
const slot: u32 = if (empty_slot >= 0) @intCast(empty_slot) else oldest_slot;
|
||||
g_unit_cache[slot] = .{};
|
||||
g_unit_cache[slot].guid = guid;
|
||||
return &g_unit_cache[slot];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Other player hash table
|
||||
// =============================================================================
|
||||
|
||||
fn hashGuidSlot(guid: u64, slot: i32) u32 {
|
||||
var h: u64 = guid ^ (@as(u64, @bitCast(@as(i64, slot))) *% 2654435761);
|
||||
h ^= h >> 33;
|
||||
h *%= 0xff51afd7ed558ccd;
|
||||
h ^= h >> 33;
|
||||
return @intCast(h % OTHER_PENDING_SIZE);
|
||||
}
|
||||
|
||||
fn findOtherPendingSlot(guid: u64, slot: i32) i32 {
|
||||
const idx = hashGuidSlot(guid, slot);
|
||||
for (0..32) |probe| {
|
||||
const i: u32 = (idx + @as(u32, @intCast(probe))) % OTHER_PENDING_SIZE;
|
||||
if (!g_other_pending[i].active) return @intCast(i);
|
||||
if (g_other_pending[i].guid == guid and g_other_pending[i].slot == slot) return @intCast(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
fn findOtherPendingEntry(guid: u64, slot: i32) i32 {
|
||||
const idx = hashGuidSlot(guid, slot);
|
||||
for (0..32) |probe| {
|
||||
const i: u32 = (idx + @as(u32, @intCast(probe))) % OTHER_PENDING_SIZE;
|
||||
if (!g_other_pending[i].active) return -1;
|
||||
if (g_other_pending[i].guid == guid and g_other_pending[i].slot == slot) return @intCast(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Calling originals (inline ASM)
|
||||
// =============================================================================
|
||||
|
||||
fn callOriginalSetBlock(obj: u32, index: u32, value: u32) u32 {
|
||||
// Save/restore ECX around the call: the callee overwrites ECX internally
|
||||
// (SetBlock does `mov ecx, [ebp+0xC]`), but we can't list ECX as a clobber
|
||||
// since it's already an input constraint. Without the save/restore, the
|
||||
// compiler may reuse the now-stale ECX for `obj` on a subsequent call.
|
||||
// The trampoline's `ret 8` cleans up the pushed index+value, leaving our
|
||||
// saved ECX on top for the pop.
|
||||
return asm volatile (
|
||||
\\push %%ecx
|
||||
\\push %[value]
|
||||
\\push %[index]
|
||||
\\call *%[func]
|
||||
\\pop %%ecx
|
||||
: [ret] "={eax}" (-> u32),
|
||||
: [_] "{ecx}" (obj),
|
||||
[index] "r" (index),
|
||||
[value] "r" (value),
|
||||
[func] "r" (set_block_hook.trampoline),
|
||||
: .{ .edx = true, .memory = true, .cc = true }
|
||||
);
|
||||
}
|
||||
|
||||
fn callOriginalRefresh(unit: u32, event_data: u32, extra_data: u32, force_update: u32) void {
|
||||
// __thiscall: ECX=this, stack args right-to-left. EDX is caller-saved scratch
|
||||
// (confirmed via Ghidra: __thiscall, EDX not part of calling convention).
|
||||
// Pin force_update to EDX to stay within 3 "r" registers (EBX/ESI/EDI)
|
||||
// since EBP is the frame pointer on x86.
|
||||
asm volatile (
|
||||
\\push %[force]
|
||||
\\push %[extra]
|
||||
\\push %[event]
|
||||
\\call *%[func]
|
||||
:
|
||||
: [_] "{ecx}" (unit),
|
||||
[force] "{edx}" (force_update),
|
||||
[event] "r" (event_data),
|
||||
[extra] "r" (extra_data),
|
||||
[func] "r" (refresh_hook.trampoline),
|
||||
: .{ .eax = true, .memory = true, .cc = true }
|
||||
);
|
||||
}
|
||||
|
||||
fn callOriginalSceneEnd(device: u32) void {
|
||||
asm volatile (
|
||||
\\call *%[func]
|
||||
:
|
||||
: [_] "{ecx}" (device),
|
||||
[func] "r" (scene_end_hook.trampoline),
|
||||
: .{ .eax = true, .edx = true, .memory = true, .cc = true }
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Timeout processing
|
||||
// =============================================================================
|
||||
|
||||
fn processTimeouts(now: u32) void {
|
||||
// LOCAL PLAYER: Safety-net cleanup for stuck pending state
|
||||
if (g_local_pending_count > 0) {
|
||||
for (0..19) |slot| {
|
||||
if (g_local_pending[slot].active) {
|
||||
const elapsed = now -% g_local_pending[slot].timestamp;
|
||||
if (elapsed >= LOCAL_PLAYER_CLEANUP_MS) {
|
||||
g_local_pending[slot].active = false;
|
||||
g_local_pending[slot].has_durability = false;
|
||||
g_local_pending_count -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OTHER PLAYERS: Use timeout since we don't have their INV_SLOT info
|
||||
if (g_other_pending_count > 0 and set_block_hook.trampoline != 0) {
|
||||
const UnitSlots = struct {
|
||||
unit: u32 = 0,
|
||||
slots: [19]i32 = .{0} ** 19,
|
||||
slot_count: u32 = 0,
|
||||
};
|
||||
var units_to_update: [16]UnitSlots = [1]UnitSlots{.{}} ** 16;
|
||||
var unit_count: u32 = 0;
|
||||
const active_count = g_other_pending_count;
|
||||
var found_count: i32 = 0;
|
||||
|
||||
for (0..OTHER_PENDING_SIZE) |i| {
|
||||
if (found_count >= active_count) break;
|
||||
if (g_other_pending[i].active) {
|
||||
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
|
||||
// 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 });
|
||||
if (unit != 0 and (unit & 1) == 0) {
|
||||
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(g_other_pending[i].slot)) * VISIBLE_ITEM_STRIDE);
|
||||
_ = callOriginalSetBlock(unit, field_index, 0);
|
||||
|
||||
// Track unit for visual update
|
||||
var unit_idx: i32 = -1;
|
||||
for (0..unit_count) |j| {
|
||||
if (units_to_update[j].unit == unit) {
|
||||
unit_idx = @intCast(j);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (unit_idx < 0 and unit_count < 16) {
|
||||
unit_idx = @intCast(unit_count);
|
||||
units_to_update[unit_count].unit = unit;
|
||||
units_to_update[unit_count].slot_count = 0;
|
||||
unit_count += 1;
|
||||
}
|
||||
if (unit_idx >= 0) {
|
||||
const uidx: u32 = @intCast(unit_idx);
|
||||
if (units_to_update[uidx].slot_count < 19) {
|
||||
units_to_update[uidx].slots[units_to_update[uidx].slot_count] = g_other_pending[i].slot;
|
||||
units_to_update[uidx].slot_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g_other_pending[i].active = false;
|
||||
g_other_pending_count -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each unit: invalidate model cache and call RefreshEquipmentDisplay
|
||||
for (0..unit_count) |i| {
|
||||
const unit = units_to_update[i].unit;
|
||||
|
||||
// Get the ModelData pointer for the BOX display ID
|
||||
// CreatureDisplayInfo table: two derefs from fixed address
|
||||
// displayTable = *(u32*)0xc0de90 → READ 1
|
||||
// boxModelData = displayTable[4] → READ 2
|
||||
const display_table = hook.readMem(u32, DISPLAY_INFO_TABLE_PTR);
|
||||
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
|
||||
const dest: *u32 = @ptrFromInt(unit + UNIT_CACHED_MODELDATA_OFFSET);
|
||||
dest.* = box_model_data;
|
||||
|
||||
con.fmt("[other] REFRESH unit=0x{X:0>8} table=0x{X:0>8} boxModel=0x{X:0>8}\n", .{ unit, display_table, box_model_data });
|
||||
refreshEquipmentDisplay(unit);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to RefreshVisualAppearance
|
||||
con.fmt("[other] REFRESH fallback unit=0x{X:0>8} table=0x{X:0>8}\n", .{ unit, display_table });
|
||||
if (refresh_hook.trampoline != 0) {
|
||||
callOriginalRefresh(unit, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook 1: SetBlock (0x6142E0)
|
||||
// =============================================================================
|
||||
|
||||
fn hookSetBlock(obj: u32, _edx: u32, index: u32, value: u32) callconv(.c) u32 {
|
||||
_ = _edx;
|
||||
const val = value;
|
||||
|
||||
// VISIBLE_ITEM writes
|
||||
if (index >= PLAYER_VISIBLE_ITEM_1_0 and
|
||||
index < PLAYER_VISIBLE_ITEM_1_0 + 19 * VISIBLE_ITEM_STRIDE)
|
||||
{
|
||||
const offset = index - PLAYER_VISIBLE_ITEM_1_0;
|
||||
if (offset % VISIBLE_ITEM_STRIDE == 0) {
|
||||
const slot: usize = @intCast(offset / VISIBLE_ITEM_STRIDE);
|
||||
|
||||
if (!g_cache.valid) _ = cachePlayerState();
|
||||
|
||||
const now = GetTickCount();
|
||||
|
||||
if (g_enabled and isLocalPlayerObject(obj)) {
|
||||
// =========== LOCAL PLAYER ===========
|
||||
if (!g_cache.valid or g_cache.player_obj != obj) {
|
||||
_ = cachePlayerState();
|
||||
}
|
||||
if (val == 0 and g_cached_visible_item[slot] != 0) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Start transmog pattern tracking
|
||||
if (!g_local_pending[slot].active) g_local_pending_count += 1;
|
||||
g_local_pending[slot].original_visible_item = g_cached_visible_item[slot];
|
||||
g_local_pending[slot].timestamp = now;
|
||||
g_local_pending[slot].active = true;
|
||||
g_local_pending[slot].has_durability = false;
|
||||
con.fmt("[local] BLOCK clear slot={d:2} item=0x{X:0>8}\n", .{ slot, g_cached_visible_item[slot] });
|
||||
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
|
||||
|
||||
if (g_local_pending[slot].has_durability) {
|
||||
const dur = g_local_pending[slot].captured_durability;
|
||||
if (dur != 0) {
|
||||
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
|
||||
con.fmt("[local] PASS broken slot={d:2}\n", .{slot});
|
||||
g_local_pending[slot].active = false;
|
||||
g_local_pending[slot].has_durability = false;
|
||||
g_local_pending_count -= 1;
|
||||
return callOriginalSetBlock(obj, index, value);
|
||||
}
|
||||
}
|
||||
g_local_pending[slot].active = false;
|
||||
g_local_pending[slot].has_durability = false;
|
||||
g_local_pending_count -= 1;
|
||||
|
||||
updateInventoryAlertStates();
|
||||
|
||||
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
|
||||
g_local_pending[slot].active = false;
|
||||
g_local_pending[slot].has_durability = false;
|
||||
g_local_pending_count -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache for non-blocked writes
|
||||
if (val != 0) {
|
||||
g_cached_visible_item[slot] = val;
|
||||
}
|
||||
} else if (g_enabled) {
|
||||
// =========== OTHER PLAYERS ===========
|
||||
const guid = getUnitGuid(obj);
|
||||
if (guid != 0 and isPlayerGuid(guid)) {
|
||||
const idx = findOtherPendingEntry(guid, @intCast(slot));
|
||||
|
||||
if (val == 0) {
|
||||
// CLEAR detected
|
||||
const current_val = readUnitVisibleItem(obj, slot);
|
||||
if (current_val != 0) {
|
||||
const new_idx = findOtherPendingSlot(guid, @intCast(slot));
|
||||
if (new_idx >= 0) {
|
||||
const ni: u32 = @intCast(new_idx);
|
||||
if (!g_other_pending[ni].active) {
|
||||
g_other_pending_count += 1;
|
||||
}
|
||||
g_other_pending[ni].guid = guid;
|
||||
g_other_pending[ni].slot = @intCast(slot);
|
||||
g_other_pending[ni].timestamp = now;
|
||||
g_other_pending[ni].unit_ptr = obj;
|
||||
g_other_pending[ni].active = true;
|
||||
con.fmt("[other] BLOCK clear slot={d:2} guid=0x{X:0>16}\n", .{ slot, guid });
|
||||
return 1; // Block the clear
|
||||
}
|
||||
}
|
||||
} else if (idx >= 0 and g_other_pending[@intCast(idx)].active) {
|
||||
const ui: u32 = @intCast(idx);
|
||||
// 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) {
|
||||
g_other_pending[ui].active = false;
|
||||
g_other_pending_count -= 1;
|
||||
con.fmt("[other] BLOCK restore slot={d:2} guid=0x{X:0>16} {d}ms -- coalesced!\n", .{ slot, guid, elapsed });
|
||||
return 1; // Block the restore
|
||||
} else {
|
||||
g_other_pending[ui].active = false;
|
||||
g_other_pending_count -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DURABILITY writes — capture for pending local player slots
|
||||
if (g_enabled and index == ITEM_FIELD_DURABILITY) {
|
||||
const slot = findSlotForItemObject(obj);
|
||||
if (slot >= 0) {
|
||||
const s: usize = @intCast(slot);
|
||||
if (g_local_pending[s].active) {
|
||||
g_local_pending[s].captured_durability = val;
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// INV_SLOT writes — detect gear changes and update cache
|
||||
if (index >= PLAYER_FIELD_INV_SLOT_HEAD and
|
||||
index < PLAYER_FIELD_INV_SLOT_HEAD + 48)
|
||||
{
|
||||
const offset = index - PLAYER_FIELD_INV_SLOT_HEAD;
|
||||
const inv_index: i32 = @intCast(offset / 2);
|
||||
const is_low_word = (offset % 2 == 0);
|
||||
const equip_slot = inv_index - 5;
|
||||
|
||||
if (g_enabled and isLocalPlayerObject(obj) and equip_slot >= 0 and equip_slot < 19) {
|
||||
const es: usize = @intCast(equip_slot);
|
||||
if (g_cache.valid) {
|
||||
if (is_low_word) {
|
||||
const current = g_cache.equipped_guids[es];
|
||||
g_cache.equipped_guids[es] = (current & 0xFFFFFFFF00000000) | val;
|
||||
} else {
|
||||
const current = g_cache.equipped_guids[es];
|
||||
g_cache.equipped_guids[es] = (current & 0x00000000FFFFFFFF) | (@as(u64, val) << 32);
|
||||
}
|
||||
}
|
||||
|
||||
// Low word clear with pending VISIBLE_ITEM block = REAL UNEQUIP
|
||||
if (is_low_word and val == 0 and g_local_pending[es].active) {
|
||||
con.fmt("[local] REAL UNEQUIP slot={d:2} -- replaying blocked clear\n", .{es});
|
||||
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(es)) * VISIBLE_ITEM_STRIDE);
|
||||
_ = callOriginalSetBlock(obj, field_index, 0);
|
||||
|
||||
g_cached_visible_item[es] = 0;
|
||||
g_local_pending[es].active = false;
|
||||
g_local_pending[es].has_durability = false;
|
||||
g_local_pending_count -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return callOriginalSetBlock(obj, index, value);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook 2: RefreshVisualAppearance (0x5fb880)
|
||||
// =============================================================================
|
||||
|
||||
fn hookRefreshVisualAppearance(unit: u32, _edx: u32, event_data: u32, extra_data: u32, force_update: u32) callconv(.c) void {
|
||||
_ = _edx;
|
||||
|
||||
if (!g_enabled or refresh_hook.trampoline == 0) {
|
||||
if (refresh_hook.trampoline != 0) {
|
||||
callOriginalRefresh(unit, event_data, extra_data, force_update);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const guid = getUnitGuid(unit);
|
||||
if (guid == 0 or !isPlayerGuid(guid)) {
|
||||
callOriginalRefresh(unit, event_data, extra_data, force_update);
|
||||
return;
|
||||
}
|
||||
|
||||
// LOCAL PLAYER: If we have pending SetBlock blocks, skip expensive refresh
|
||||
if (g_cache.valid and guid == g_cache.local_guid and g_local_pending_count > 0) {
|
||||
con.fmt("[local] SKIP RefreshVisualAppearance (pending={d})\n", .{g_local_pending_count});
|
||||
refreshAppearanceAndEquipment(unit);
|
||||
const flags1: *u32 = @ptrFromInt(unit + 0xccc);
|
||||
const flags2: *u32 = @ptrFromInt(unit + 0xcd0);
|
||||
flags1.* = 1;
|
||||
flags2.* = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = GetTickCount();
|
||||
|
||||
var current_items: [19]u32 = undefined;
|
||||
readVisibleItems(unit, ¤t_items);
|
||||
|
||||
const state = getUnitCache(guid, true) orelse {
|
||||
callOriginalRefresh(unit, event_data, extra_data, force_update);
|
||||
return;
|
||||
};
|
||||
state.last_seen = now;
|
||||
|
||||
// Analyze changes
|
||||
var cleared_slots: i32 = 0;
|
||||
var restored_slots: i32 = 0;
|
||||
var all_restores_within_timeout = true;
|
||||
|
||||
for (0..19) |s| {
|
||||
const cached = state.visible_items[s];
|
||||
const current = current_items[s];
|
||||
|
||||
if (cached != current) {
|
||||
if (current == 0 and cached != 0) {
|
||||
cleared_slots += 1;
|
||||
state.clear_timestamp[s] = now;
|
||||
state.has_pending_clear = true;
|
||||
} else if (current != 0 and cached == 0 and state.clear_timestamp[s] != 0) {
|
||||
const elapsed = now -% state.clear_timestamp[s];
|
||||
if (elapsed < OTHER_PLAYER_TIMEOUT_MS) {
|
||||
restored_slots += 1;
|
||||
} else {
|
||||
all_restores_within_timeout = false;
|
||||
}
|
||||
state.clear_timestamp[s] = 0;
|
||||
} else if (current != 0 and cached == 0) {
|
||||
all_restores_within_timeout = false;
|
||||
} else {
|
||||
all_restores_within_timeout = false;
|
||||
state.clear_timestamp[s] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
state.visible_items = current_items;
|
||||
|
||||
// Update hasPendingClear
|
||||
state.has_pending_clear = false;
|
||||
for (0..19) |s| {
|
||||
if (state.clear_timestamp[s] != 0) {
|
||||
const elapsed = now -% state.clear_timestamp[s];
|
||||
if (elapsed >= OTHER_PLAYER_TIMEOUT_MS) {
|
||||
state.clear_timestamp[s] = 0;
|
||||
} else {
|
||||
state.has_pending_clear = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const should_skip = (restored_slots > 0) and (cleared_slots == 0) and all_restores_within_timeout;
|
||||
|
||||
if (should_skip) {
|
||||
con.fmt("[other] SKIP RefreshVisualAppearance restored={d} guid=0x{X:0>16}\n", .{ restored_slots, guid });
|
||||
refreshAppearanceAndEquipment(unit);
|
||||
const flags1: *u32 = @ptrFromInt(unit + 0xccc);
|
||||
const flags2: *u32 = @ptrFromInt(unit + 0xcd0);
|
||||
flags1.* = 1;
|
||||
flags2.* = 1;
|
||||
|
||||
if (g_cache.valid and guid == g_cache.local_guid) {
|
||||
updateInventoryAlertStates();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
callOriginalRefresh(unit, event_data, extra_data, force_update);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook 3: SceneEnd (0x5a17a0)
|
||||
// =============================================================================
|
||||
|
||||
fn hookSceneEnd(device: u32, _edx: u32) callconv(.c) void {
|
||||
_ = _edx;
|
||||
|
||||
if (g_enabled and (g_local_pending_count > 0 or g_other_pending_count > 0)) {
|
||||
processTimeouts(GetTickCount());
|
||||
}
|
||||
|
||||
callOriginalSceneEnd(device);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Init / Cleanup
|
||||
// =============================================================================
|
||||
|
||||
pub fn installHooks() bool {
|
||||
con.print("[transmogfix] 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\\TransmogCoalesceHook_{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;
|
||||
g_initialized = true;
|
||||
con.print("[transmogfix] Another DLL owns hooks (mutex taken), skipping\n");
|
||||
return true; // Success but not owner — no hooks
|
||||
}
|
||||
g_is_hook_owner = true;
|
||||
|
||||
// Initialize state (already zero-initialized by Zig defaults)
|
||||
g_local_pending = [1]LocalPending{.{}} ** 19;
|
||||
g_local_pending_count = 0;
|
||||
g_other_pending = [1]OtherPending{.{}} ** OTHER_PENDING_SIZE;
|
||||
g_other_pending_count = 0;
|
||||
g_cache = .{};
|
||||
g_unit_cache = [1]UnitVisualState{.{}} ** UNIT_CACHE_SIZE;
|
||||
g_cached_visible_item = .{0} ** 19;
|
||||
|
||||
// Hook 1: SetBlock (6 bytes, no fixups)
|
||||
if (!set_block_hook.prepare(ADDR_SetBlock, 6, &.{})) return false;
|
||||
const sb_thunk = set_block_hook.mem.? + 32;
|
||||
_ = hook.buildFastcallToCdeclThunk(sb_thunk, @intFromPtr(&hookSetBlock), 2);
|
||||
set_block_hook.activate(@intFromPtr(sb_thunk));
|
||||
|
||||
// Hook 2: RefreshVisualAppearance (6 bytes, no fixups)
|
||||
if (!refresh_hook.prepare(ADDR_RefreshVisualAppearance, 6, &.{})) {
|
||||
set_block_hook.remove();
|
||||
return false;
|
||||
}
|
||||
const rv_thunk = refresh_hook.mem.? + 32;
|
||||
_ = hook.buildFastcallToCdeclThunk(rv_thunk, @intFromPtr(&hookRefreshVisualAppearance), 3);
|
||||
refresh_hook.activate(@intFromPtr(rv_thunk));
|
||||
|
||||
// Hook 3: SceneEnd (9 bytes, no fixups)
|
||||
if (!scene_end_hook.prepare(ADDR_SceneEnd, 9, &.{})) {
|
||||
refresh_hook.remove();
|
||||
set_block_hook.remove();
|
||||
return false;
|
||||
}
|
||||
const se_thunk = scene_end_hook.mem.? + 32;
|
||||
_ = hook.buildFastcallToCdeclThunk(se_thunk, @intFromPtr(&hookSceneEnd), 0);
|
||||
scene_end_hook.activate(@intFromPtr(se_thunk));
|
||||
|
||||
g_initialized = true;
|
||||
con.print("[transmogfix] All 3 hooks installed\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn removeHooks() void {
|
||||
if (g_is_hook_owner) {
|
||||
scene_end_hook.remove();
|
||||
refresh_hook.remove();
|
||||
set_block_hook.remove();
|
||||
}
|
||||
|
||||
if (g_is_hook_owner) {
|
||||
if (g_mutex) |m| {
|
||||
_ = ReleaseMutex(m);
|
||||
_ = CloseHandle(m);
|
||||
g_mutex = null;
|
||||
}
|
||||
}
|
||||
|
||||
g_initialized = false;
|
||||
g_is_hook_owner = false;
|
||||
}
|
||||
Reference in New Issue
Block a user