Module control API: - Three cdecl exports: WeirdUtils_IsModuleActive, WeirdUtils_DisableModule, WeirdUtils_DisableAll - C header include/weirdutils_api.h for runtime DLL discovery - All modules gain pub module_name, isActive(), shared mutex via src/mutex.zig - build.zig refactored to module_list array of ModuleDesc Bigcursor: - D3D9 vtable hooks on SetCursorProperties/ShowCursor - Scale2x/Scale3x pixel-art upscalers in pure Zig (replaces 12K-line hqx C) - Bilinear resampler for fractional scales (1.0-4.0, default 1.2) - Win32 cursor via CreateIconIndirect bypasses D3D9 32x32 limit - FNV-1a hash cache for ~16 cursor bitmaps - Lua API: SetCursorScale(n) / GetCursorScale() - CVar cursorScale for Config.wtf persistence (tenths) Other: - Add dpslog module stub - Docs and README updates
320 lines
11 KiB
Zig
320 lines
11 KiB
Zig
// =============================================================================
|
|
// customassets - 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("zhook");
|
|
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
|
|
|
|
const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
|
|
const CheckFileExistenceFn = fn (u32, u32, u32) callconv(fc) u32;
|
|
|
|
var cfe_hook: hook.Detour(CheckFileExistenceFn) = .{};
|
|
|
|
fn checkFileExistenceDetour(filename_ptr: u32, flags: u32, output_buffer_ptr: u32) callconv(fc) u32 {
|
|
if (filename_ptr != 0) {
|
|
if (looseFilesLookup(filename_ptr)) |disk_path| {
|
|
const raw: [*]const u8 = @ptrFromInt(filename_ptr);
|
|
con.fmt("[customassets] loose hit: \"{s}\"\n", .{raw[0..cStrLen(raw)]});
|
|
|
|
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 cfe_hook.callOriginal(.{ filename_ptr, flags, output_buffer_ptr });
|
|
}
|
|
|
|
fn installHook() bool {
|
|
return cfe_hook.attach(0x654DD0, &checkFileExistenceDetour) == .ok;
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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
|
|
// =============================================================================
|
|
|
|
const mod_mutex = @import("../mutex.zig");
|
|
|
|
pub const module_name: [*:0]const u8 = "customassets";
|
|
|
|
var installed: bool = false;
|
|
var g_mutex: ?*anyopaque = null;
|
|
var g_is_hook_owner: bool = false;
|
|
|
|
pub fn isActive() bool {
|
|
return g_is_hook_owner;
|
|
}
|
|
|
|
pub fn installHooks() void {
|
|
con.print("[customassets] Module loaded\n");
|
|
|
|
const result = mod_mutex.acquire(module_name);
|
|
g_mutex = result.handle;
|
|
g_is_hook_owner = result.is_owner;
|
|
if (!g_is_hook_owner) return;
|
|
|
|
applyGlobPatch();
|
|
applyLooseFilePatches();
|
|
looseFilesInit();
|
|
if (!installHook()) return;
|
|
installed = true;
|
|
}
|
|
|
|
pub fn removeHooks() void {
|
|
if (g_is_hook_owner and installed) {
|
|
cfe_hook.detach();
|
|
revertLooseFilePatches();
|
|
revertGlobPatch();
|
|
looseFilesCleanup();
|
|
installed = false;
|
|
}
|
|
|
|
if (g_is_hook_owner) {
|
|
mod_mutex.release(&g_mutex);
|
|
}
|
|
g_is_hook_owner = false;
|
|
}
|