Files
WeirdUtils/src/customassets/customassets.zig
T
MarcelineVQ 2aaa089c76 Add module control API, bigcursor with fractional scaling, shared mutex
Module control API:
- Three cdecl exports: WeirdUtils_IsModuleActive, WeirdUtils_DisableModule, WeirdUtils_DisableAll
- C header include/weirdutils_api.h for runtime DLL discovery
- All modules gain pub module_name, isActive(), shared mutex via src/mutex.zig
- build.zig refactored to module_list array of ModuleDesc

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

Other:
- Add dpslog module stub
- Docs and README updates
2026-03-06 13:27:35 -08:00

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;
}