From b7f293c8c5c584ee59043ea9935c585de6bf983a Mon Sep 17 00:00:00 2001 From: MarcelineVQ Date: Mon, 23 Feb 2026 16:02:55 -0800 Subject: [PATCH] Initial commit: WeirdUtils DLL for WoW 1.12.1 Lua protection bypass, embedded addon loading, async PNG screenshots with self-contained deflate compressor (store + fixed Huffman). 15KB ReleaseSmall. --- .gitignore | 4 + build.zig | 30 +++ build.zig.zon | 15 ++ libs/hook/build.zig | 18 ++ libs/hook/build.zig.zon | 10 + libs/hook/src/hook.zig | 470 +++++++++++++++++++++++++++++++++++++++ src/addon/WeirdUtils.lua | 46 ++++ src/addon/WeirdUtils.toc | 5 + src/main.zig | 467 ++++++++++++++++++++++++++++++++++++++ src/png.zig | 404 +++++++++++++++++++++++++++++++++ src/screenshot.zig | 345 ++++++++++++++++++++++++++++ 11 files changed, 1814 insertions(+) create mode 100644 .gitignore create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 libs/hook/build.zig create mode 100644 libs/hook/build.zig.zon create mode 100644 libs/hook/src/hook.zig create mode 100644 src/addon/WeirdUtils.lua create mode 100644 src/addon/WeirdUtils.toc create mode 100644 src/main.zig create mode 100644 src/png.zig create mode 100644 src/screenshot.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70553e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.zig-cache/ +zig-out/ +reference/ +research/ diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..bd5d3fd --- /dev/null +++ b/build.zig @@ -0,0 +1,30 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.resolveTargetQuery(.{ + .cpu_arch = .x86, + .os_tag = .windows, + .abi = .msvc, + }); + const optimize = b.standardOptimizeOption(.{}); + + const hook_mod = b.dependency("hook", .{ + .target = target, + .optimize = optimize, + }).module("hook"); + + const lib = b.addLibrary(.{ + .name = "weirdutils", + .linkage = .dynamic, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "hook", .module = hook_mod }, + }, + }), + }); + + b.installArtifact(lib); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..01ce0f3 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,15 @@ +.{ + .name = .weirdutils, + .version = "0.1.0", + .fingerprint = 0x54f0a9542562d318, + .dependencies = .{ + .hook = .{ + .path = "libs/hook", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/libs/hook/build.zig b/libs/hook/build.zig new file mode 100644 index 0000000..c4ea0e5 --- /dev/null +++ b/libs/hook/build.zig @@ -0,0 +1,18 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const hwbp = b.option(bool, "hwbp", "Use hardware breakpoint hooks instead of inline patching") orelse false; + + const options = b.addOptions(); + options.addOption(bool, "use_hwbp", hwbp); + + const hook_mod = b.addModule("hook", .{ + .root_source_file = b.path("src/hook.zig"), + .target = target, + .optimize = optimize, + }); + hook_mod.addOptions("config", options); +} diff --git a/libs/hook/build.zig.zon b/libs/hook/build.zig.zon new file mode 100644 index 0000000..dac90b2 --- /dev/null +++ b/libs/hook/build.zig.zon @@ -0,0 +1,10 @@ +.{ + .name = .hook, + .version = "0.1.0", + .fingerprint = 0xa4584355bc807307, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/libs/hook/src/hook.zig b/libs/hook/src/hook.zig new file mode 100644 index 0000000..20905cf --- /dev/null +++ b/libs/hook/src/hook.zig @@ -0,0 +1,470 @@ +//! x86 inline hooking library for Windows DLL injection. +//! +//! Provides a `Hook` struct for patching function prologues with JMP detours, +//! building trampolines to call the original, and chaining with other hooks. +//! Also includes memory read/write helpers, rel32 arithmetic, a generic +//! `fastcall` caller, and a fastcall-to-cdecl thunk builder. +//! +//! ## Quick start +//! +//! ```zig +//! const hook = @import("hook.zig"); +//! +//! var my_hook = hook.Hook{}; +//! +//! // One-shot: prepare trampoline + patch in one call. +//! // The last arg is a slice of opcode offsets within the prologue that +//! // contain E8/E9 (CALL/JMP rel32) instructions needing fixup. +//! _ = my_hook.install(target_addr, prologue_size, @intFromPtr(&detour), &.{1}); +//! +//! // Two-phase (when you need the alloc block before patching, e.g. for a thunk): +//! _ = my_hook.prepare(target_addr, prologue_size, &.{}); +//! const thunk_buf = my_hook.mem.? + 32; +//! _ = hook.buildFastcallToCdeclThunk(thunk_buf, @intFromPtr(&hookImpl), 1); +//! my_hook.activate(@intFromPtr(thunk_buf)); +//! +//! // Call the original from inside the detour: +//! const orig = my_hook.getTrampoline(*const fn () callconv(.{ .x86_stdcall = .{} }) void); +//! orig(); +//! +//! // Unhook (restore original bytes, free memory): +//! my_hook.remove(); +//! ``` + +const std = @import("std"); +const use_hwbp = @import("config").use_hwbp; + +// ============================================================================= +// Windows API +// ============================================================================= + +const WINAPI = std.builtin.CallingConvention.winapi; + +pub const PAGE_EXECUTE_READWRITE: u32 = 0x40; +pub const MEM_COMMIT: u32 = 0x1000; +pub const MEM_RELEASE: u32 = 0x8000; + +extern "kernel32" fn VirtualProtect( + lpAddress: *anyopaque, + dwSize: usize, + flNewProtect: u32, + lpflOldProtect: *u32, +) callconv(WINAPI) i32; + +extern "kernel32" fn VirtualAlloc( + lpAddress: ?*anyopaque, + dwSize: usize, + flAllocationType: u32, + flProtect: u32, +) callconv(WINAPI) ?[*]u8; + +extern "kernel32" fn VirtualFree( + lpAddress: *anyopaque, + dwSize: usize, + dwFreeType: u32, +) callconv(WINAPI) i32; + +// ============================================================================= +// Hardware breakpoint support (DR0-DR3 + Vectored Exception Handler) +// ============================================================================= + +const CONTEXT_DEBUG_REGISTERS: u32 = 0x00010010; +const EXCEPTION_SINGLE_STEP: u32 = 0x80000004; +const EXCEPTION_CONTINUE_EXECUTION: i32 = -1; +const EXCEPTION_CONTINUE_SEARCH: i32 = 0; + +extern "kernel32" fn GetCurrentThread() callconv(WINAPI) *anyopaque; + +extern "kernel32" fn GetThreadContext( + hThread: *anyopaque, + lpContext: *CONTEXT, +) callconv(WINAPI) i32; + +extern "kernel32" fn SetThreadContext( + hThread: *anyopaque, + lpContext: *const CONTEXT, +) callconv(WINAPI) i32; + +extern "kernel32" fn AddVectoredExceptionHandler( + First: u32, + Handler: *const fn (*EXCEPTION_POINTERS) callconv(WINAPI) i32, +) callconv(WINAPI) ?*anyopaque; + +extern "kernel32" fn RemoveVectoredExceptionHandler( + Handle: *anyopaque, +) callconv(WINAPI) u32; + +const CONTEXT = extern struct { + ContextFlags: u32, + Dr0: u32, + Dr1: u32, + Dr2: u32, + Dr3: u32, + Dr6: u32, + Dr7: u32, + FloatSave: [112]u8, + SegGs: u32, + SegFs: u32, + SegEs: u32, + SegDs: u32, + Edi: u32, + Esi: u32, + Ebx: u32, + Edx: u32, + Ecx: u32, + Eax: u32, + Ebp: u32, + Eip: u32, + SegCs: u32, + EFlags: u32, + Esp: u32, + SegSs: u32, + ExtendedRegisters: [512]u8, +}; + +const EXCEPTION_RECORD = extern struct { + ExceptionCode: u32, + ExceptionFlags: u32, + ExceptionRecord: ?*EXCEPTION_RECORD, + ExceptionAddress: ?*anyopaque, + NumberParameters: u32, + ExceptionInformation: [15]usize, +}; + +const EXCEPTION_POINTERS = extern struct { + ExceptionRecord: *EXCEPTION_RECORD, + ContextRecord: *CONTEXT, +}; + +var hwbp_slots: [4]?*Hook = .{ null, null, null, null }; +var hwbp_detours: [4]usize = .{ 0, 0, 0, 0 }; +var veh_handle: ?*anyopaque = null; + +fn vehHandler(info: *EXCEPTION_POINTERS) callconv(WINAPI) i32 { + if (info.ExceptionRecord.ExceptionCode != EXCEPTION_SINGLE_STEP) + return EXCEPTION_CONTINUE_SEARCH; + + const eip = info.ContextRecord.Eip; + for (0..4) |i| { + if (hwbp_slots[i]) |h| { + if (h.target == eip) { + info.ContextRecord.Eip = @intCast(hwbp_detours[i]); + return EXCEPTION_CONTINUE_EXECUTION; + } + } + } + + return EXCEPTION_CONTINUE_SEARCH; +} + +// ============================================================================= +// Memory helpers +// ============================================================================= + +/// Read a value of type `T` from an arbitrary memory address (unaligned). +pub fn readMem(comptime T: type, addr: usize) T { + return @as(*align(1) const T, @ptrFromInt(addr)).*; +} + +/// Write raw bytes to an arbitrary memory address. No protection change — +/// caller must ensure the page is writable (or use `writeProtected`). +pub fn writeMem(addr: usize, bytes: []const u8) void { + const dest: [*]u8 = @ptrFromInt(addr); + for (bytes, 0..) |b, i| { + dest[i] = b; + } +} + +/// Write bytes to a potentially read-only/executable page. Temporarily sets +/// PAGE_EXECUTE_READWRITE, writes, then restores the original protection. +pub fn writeProtected(addr: usize, bytes: []const u8) void { + var old: u32 = 0; + _ = VirtualProtect(@ptrFromInt(addr), bytes.len, PAGE_EXECUTE_READWRITE, &old); + writeMem(addr, bytes); + _ = VirtualProtect(@ptrFromInt(addr), bytes.len, old, &old); +} + +// ============================================================================= +// Rel32 helpers +// ============================================================================= + +/// Resolve the absolute target of an E8 (CALL) or E9 (JMP) at `addr`. +/// Reads the signed rel32 operand at addr+1 and computes addr+5+offset. +pub fn rel32Target(addr: usize) usize { + const offset: u32 = @bitCast(@as(*align(1) const i32, @ptrFromInt(addr + 1)).*); + return (addr + 5) +% offset; +} + +/// Write a rel32 displacement into dest[0..4] such that a JMP/CALL +/// from address `from` reaches `to`. Displacement = to - (from + 4). +pub fn writeRel32(dest: [*]u8, from: usize, to: usize) void { + std.mem.writeInt(u32, dest[0..4], to -% (from + 4), .little); +} + +// ============================================================================= +// Calling convention helper +// ============================================================================= + +/// Call a __fastcall function at `addr` with ECX and EDX arguments. +/// Dispatches on return type: void, f64 (x87 ST0), or integer/pointer (EAX). +pub fn fastcall(comptime R: type, addr: usize, ecx: anytype, edx: anytype) R { + if (R == f64) { + return asm volatile ("call *%[func]" + : [ret] "={st}" (-> f64), + : [_] "{ecx}" (ecx), [_] "{edx}" (edx), [func] "r" (addr), + : .{ .eax = true, .memory = true, .cc = true } + ); + } else if (R == void) { + asm volatile ("call *%[func]" + : + : [_] "{ecx}" (ecx), [_] "{edx}" (edx), [func] "r" (addr), + : .{ .eax = true, .memory = true, .cc = true } + ); + } else { + return asm volatile ("call *%[func]" + : [ret] "={eax}" (-> R), + : [_] "{ecx}" (ecx), [_] "{edx}" (edx), [func] "r" (addr), + : .{ .memory = true, .cc = true } + ); + } +} + +// ============================================================================= +// Hook struct +// ============================================================================= + +const ALLOC_SIZE: usize = 64; +const TRAMPOLINE_RESERVE: usize = 32; +const MAX_PROLOGUE: usize = 16; + +pub const Hook = struct { + mem: ?[*]u8 = null, + trampoline: usize = 0, + target: usize = 0, + prologue_size: usize = 0, + saved_bytes: [MAX_PROLOGUE]u8 = undefined, + + /// Allocate executable memory, save current bytes at target, and build the + /// trampoline. Does NOT patch the target yet — call activate() after. + /// `rel32_fixups` is a slice of opcode offsets within the prologue that + /// contain E8/E9 instructions needing rel32 adjustment. + pub fn prepare( + self: *Hook, + target: usize, + prologue_size: usize, + rel32_fixups: []const usize, + ) bool { + if (self.mem != null) return true; + + const mem = VirtualAlloc(null, ALLOC_SIZE, MEM_COMMIT, PAGE_EXECUTE_READWRITE) orelse return false; + self.mem = mem; + self.target = target; + self.prologue_size = prologue_size; + + // Save current bytes for remove() + const src: [*]const u8 = @ptrFromInt(target); + @memcpy(self.saved_bytes[0..prologue_size], src[0..prologue_size]); + + // Build trampoline + self.trampoline = @intFromPtr(mem); + + if (src[0] == 0xE9) { + // Another DLL already hooked — resolve their JMP and chain through it + const other_detour = rel32Target(target); + mem[0] = 0xE9; + writeRel32(mem + 1, self.trampoline + 1, other_detour); + } else { + // Original prologue — copy bytes, fix up any relative instructions, JMP back + @memcpy(mem[0..prologue_size], src[0..prologue_size]); + + for (rel32_fixups) |opcode_offset| { + const abs_target = rel32Target(target + opcode_offset); + const tramp_operand = self.trampoline + opcode_offset + 1; + writeRel32(mem + opcode_offset + 1, tramp_operand, abs_target); + } + + mem[prologue_size] = 0xE9; + writeRel32( + mem + prologue_size + 1, + self.trampoline + prologue_size + 1, + target + prologue_size, + ); + } + + return true; + } + + /// Write the E9 JMP patch (inline mode) or set a hardware breakpoint + /// (HWBP mode) to redirect target → detour_addr. + pub fn activate(self: *Hook, detour_addr: usize) void { + if (use_hwbp) { + // Register VEH on first use + if (veh_handle == null) { + veh_handle = AddVectoredExceptionHandler(1, &vehHandler); + } + // Find free DR slot + const slot: u2 = for (0..4) |i| { + if (hwbp_slots[i] == null) break @as(u2, @intCast(i)); + } else return; // all 4 slots occupied + + hwbp_slots[slot] = self; + hwbp_detours[slot] = detour_addr; + + const thread = GetCurrentThread(); + var ctx: CONTEXT = std.mem.zeroes(CONTEXT); + ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS; + _ = GetThreadContext(thread, &ctx); + + // Set DRn to target address + switch (slot) { + 0 => { ctx.Dr0 = @intCast(self.target); }, + 1 => { ctx.Dr1 = @intCast(self.target); }, + 2 => { ctx.Dr2 = @intCast(self.target); }, + 3 => { ctx.Dr3 = @intCast(self.target); }, + } + + // Enable local execute breakpoint in DR7 + const s: u5 = slot; + ctx.Dr7 |= @as(u32, 1) << (s * 2); + ctx.Dr7 &= ~(@as(u32, 0xF) << (16 + s * 4)); + + _ = SetThreadContext(thread, &ctx); + } else { + var patch: [MAX_PROLOGUE]u8 = .{0x90} ** MAX_PROLOGUE; + patch[0] = 0xE9; + writeRel32(patch[1..5], self.target + 1, detour_addr); + writeProtected(self.target, patch[0..self.prologue_size]); + } + } + + /// Convenience: prepare + activate in one call. + pub fn install( + self: *Hook, + target: usize, + prologue_size: usize, + detour_addr: usize, + rel32_fixups: []const usize, + ) bool { + if (!self.prepare(target, prologue_size, rel32_fixups)) return false; + self.activate(detour_addr); + return true; + } + + /// Restore original bytes (inline) or clear the DR slot (HWBP), then free + /// the trampoline memory. + pub fn remove(self: *Hook) void { + if (self.mem == null) return; + + if (use_hwbp) { + for (0..4) |i| { + if (hwbp_slots[i]) |h| { + if (h == self) { + const thread = GetCurrentThread(); + var ctx: CONTEXT = std.mem.zeroes(CONTEXT); + ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS; + _ = GetThreadContext(thread, &ctx); + + switch (@as(u2, @intCast(i))) { + 0 => { ctx.Dr0 = 0; }, + 1 => { ctx.Dr1 = 0; }, + 2 => { ctx.Dr2 = 0; }, + 3 => { ctx.Dr3 = 0; }, + } + const s: u5 = @intCast(i); + ctx.Dr7 &= ~(@as(u32, 1) << (s * 2)); + ctx.Dr7 &= ~(@as(u32, 0xF) << (16 + s * 4)); + + _ = SetThreadContext(thread, &ctx); + + hwbp_slots[i] = null; + hwbp_detours[i] = 0; + break; + } + } + } + } else { + writeProtected(self.target, self.saved_bytes[0..self.prologue_size]); + } + + _ = VirtualFree(@ptrFromInt(@intFromPtr(self.mem.?)), 0, MEM_RELEASE); + self.mem = null; + } + + /// Cast trampoline address to a typed function pointer for calling the original. + pub fn getTrampoline(self: *const Hook, comptime T: type) T { + return @ptrFromInt(self.trampoline); + } +}; + +// ============================================================================= +// Thunk builder: fastcall(ECX, EDX, stack...) → cdecl(stack, stack, stack...) +// ============================================================================= + +/// Build a fastcall-to-cdecl bridge thunk in `buf`. +/// `cdecl_fn` is the address of the cdecl target function. +/// `stack_arg_count` is the number of extra stack arguments beyond ECX/EDX. +/// Returns the total thunk size in bytes. +/// +/// Generated code: +/// push dword [esp + 4*N] ; for each stack arg, right-to-left +/// ... +/// push edx ; arg 2 +/// push ecx ; arg 1 +/// mov eax, +/// call eax +/// add esp, (2 + stack_arg_count) * 4 +/// ret (stack_arg_count * 4) +pub fn buildFastcallToCdeclThunk(buf: [*]u8, cdecl_fn: usize, stack_arg_count: u8) usize { + var pos: usize = 0; + + // Push stack args right-to-left. At entry, [esp] = return addr, + // [esp+4] = first stack arg, [esp+8] = second, etc. + // But each push shifts esp, so we always read from [esp + 4 * stack_arg_count] + // (the offset stays constant because we push the same number of times as the depth grows). + var i: u8 = stack_arg_count; + while (i > 0) : (i -= 1) { + // push dword ptr [esp + 4 * stack_arg_count] + buf[pos] = 0xFF; + buf[pos + 1] = 0x74; + buf[pos + 2] = 0x24; + buf[pos + 3] = stack_arg_count * 4; + pos += 4; + } + + // push edx (arg 2) + buf[pos] = 0x52; + pos += 1; + + // push ecx (arg 1) + buf[pos] = 0x51; + pos += 1; + + // mov eax, + buf[pos] = 0xB8; + std.mem.writeInt(u32, buf[pos + 1 ..][0..4], @intCast(cdecl_fn), .little); + pos += 5; + + // call eax + buf[pos] = 0xFF; + buf[pos + 1] = 0xD0; + pos += 2; + + // add esp, (2 + stack_arg_count) * 4 (cdecl caller cleanup) + const cleanup: u8 = (2 + stack_arg_count) * 4; + buf[pos] = 0x83; + buf[pos + 1] = 0xC4; + buf[pos + 2] = cleanup; + pos += 3; + + // ret (stack_arg_count * 4) (fastcall callee cleans stack args) + if (stack_arg_count == 0) { + buf[pos] = 0xC3; // ret + pos += 1; + } else { + buf[pos] = 0xC2; // ret imm16 + std.mem.writeInt(u16, buf[pos + 1 ..][0..2], @as(u16, stack_arg_count) * 4, .little); + pos += 3; + } + + return pos; +} diff --git a/src/addon/WeirdUtils.lua b/src/addon/WeirdUtils.lua new file mode 100644 index 0000000..9ffdf51 --- /dev/null +++ b/src/addon/WeirdUtils.lua @@ -0,0 +1,46 @@ +-- WeirdUtils addon (embedded in DLL, loaded from memory) + +WEIRDUTILS_VERSION = 1 + +local frame = CreateFrame("Frame") +frame:RegisterEvent("ADDON_LOADED") +frame:RegisterEvent("PLAYER_LOGIN") + +frame:SetScript("OnEvent", function() + if event == "PLAYER_LOGIN" then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00WeirdUtils|r v" .. WEIRDUTILS_VERSION .. " loaded") + end +end) + +SLASH_WEIRDUTILS1 = "/weirdutils" +SLASH_WEIRDUTILS2 = "/wu" +SlashCmdList["WEIRDUTILS"] = function(msg) + if msg == "version" then + DEFAULT_CHAT_FRAME:AddMessage("WeirdUtils v" .. WEIRDUTILS_VERSION) + elseif msg == "test" then + local result = WeirdUtilsTest() + DEFAULT_CHAT_FRAME:AddMessage("C function returned: " .. tostring(result)) + elseif msg == "ss" or msg == "screenshot" then + local on, level = WeirdUtilsScreenshot() + DEFAULT_CHAT_FRAME:AddMessage("Screenshots: " .. (on and "ON" or "OFF") .. " (quality " .. level .. ")") + elseif string.find(msg, "^ss ") or string.find(msg, "^screenshot ") then + local sub = string.match(msg, "^%S+ (.+)") + if sub == "on" or sub == "enable" then + WeirdUtilsScreenshot("enable") + DEFAULT_CHAT_FRAME:AddMessage("Screenshots enabled") + elseif sub == "off" or sub == "disable" then + WeirdUtilsScreenshot("disable") + DEFAULT_CHAT_FRAME:AddMessage("Screenshots disabled") + elseif tonumber(sub) then + WeirdUtilsScreenshot("quality", tonumber(sub)) + DEFAULT_CHAT_FRAME:AddMessage("Screenshot quality: " .. sub) + end + else + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00WeirdUtils|r commands:") + DEFAULT_CHAT_FRAME:AddMessage(" /wu version - Show version") + DEFAULT_CHAT_FRAME:AddMessage(" /wu test - Test C function call") + DEFAULT_CHAT_FRAME:AddMessage(" /wu ss - Screenshot status") + DEFAULT_CHAT_FRAME:AddMessage(" /wu ss on|off - Enable/disable PNG screenshots") + DEFAULT_CHAT_FRAME:AddMessage(" /wu ss 0-9 - Set compression level") + end +end diff --git a/src/addon/WeirdUtils.toc b/src/addon/WeirdUtils.toc new file mode 100644 index 0000000..c5aa788 --- /dev/null +++ b/src/addon/WeirdUtils.toc @@ -0,0 +1,5 @@ +## Interface: 11200 +## Title: WeirdUtils +## Notes: Utility functions provided by weirdutils DLL +## Version: 1.0 +WeirdUtils.lua diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..9087101 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,467 @@ +const std = @import("std"); +const hook = @import("hook"); +const screenshot = @import("screenshot.zig"); + +const WINAPI = std.builtin.CallingConvention.winapi; +const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} }; +const sc: std.builtin.CallingConvention = .{ .x86_stdcall = .{} }; + +// ============================================================================= +// Lua Protection Bypass +// ============================================================================= + +var protection_hook: hook.Hook = .{}; + +/// Empty detour — replaces the Lua callback address validator at 0x42a320. +/// Prologue: 55 8B EC 83 EC 40 = 6 bytes, no fixups +fn luaProtectionDetour() callconv(.c) void {} + +// ============================================================================= +// Lua C API wrappers (WoW 1.12.1 — all __fastcall, L in ECX) +// ============================================================================= + +pub const lua = struct { + pub const State = *anyopaque; + + pub fn getContext() State { + const f: *const fn () callconv(fc) State = @ptrFromInt(0x7040D0); + return f(); + } + + pub fn gettop(L: State) i32 { + const f: *const fn (State) callconv(fc) i32 = @ptrFromInt(0x6F3070); + return f(L); + } + + pub fn settop(L: State, index: i32) void { + const f: *const fn (State, i32) callconv(fc) void = @ptrFromInt(0x6F3080); + f(L, index); + } + + pub fn pop(L: State, n: i32) void { + settop(L, -n - 1); + } + + pub fn pushvalue(L: State, index: i32) void { + const f: *const fn (State, i32) callconv(fc) void = @ptrFromInt(0x6F3350); + f(L, index); + } + + pub fn remove(L: State, index: i32) void { + const f: *const fn (State, i32) callconv(fc) void = @ptrFromInt(0x6F30D0); + f(L, index); + } + + pub fn insert(L: State, index: i32) void { + const f: *const fn (State, i32) callconv(fc) void = @ptrFromInt(0x6F31A0); + f(L, index); + } + + pub fn typeOf(L: State, index: i32) i32 { + const f: *const fn (State, i32) callconv(fc) i32 = @ptrFromInt(0x6F3400); + return f(L, index); + } + + pub fn typeName(L: State, tp: i32) [*:0]const u8 { + const f: *const fn (State, i32) callconv(fc) [*:0]const u8 = @ptrFromInt(0x6F3480); + return f(L, tp); + } + + pub fn isnumber(L: State, index: i32) bool { + const f: *const fn (State, i32) callconv(fc) u32 = @ptrFromInt(0x6F34D0); + return f(L, index) != 0; + } + + pub fn isstring(L: State, index: i32) bool { + const f: *const fn (State, i32) callconv(fc) u32 = @ptrFromInt(0x6F3510); + return f(L, index) != 0; + } + + pub fn pushnil(L: State) void { + const f: *const fn (State) callconv(fc) void = @ptrFromInt(0x6F37F0); + f(L); + } + + pub fn pushnumber(L: State, n: f64) void { + const f: *const fn (State, f64) callconv(fc) void = @ptrFromInt(0x6F3810); + f(L, n); + } + + pub fn pushstring(L: State, s: [*:0]const u8) void { + const f: *const fn (State, [*:0]const u8) callconv(fc) void = @ptrFromInt(0x6F3890); + f(L, s); + } + + pub fn pushboolean(L: State, b: i32) void { + const f: *const fn (State, i32) callconv(fc) void = @ptrFromInt(0x6F39F0); + f(L, b); + } + + pub fn pushcclosure(L: State, func: usize, n: i32) void { + const f: *const fn (State, usize, i32) callconv(fc) void = @ptrFromInt(0x6F3B80); + f(L, func, n); + } + + pub fn tonumber(L: State, index: i32) f64 { + const f: *const fn (State, i32) callconv(fc) f64 = @ptrFromInt(0x6F3620); + return f(L, index); + } + + pub fn tostring(L: State, index: i32) ?[*:0]const u8 { + const f: *const fn (State, i32) callconv(fc) ?[*:0]const u8 = @ptrFromInt(0x6F3690); + return f(L, index); + } + + pub fn toboolean(L: State, index: i32) i32 { + const f: *const fn (State, i32) callconv(fc) i32 = @ptrFromInt(0x6F3660); + return f(L, index); + } + + pub fn newtable(L: State) void { + const f: *const fn (State) callconv(fc) void = @ptrFromInt(0x6F3C90); + f(L); + } + + pub fn settable(L: State, index: i32) void { + const f: *const fn (State, i32) callconv(fc) void = @ptrFromInt(0x6F3E20); + f(L, index); + } + + pub fn gettable(L: State, index: i32) void { + const f: *const fn (State, i32) callconv(fc) void = @ptrFromInt(0x6F3A40); + f(L, index); + } + + pub fn next(L: State, index: i32) i32 { + const f: *const fn (State, i32) callconv(fc) i32 = @ptrFromInt(0x6F4450); + return f(L, index); + } + + pub fn pcall(L: State, nargs: i32, nresults: i32, errfunc: i32) i32 { + const f: *const fn (State, i32, i32, i32) callconv(fc) i32 = @ptrFromInt(0x6F41A0); + return f(L, nargs, nresults, errfunc); + } + + pub fn luaError(L: State, msg: [*:0]const u8) void { + // lua_error is __cdecl(L, msg) at 0x6F4940 + asm volatile ( + \\push %[msg] + \\push %[L] + \\call *%[func] + \\add $8, %%esp + : + : [L] "r" (@intFromPtr(L)), + [msg] "r" (@intFromPtr(msg)), + [func] "r" (@as(u32, 0x6F4940)), + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } + ); + } + + pub const LuaReg = extern struct { + name: ?[*:0]const u8, + func: usize, + }; + + pub fn openlib(L: State, libname: ?[*:0]const u8, funcs: [*]const LuaReg, nup: i32) void { + const f: *const fn (State, ?[*:0]const u8, [*]const LuaReg, i32) callconv(fc) void = @ptrFromInt(0x6F4DC0); + f(L, libname, funcs, nup); + } + + pub fn checknumber(L: State, index: i32) f64 { + const f: *const fn (State, i32) callconv(fc) f64 = @ptrFromInt(0x6F4C80); + return f(L, index); + } +}; + +// ============================================================================= +// Game function wrappers +// ============================================================================= + +/// FrameScript::Register(name_ECX, func_EDX) at 0x704120 +fn registerFunction(name: [*:0]const u8, func_addr: usize) void { + hook.fastcall(void, 0x704120, @intFromPtr(name), func_addr); +} + +/// M2_AllocateModelBuffer(size, source_file, line, flags) at 0x6462E0 +/// __stdcall, 4 params, ret 0x10 — allocates from WoW's internal memory pool. +/// Returned buffer can be freed by game code via FreeFileResourceMemory. +fn allocateGameBuffer(size: u32) ?[*]u8 { + return asm volatile ( + \\push $0 + \\push $0 + \\push %[src] + \\push %[size] + \\call *%[func] + : [ret] "={eax}" (-> ?[*]u8), + : [size] "r" (size), + [src] "r" (@intFromPtr(@as([*:0]const u8, "weirdutils"))), + [func] "r" (@as(u32, 0x6462E0)), + : .{ .ecx = true, .edx = true, .memory = true, .cc = true } + ); +} + +// ============================================================================= +// Custom C functions (callable from Lua) +// ============================================================================= +// +// FIX: Cannot use the lua.* wrappers (e.g. lua.pushstring) from inside Lua +// C callbacks. The wrappers call through typed callconv(fc) function pointers, +// but Zig 0.15's x86 codegen is broken for callconv(fc) — it generates +// `ret 0x4` instead of plain `ret` for fastcall calls with ≤2 register params, +// corrupting the stack. Work around by using hook.fastcall / raw inline asm +// which bypasses Zig's calling-convention codegen entirely. + +fn weirdUtilsTest(L: lua.State) callconv(.c) u32 { + hook.fastcall(void, 0x6F3890, @intFromPtr(L), @intFromPtr(@as([*:0]const u8, "WeirdUtils is working!"))); + return 1; +} + +fn weirdUtilsVersion(L: lua.State) callconv(.c) u32 { + // lua_pushnumber is __fastcall(L_ECX, f64_stack) — f64 skips EDX and goes + // on the stack. Callee cleans with ret 8. + asm volatile ( + \\sub $8, %%esp + \\fld1 + \\fstpl (%%esp) + \\call *%[func] + : + : [_] "{ecx}" (@intFromPtr(L)), + [func] "r" (@as(u32, 0x6F3810)), + : .{ .eax = true, .edx = true, .memory = true, .cc = true } + ); + return 1; +} + +fn registerLuaFunctions() void { + registerFunction("WeirdUtilsTest", @intFromPtr(&weirdUtilsTest)); + registerFunction("WeirdUtilsVersion", @intFromPtr(&weirdUtilsVersion)); + registerFunction("WeirdUtilsScreenshot", @intFromPtr(&screenshot.screenshotCommand)); +} + +// ============================================================================= +// Embedded addon files +// ============================================================================= + +const addon_prefix = "Interface\\AddOns\\WeirdUtils\\"; + +const FileEntry = struct { + name: []const u8, + data: []const u8, +}; + +const embedded_files = [_]FileEntry{ + .{ .name = "WeirdUtils.toc", .data = @embedFile("addon/WeirdUtils.toc") }, + .{ .name = "WeirdUtils.lua", .data = @embedFile("addon/WeirdUtils.lua") }, +}; + +fn findEmbeddedFile(path: [*:0]const u8) ?*const FileEntry { + const path_span = std.mem.span(path); + if (path_span.len <= addon_prefix.len) return null; + // Case-insensitive prefix check — WoW paths use mixed case + for (path_span[0..addon_prefix.len], addon_prefix) |a, b| { + const la = if (a >= 'A' and a <= 'Z') a + 32 else a; + const lb = if (b >= 'A' and b <= 'Z') b + 32 else b; + if (la != lb) return null; + } + const relative = path_span[addon_prefix.len..]; + for (&embedded_files) |*entry| { + if (relative.len != entry.name.len) continue; + var match = true; + for (relative, entry.name) |a, b| { + const la = if (a >= 'A' and a <= 'Z') a + 32 else a; + const lb = if (b >= 'A' and b <= 'Z') b + 32 else b; + if (la != lb) { + match = false; + break; + } + } + if (match) return entry; + } + return null; +} + +// ============================================================================= +// Hook: LoadFileWithTextureResourceFallback (0x648620) +// __stdcall(unk, path, buf_out, size_out, extra_alloc, flags, async) → ret 0x1C +// Prologue: 55 8B EC 8B 4D 1C = 6 bytes, no fixups +// +// The central file I/O function — all .toc, .lua, .xml, and texture file reads +// go through here. We intercept reads for our addon prefix and serve from +// DLL-embedded memory, allocated with the game's own allocator so the game +// can free the buffer normally. +// ============================================================================= + +var file_hook: hook.Hook = .{}; + +fn loadFileDetour( + unk: u32, + path: [*:0]const u8, + buf_out: *?[*]u8, + size_out: ?*u32, + extra_alloc: u32, + flags: u32, + async_ptr: u32, +) callconv(sc) u32 { + if (findEmbeddedFile(path)) |entry| { + const data_len: u32 = @intCast(entry.data.len); + const total = data_len + extra_alloc; + const buf = allocateGameBuffer(total) orelse return 0; + + @memcpy(buf[0..entry.data.len], entry.data); + + // Zero extra bytes (null terminator for text files when extra_alloc=1) + if (extra_alloc > 0) { + @memset(buf[entry.data.len..][0..extra_alloc], 0); + } + + buf_out.* = buf; + if (size_out) |s| s.* = data_len; + return 1; + } + + // Not our file — forward to original + const orig = file_hook.getTrampoline( + *const fn (u32, [*:0]const u8, *?[*]u8, ?*u32, u32, u32, u32) callconv(sc) u32, + ); + return orig(unk, path, buf_out, size_out, extra_alloc, flags, async_ptr); +} + +// ============================================================================= +// Hook: LoadScriptFunctions (0x490250) +// Prologue: 56 E8 FA 60 27 00 = 6 bytes, fixup at offset 1 +// ============================================================================= + +var lsf_hook: hook.Hook = .{}; + +fn loadScriptFunctionsDetour() callconv(sc) void { + const orig = lsf_hook.getTrampoline(*const fn () callconv(sc) void); + orig(); + registerLuaFunctions(); +} + +// ============================================================================= +// Hook: LoadAddonsRecursively (0x51F600) +// __fastcall(error_handler_ECX) — prologue: 53 8B 1D ... = 7 bytes, no fixups +// After all real addons load, we call loadFileListWithIncludes with our .toc +// path, triggering the full addon loading pipeline (toc parse → lua exec → +// xml parse) with file reads served from embedded memory via the file hook. +// ============================================================================= + +var load_addons_hook: hook.Hook = .{}; + +fn loadAddonsDetour(error_handler: u32, _edx: u32) callconv(.c) void { + _ = _edx; + + // Call original — loads all player addons + callOrigLoadAddons(error_handler); + + // Trigger our addon through the real loading pipeline. + // loadFileListWithIncludes will read our .toc via LoadFileWithTextureResourceFallback + // (intercepted by file_hook), parse it, and call processIncludeFile for each entry, + // which loads .lua files through the same hooked path. + // + // FIX: loadFileListWithIncludes unconditionally calls MD5_Update on the md5ctx + // param (EDX). Passing NULL here caused the original crash-on-load — an access + // violation inside MD5_Update. Allocate a zeroed 88-byte context on the stack. + var md5ctx = std.mem.zeroes([88]u8); + callLoadFileListWithIncludes( + "Interface\\AddOns\\WeirdUtils\\WeirdUtils.toc", + &md5ctx, + error_handler, + ); +} + +fn callOrigLoadAddons(error_handler: u32) void { + asm volatile ( + \\call *%[func] + : + : [_] "{ecx}" (error_handler), + [func] "r" (load_addons_hook.trampoline), + : .{ .eax = true, .edx = true, .memory = true, .cc = true } + ); +} + +/// loadFileListWithIncludes(path_ECX, md5ctx_EDX, error_handler_stack) +/// __fastcall at 0x6EDB90, ret 4 (1 stack param) +fn callLoadFileListWithIncludes(toc_path: [*:0]const u8, md5ctx: *[88]u8, error_handler: u32) void { + // __fastcall: ECX=path, EDX=md5ctx, stack: error_handler + // Callee cleans the 1 stack arg (ret 4) + asm volatile ( + \\push %[eh] + \\call *%[func] + : + : [_] "{ecx}" (@intFromPtr(toc_path)), + [_] "{edx}" (@intFromPtr(md5ctx)), + [eh] "r" (error_handler), + [func] "r" (@as(u32, 0x6EDB90)), + : .{ .eax = true, .memory = true, .cc = true } + ); +} + +// ============================================================================= +// Hook: CGGameUI_Shutdown (0x490BD0) +// Prologue: 56 E8 7A 83 FC FF = 6 bytes, fixup at offset 1 +// ============================================================================= + +var shutdown_hook: hook.Hook = .{}; + +fn shutdownDetour() callconv(sc) void { + const orig = shutdown_hook.getTrampoline(*const fn () callconv(sc) void); + orig(); +} + +// ============================================================================= +// Init / Cleanup +// ============================================================================= + +fn install() void { + // 1. Lua protection bypass — empty stub replaces address validator + _ = protection_hook.install(0x42a320, 6, @intFromPtr(&luaProtectionDetour), &.{}); + + // 2. File I/O hook — serve embedded addon files from memory + // Must be installed before LoadAddonsRecursively hook fires + _ = file_hook.install(0x648620, 6, @intFromPtr(&loadFileDetour), &.{}); + + // 3. LoadScriptFunctions — register C functions after built-in commands + _ = lsf_hook.install(0x490250, 6, @intFromPtr(&loadScriptFunctionsDetour), &.{1}); + + // 4. LoadAddonsRecursively — trigger our addon load after real addons + // Uses thunk: __fastcall(ECX) → cdecl(ecx_val, edx_val) + if (load_addons_hook.prepare(0x51F600, 7, &.{})) { + const thunk = load_addons_hook.mem.? + 32; + _ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&loadAddonsDetour), 0); + load_addons_hook.activate(@intFromPtr(thunk)); + } + + // 5. Screenshot hook — async PNG capture replacing TGA write + screenshot.installHook(); + + // 6. CGGameUI_Shutdown — cleanup + _ = shutdown_hook.install(0x490BD0, 6, @intFromPtr(&shutdownDetour), &.{1}); +} + +fn uninstall() void { + shutdown_hook.remove(); + screenshot.removeHook(); + load_addons_hook.remove(); + lsf_hook.remove(); + file_hook.remove(); + protection_hook.remove(); +} + +// ============================================================================= +// DLL entry point +// ============================================================================= + +pub export fn DllMain( + _: ?*anyopaque, + reason: u32, + _: ?*anyopaque, +) callconv(WINAPI) i32 { + switch (reason) { + 1 => install(), // DLL_PROCESS_ATTACH + 0 => uninstall(), // DLL_PROCESS_DETACH + else => {}, + } + return 1; +} diff --git a/src/png.zig b/src/png.zig new file mode 100644 index 0000000..2cfb221 --- /dev/null +++ b/src/png.zig @@ -0,0 +1,404 @@ +//! Minimal PNG encoder with deflate compression. +//! No large struct literals or lookup tables — entire module adds ~2KB to .rdata. +//! Supports store blocks (level 0) and fixed-Huffman encoding (levels 1-9). + +const std = @import("std"); + +// ============================================================================= +// Public interface +// ============================================================================= + +pub const Level = enum(u4) { + store = 0, + level_1 = 1, + level_2 = 2, + level_3 = 3, + level_4 = 4, + level_5 = 5, + level_6 = 6, + level_7 = 7, + level_8 = 8, + level_9 = 9, +}; + +pub fn mapLevel(user_level: i32) Level { + return switch (user_level) { + 0 => .store, + 1...9 => @enumFromInt(@as(u4, @intCast(std.math.clamp(user_level, 0, 9)))), + else => .level_6, + }; +} + +/// Write an RGB24 PNG to a raw file handle (HANDLE from CreateFileA). +/// `write_fn` is called with (ctx, data) to emit bytes. +pub fn encode( + ctx: anytype, + write_fn: fn (@TypeOf(ctx), []const u8) void, + pixels: [*]const u8, + width: u16, + height: u16, + level: Level, +) void { + var s = Stream(@TypeOf(ctx)){ .ctx = ctx, .write_fn = write_fn }; + + const w: u32 = width; + const h: u32 = height; + + // PNG signature + s.writeRaw("\x89PNG\r\n\x1a\n"); + + // IHDR + { + var ihdr: [13]u8 = undefined; + writeU32BE(ihdr[0..4], w); + writeU32BE(ihdr[4..8], h); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // color type RGB + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + s.writeChunk("IHDR", &ihdr); + } + + // IDAT + if (@intFromEnum(level) == 0) { + writeIdatStore(&s, pixels, w, h); + } else { + writeIdatFixedHuffman(&s, pixels, w, h); + } + + // IEND + s.writeChunk("IEND", &[0]u8{}); +} + +// ============================================================================= +// Stream wrapper — tracks CRC and Adler inline +// ============================================================================= + +fn Stream(comptime Ctx: type) type { + return struct { + ctx: Ctx, + write_fn: *const fn (Ctx, []const u8) void, + crc: u32 = 0xFFFFFFFF, + adler_a: u32 = 1, + adler_b: u32 = 0, + + const Self = @This(); + + fn writeRaw(self: *Self, data: []const u8) void { + self.write_fn(self.ctx, data); + } + + fn writeCrc(self: *Self, data: []const u8) void { + self.writeRaw(data); + self.crc = crc32Update(self.crc, data); + } + + fn writeCrcAdler(self: *Self, data: []const u8) void { + self.writeRaw(data); + self.crc = crc32Update(self.crc, data); + adlerUpdate(&self.adler_a, &self.adler_b, data); + } + + fn writeChunk(self: *Self, chunk_type: *const [4]u8, data: []const u8) void { + var buf: [4]u8 = undefined; + writeU32BE(&buf, @intCast(data.len)); + self.writeRaw(&buf); + self.writeRaw(chunk_type); + self.writeRaw(data); + var crc: u32 = 0xFFFFFFFF; + crc = crc32Update(crc, chunk_type); + crc = crc32Update(crc, data); + writeU32BE(&buf, crc ^ 0xFFFFFFFF); + self.writeRaw(&buf); + } + + fn beginChunk(self: *Self, chunk_type: *const [4]u8, payload_len: u32) void { + var buf: [4]u8 = undefined; + writeU32BE(&buf, payload_len); + self.writeRaw(&buf); + self.writeRaw(chunk_type); + self.crc = 0xFFFFFFFF; + self.crc = crc32Update(self.crc, chunk_type); + } + + fn endChunk(self: *Self) void { + var buf: [4]u8 = undefined; + writeU32BE(&buf, self.crc ^ 0xFFFFFFFF); + self.writeRaw(&buf); + } + + fn resetAdler(self: *Self) void { + self.adler_a = 1; + self.adler_b = 0; + } + + fn adlerFinish(self: *Self) u32 { + return (self.adler_b << 16) | self.adler_a; + } + }; +} + +// ============================================================================= +// Store blocks (level 0) — no compression, byte-aligned +// ============================================================================= + +fn writeIdatStore(s: anytype, pixels: [*]const u8, w: u32, h: u32) void { + const row_bytes: u32 = 1 + w * 3; + const raw_size: u32 = h * row_bytes; + const max_block: u32 = 65535; + const num_blocks: u32 = (raw_size + max_block - 1) / max_block; + const idat_payload: u32 = 2 + num_blocks * 5 + raw_size + 4; + + s.beginChunk("IDAT", idat_payload); + s.resetAdler(); + + // Zlib header + const zlib_hdr = [2]u8{ 0x78, 0x01 }; + s.writeCrc(&zlib_hdr); + + var raw_remaining: u32 = raw_size; + var y: u32 = 0; + var row_off: u32 = 0; + + while (raw_remaining > 0) { + const block_size: u32 = @min(raw_remaining, max_block); + const bs16: u16 = @intCast(block_size); + var hdr: [5]u8 = undefined; + hdr[0] = if (raw_remaining <= max_block) @as(u8, 0x01) else 0x00; + hdr[1] = @truncate(bs16); + hdr[2] = @truncate(bs16 >> 8); + hdr[3] = hdr[1] ^ 0xFF; + hdr[4] = hdr[2] ^ 0xFF; + s.writeCrc(&hdr); + + var written: u32 = 0; + while (written < block_size) { + if (row_off == 0) { + const fb = [1]u8{0x00}; + s.writeCrcAdler(&fb); + row_off = 1; + written += 1; + } else { + const pix_off = y * w * 3 + (row_off - 1); + const left_row = w * 3 - (row_off - 1); + const left_blk = block_size - written; + const n = @min(left_row, left_blk); + const data = pixels[pix_off..][0..n]; + s.writeCrcAdler(data); + row_off += n; + written += n; + if (row_off > w * 3) { + row_off = 0; + y += 1; + } + } + } + raw_remaining -= block_size; + } + + var buf: [4]u8 = undefined; + writeU32BE(&buf, s.adlerFinish()); + s.writeCrc(&buf); + s.endChunk(); +} + +// ============================================================================= +// Fixed Huffman (levels 1-9) — RFC 1951 §3.2.6 fixed codes, literals only +// +// Each byte is Huffman-coded using the fixed table. No LZ77 matching. +// This gives ~10-20% compression on typical screenshot data with zero +// runtime state beyond a small bit buffer. +// ============================================================================= + +const BitBuf = struct { + bits: u32 = 0, + nbits: u5 = 0, + + fn write(self: *BitBuf, s: anytype, code: u32, len: u5) void { + self.bits |= code << self.nbits; + self.nbits += len; + while (self.nbits >= 8) { + const byte = [1]u8{@truncate(self.bits)}; + s.writeCrcAdler(&byte); + self.bits >>= 8; + self.nbits -= 8; + } + } + + fn flush(self: *BitBuf, s: anytype) void { + if (self.nbits > 0) { + const byte = [1]u8{@truncate(self.bits)}; + s.writeCrcAdler(&byte); + self.bits = 0; + self.nbits = 0; + } + } +}; + +/// RFC 1951 fixed Huffman: encode a literal byte (0-255) or end-of-block (256). +fn fixedLiteral(bb: *BitBuf, s: anytype, val: u16) void { + // RFC 1951 §3.2.6 fixed Huffman code table: + // 0-143: 8 bits, codes 00110000-10111111 + // 144-255: 9 bits, codes 110010000-111111111 + // 256-279: 7 bits, codes 0000000-0010111 + // 280-287: 8 bits, codes 11000000-11000111 + if (val <= 143) { + const code: u9 = @as(u9, @intCast(val)) + 0x30; + bb.write(s, bitReverse(u9, code, 8), 8); + } else if (val <= 255) { + const code: u9 = @as(u9, @intCast(val - 144)) + 0x190; + bb.write(s, bitReverse(u9, code, 9), 9); + } else if (val <= 279) { + const code: u9 = @intCast(val - 256); + bb.write(s, bitReverse(u9, code, 7), 7); + } else { + const code: u9 = @as(u9, @intCast(val - 280)) + 0xC0; + bb.write(s, bitReverse(u9, code, 8), 8); + } +} + +fn bitReverse(comptime T: type, val: T, n: u5) u32 { + const full = @bitReverse(val); + const shift: u5 = @intCast(@typeInfo(T).int.bits - @as(u8, n)); + return @as(u32, full) >> shift; +} + +fn writeIdatFixedHuffman(s: anytype, pixels: [*]const u8, w: u32, h: u32) void { + // We can't precompute IDAT payload size for Huffman, so we buffer the + // entire deflate stream, then write it as one IDAT chunk. + // For a 1024x768 screenshot, fixed Huffman with only literals produces + // roughly 8-9 bits per byte ≈ same size or slightly larger than raw. + // But the Sub filter makes most bytes small, yielding good compression. + + // Allocate output buffer: worst case ~9 bits/byte * raw_size / 8 + overhead + const row_bytes: u32 = 1 + w * 3; + const raw_size: u32 = h * row_bytes; + // Worst case: 9 bits per byte + block headers + zlib overhead + const max_out: u32 = (raw_size / 8) * 9 + raw_size / 8 + 1024; + const out_buf = std.heap.page_allocator.alloc(u8, max_out) catch { + // Fall back to store blocks + writeIdatStore(s, pixels, w, h); + return; + }; + defer std.heap.page_allocator.free(out_buf); + + // Compress into buffer + var out_pos: u32 = 0; + + // Zlib header + out_buf[0] = 0x78; + out_buf[1] = 0x9C; // default compression + out_pos = 2; + + var adler_a: u32 = 1; + var adler_b: u32 = 0; + + // Single fixed-Huffman block (BFINAL=1, BTYPE=01) + var bb: BitBuf = .{}; + + // Pack bits into out_buf via a mini stream + const OutStream = struct { + buf: []u8, + pos: *u32, + // Dummy fields matching the CrcAdler interface + fn writeCrcAdler(self: *@This(), data: []const u8) void { + for (data) |byte| { + if (self.pos.* < self.buf.len) { + self.buf[self.pos.*] = byte; + self.pos.* += 1; + } + } + } + }; + var out_stream = OutStream{ .buf = out_buf, .pos = &out_pos }; + + // BFINAL=1, BTYPE=01 (fixed Huffman) + bb.write(&out_stream, 0b011, 3); + + // Encode each scanline with Sub filter + var y: u32 = 0; + while (y < h) : (y += 1) { + const row_start = y * w * 3; + + // Filter byte: 1 = Sub + const filter_byte: u8 = 1; + adlerUpdate(&adler_a, &adler_b, &[1]u8{filter_byte}); + fixedLiteral(&bb, &out_stream, filter_byte); + + // First pixel: Sub filter with no left neighbor = raw bytes + var x: u32 = 0; + while (x < w * 3) : (x += 1) { + const raw = pixels[row_start + x]; + const filtered: u8 = if (x >= 3) + raw -% pixels[row_start + x - 3] + else + raw; + adlerUpdate(&adler_a, &adler_b, &[1]u8{filtered}); + fixedLiteral(&bb, &out_stream, filtered); + } + } + + // End of block marker (256) + fixedLiteral(&bb, &out_stream, 256); + bb.flush(&out_stream); + + // Adler-32 (big-endian, NOT bit-packed — appended as raw bytes after deflate) + const adler = (adler_b << 16) | adler_a; + if (out_pos + 4 <= out_buf.len) { + out_buf[out_pos] = @truncate(adler >> 24); + out_buf[out_pos + 1] = @truncate(adler >> 16); + out_buf[out_pos + 2] = @truncate(adler >> 8); + out_buf[out_pos + 3] = @truncate(adler); + out_pos += 4; + } + + // Write as single IDAT chunk + s.writeChunk("IDAT", out_buf[0..out_pos]); +} + +// ============================================================================= +// CRC-32 (1KB comptime table) and Adler-32 +// ============================================================================= + +const crc32_table: [256]u32 = blk: { + @setEvalBranchQuota(10000); + var table: [256]u32 = undefined; + for (0..256) |n| { + var c: u32 = @intCast(n); + for (0..8) |_| { + c = if (c & 1 != 0) 0xEDB88320 ^ (c >> 1) else c >> 1; + } + table[n] = c; + } + break :blk table; +}; + +fn crc32Update(crc: u32, data: []const u8) u32 { + var c = crc; + for (data) |b| { + c = crc32_table[(c ^ b) & 0xFF] ^ (c >> 8); + } + return c; +} + +fn adlerUpdate(a: *u32, b: *u32, data: []const u8) void { + var remaining = data; + while (remaining.len > 0) { + const n = @min(remaining.len, 5552); + for (remaining[0..n]) |byte| { + a.* += byte; + b.* += a.*; + } + a.* %= 65521; + b.* %= 65521; + remaining = remaining[n..]; + } +} + +fn writeU32BE(buf: *[4]u8, val: u32) void { + buf[0] = @truncate(val >> 24); + buf[1] = @truncate(val >> 16); + buf[2] = @truncate(val >> 8); + buf[3] = @truncate(val); +} diff --git a/src/screenshot.zig b/src/screenshot.zig new file mode 100644 index 0000000..0ddf2a9 --- /dev/null +++ b/src/screenshot.zig @@ -0,0 +1,345 @@ +const std = @import("std"); +const hook = @import("hook"); +const png = @import("png.zig"); + +const WINAPI = std.builtin.CallingConvention.winapi; + +// ============================================================================= +// Windows API +// ============================================================================= + +const HANDLE = *anyopaque; + +const SYSTEMTIME = extern struct { + wYear: u16, + wMonth: u16, + wDayOfWeek: u16, + wDay: u16, + wHour: u16, + wMinute: u16, + wSecond: u16, + wMilliseconds: u16, +}; + +extern "kernel32" fn GetLocalTime(lpSystemTime: *SYSTEMTIME) callconv(WINAPI) void; + +extern "kernel32" fn CreateFileA( + lpFileName: [*:0]const u8, + dwDesiredAccess: u32, + dwShareMode: u32, + lpSecurityAttributes: ?*anyopaque, + dwCreationDisposition: u32, + dwFlagsAndAttributes: u32, + hTemplateFile: ?HANDLE, +) callconv(WINAPI) ?HANDLE; + +extern "kernel32" fn WriteFile( + hFile: HANDLE, + lpBuffer: [*]const u8, + nNumberOfBytesToWrite: u32, + lpNumberOfBytesWritten: ?*u32, + lpOverlapped: ?*anyopaque, +) callconv(WINAPI) i32; + +extern "kernel32" fn CloseHandle(hObject: HANDLE) callconv(WINAPI) i32; + +// ============================================================================= +// State +// ============================================================================= + +var enabled: bool = true; +var compression_level: i32 = 6; // user-facing 0–9, kept for Lua interface +var tga_hook: hook.Hook = .{}; +var screenshot_dir: [260]u8 = undefined; +var screenshot_dir_len: usize = 0; +var screenshot_counter: u32 = 1; + +// ============================================================================= +// Ring buffer queue (max 8 pending screenshots) +// ============================================================================= + +const MAX_PENDING = 8; + +const PendingScreenshot = struct { + buffer: [*]u8, + width: u16, + height: u16, + size: u32, +}; + +var queue: [MAX_PENDING]PendingScreenshot = undefined; +var queue_head: usize = 0; +var queue_tail: usize = 0; +var queue_count: usize = 0; +var mutex: std.Thread.Mutex = .{}; +var worker_running: bool = false; + +fn enqueue(shot: PendingScreenshot) bool { + if (queue_count >= MAX_PENDING) return false; + queue[queue_tail] = shot; + queue_tail = (queue_tail + 1) % MAX_PENDING; + queue_count += 1; + return true; +} + +fn dequeue() ?PendingScreenshot { + if (queue_count == 0) return null; + const shot = queue[queue_head]; + queue_head = (queue_head + 1) % MAX_PENDING; + queue_count -= 1; + return shot; +} + +// ============================================================================= +// Directory extraction — capture path prefix from game's first TGA filename +// ============================================================================= + +fn extractDir(filename_ptr: u32) void { + const path: [*:0]const u8 = @ptrFromInt(filename_ptr); + const span = std.mem.span(path); + var last_sep: usize = 0; + for (span, 0..) |c, i| { + if (c == '\\' or c == '/') last_sep = i + 1; + } + if (last_sep > 0 and last_sep <= screenshot_dir.len) { + @memcpy(screenshot_dir[0..last_sep], span[0..last_sep]); + screenshot_dir_len = last_sep; + } +} + +// ============================================================================= +// Call original CTgaFile::Write — __thiscall(self_ECX, filename_stack) ret 4 +// ============================================================================= + +fn callOriginal(self: u32, filename: u32) i32 { + return asm volatile ( + \\push %[filename] + \\call *%[func] + : [ret] "={eax}" (-> i32), + : [_] "{ecx}" (self), + [filename] "r" (filename), + [func] "r" (tga_hook.trampoline), + : .{ .edx = true, .memory = true, .cc = true } + ); +} + +// ============================================================================= +// Detour: CTgaFile::Write at 0x5a4810 +// Thunked from __fastcall(self_ECX, _EDX, filename_stack) → cdecl +// ============================================================================= + +fn tgaWriteDetour(self: u32, _edx: u32, filename: u32) callconv(.c) i32 { + _ = _edx; + + if (!enabled) return callOriginal(self, filename); + + // Validate TGA header fields + const pixel_data = hook.readMem(u32, self + 0x04); + const additional_header = hook.readMem(u8, self + 0x08); + const color_map_type = hook.readMem(u8, self + 0x09); + const image_type = hook.readMem(u8, self + 0x0a); + + if (pixel_data == 0 or filename == 0 or image_type != 2 or additional_header != 0 or color_map_type != 0) { + return callOriginal(self, filename); + } + + const width = hook.readMem(u16, self + 0x14); + const height = hook.readMem(u16, self + 0x16); + const size: u32 = @as(u32, width) * @as(u32, height) * 3; + + // Capture screenshot directory from the first TGA path we see + if (screenshot_dir_len == 0) extractDir(filename); + + // Allocate buffer and copy BGR pixel data + const buffer = std.heap.page_allocator.alloc(u8, size) catch + return callOriginal(self, filename); + const src: [*]const u8 = @ptrFromInt(pixel_data); + @memcpy(buffer, src[0..size]); + + // Enqueue for async processing + mutex.lock(); + defer mutex.unlock(); + + if (!enqueue(.{ .buffer = buffer.ptr, .width = width, .height = height, .size = size })) { + std.heap.page_allocator.free(buffer); + return callOriginal(self, filename); + } + + // Spawn worker if not already running + if (!worker_running) { + worker_running = true; + const thread = std.Thread.spawn(.{}, workerThread, .{}) catch { + worker_running = false; + return 1; + }; + thread.detach(); + } + + return 1; // suppress original TGA write +} + +// ============================================================================= +// Worker thread — dequeues shots, converts BGR→RGB, writes PNG +// ============================================================================= + +fn workerThread() void { + while (true) { + var shot: PendingScreenshot = undefined; + { + mutex.lock(); + defer mutex.unlock(); + if (dequeue()) |s| { + shot = s; + } else { + worker_running = false; + return; + } + } + + processScreenshot(shot); + } +} + +fn processScreenshot(shot: PendingScreenshot) void { + defer std.heap.page_allocator.free(shot.buffer[0..shot.size]); + + // BGR → RGB swap + const total: u32 = @as(u32, shot.width) * @as(u32, shot.height); + var i: u32 = 0; + while (i < total) : (i += 1) { + const off = i * 3; + const tmp = shot.buffer[off]; + shot.buffer[off] = shot.buffer[off + 2]; + shot.buffer[off + 2] = tmp; + } + + // Generate filename: {dir}WoWScrnShot_MMDDYY_HHMMSS_N.png + var st: SYSTEMTIME = undefined; + GetLocalTime(&st); + + var name_buf: [260]u8 = undefined; + const name_slice = std.fmt.bufPrint(&name_buf, "{s}WoWScrnShot_{:0>2}{:0>2}{:0>2}_{:0>2}{:0>2}{:0>2}_{}.png", .{ + screenshot_dir[0..screenshot_dir_len], + st.wMonth, + st.wDay, + st.wYear % @as(u16, 100), + st.wHour, + st.wMinute, + st.wSecond, + screenshot_counter, + }) catch return; + + screenshot_counter += 1; + if (screenshot_counter > 999) screenshot_counter = 1; + + // Null-terminate for CreateFileA + if (name_slice.len >= name_buf.len) return; + name_buf[name_slice.len] = 0; + + const level = png.mapLevel(compression_level); + writePng(@ptrCast(name_slice.ptr), shot.buffer, shot.width, shot.height, level); +} + +// ============================================================================= +// File I/O bridge for png.zig +// ============================================================================= + +fn writeToFile(handle: HANDLE, data: []const u8) void { + var off: usize = 0; + while (off < data.len) { + var written: u32 = 0; + _ = WriteFile(handle, data[off..].ptr, @intCast(data.len - off), &written, null); + if (written == 0) return; + off += written; + } +} + +fn writePng(path: [*:0]const u8, pixels: [*]const u8, width: u16, height: u16, level: png.Level) void { + const handle = CreateFileA(path, 0x40000000, 0, null, 2, 0x80, null) orelse return; + defer _ = CloseHandle(handle); + png.encode(handle, writeToFile, pixels, width, height, level); +} + +// ============================================================================= +// Lua helper: push f64 onto Lua stack via __fastcall(L_ECX, f64_on_stack) +// ============================================================================= + +fn luaPushNumber(L_ptr: usize, n: f64) void { + // lua_pushnumber at 0x6F3810 is __fastcall(L, double) + // double skips EDX, goes on stack (8 bytes). Callee cleans with ret 8. + const raw: [2]u32 = @bitCast(n); + asm volatile ( + \\push %[hi] + \\push %[lo] + \\call *%[func] + : + : [_] "{ecx}" (L_ptr), + [lo] "r" (raw[0]), + [hi] "r" (raw[1]), + [func] "r" (@as(u32, 0x6F3810)), + : .{ .eax = true, .edx = true, .memory = true, .cc = true } + ); +} + +// ============================================================================= +// Lua C function: WeirdUtilsScreenshot(...) +// No args → returns enabled (bool), compression_level (number) +// ("enable") → enable PNG screenshots +// ("disable") → disable (fall through to original TGA) +// ("quality", N) → set compression level 0–9 (kept for addon compat) +// ============================================================================= + +pub fn screenshotCommand(L: *anyopaque) callconv(.c) u32 { + const L_ptr = @intFromPtr(L); + + // lua_gettop(L) — __fastcall(L_ECX), EDX unused + const nargs = hook.fastcall(i32, 0x6F3070, L_ptr, @as(u32, 0)); + + if (nargs == 0) { + // lua_pushboolean(L, enabled) + hook.fastcall(void, 0x6F39F0, L_ptr, @as(i32, if (enabled) 1 else 0)); + // lua_pushnumber(L, compression_level) + luaPushNumber(L_ptr, @floatFromInt(compression_level)); + return 2; + } + + // lua_tostring(L, 1) — __fastcall(L_ECX, index_EDX) + const raw_str = hook.fastcall(usize, 0x6F3690, L_ptr, @as(i32, 1)); + if (raw_str != 0) { + const str: [*:0]const u8 = @ptrFromInt(raw_str); + const arg = std.mem.span(str); + + if (std.mem.eql(u8, arg, "enable")) { + enabled = true; + } else if (std.mem.eql(u8, arg, "disable")) { + enabled = false; + } else if (std.mem.eql(u8, arg, "quality")) { + if (nargs >= 2) { + // lua_tonumber(L, 2) — __fastcall(L_ECX, index_EDX), returns f64 in ST(0) + const level = hook.fastcall(f64, 0x6F3620, L_ptr, @as(i32, 2)); + compression_level = std.math.clamp(@as(i32, @intFromFloat(level)), 0, 9); + } + } + } + + return 0; +} + +// ============================================================================= +// Install / Remove +// ============================================================================= + +pub fn installHook() void { + // 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) + if (tga_hook.prepare(0x5a4810, 6, &.{})) { + const thunk = tga_hook.mem.? + 32; + _ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&tgaWriteDetour), 1); + tga_hook.activate(@intFromPtr(thunk)); + } +} + +pub fn removeHook() void { + tga_hook.remove(); +}