diff --git a/src/healtextfix/healtextfix.zig b/src/healtextfix/healtextfix.zig new file mode 100644 index 0000000..7d1788d --- /dev/null +++ b/src/healtextfix/healtextfix.zig @@ -0,0 +1,216 @@ +//! SuperWoW Heal Text Fix +//! +//! Patches SuperWoWhook.dll at runtime to disable the duplicate floating +//! healing combat text that SuperWoW 1.5.1 adds. +//! +//! Based on: +//! - https://github.com/MarcelineVQ/SuperWoWHealTextFix +//! - https://github.com/turtlenips/superwow-patch +//! +//! The turtlenips version adds two extra patches (at file offsets 0x306E and +//! 0x3123) that fix HoT ticks (e.g. Renew) showing in the wrong color by +//! redirecting function pointers. +//! +//! The reference repos patch the DLL on disk before loading, which prevents +//! the hook registration call from executing. Since we patch at runtime (after +//! SuperWoW has already initialized and installed its hooks), we must instead +//! patch the handler function itself to skip its duplicate text creation. +//! +//! Patches applied to SuperWoWhook.dll in memory: +//! +//! 1. 0x3006 (2 bytes) — Skip duplicate heal text in handler +//! The handler at RVA 0x3BF0 creates floating text, then calls through +//! to the original wow.exe function (which also creates text = duplicate). +//! Patch MOV ECX,[EDI] -> JMP +0x7A to skip to the call-through at 0x3C82. +//! Old: 8B 0F +//! New: EB 7A +//! +//! 2. 0x306E (4 bytes) — Redirect HoT text handler pointer +//! Old: 9C D8 C4 00 +//! New: 06 7C 44 00 +//! +//! 3. 0x3123 (4 bytes) — Redirect HoT text handler pointer (second site) +//! Old: 9C D8 C4 00 +//! New: 06 7C 44 00 +//! +//! NOTE: These are file offsets, converted to virtual addresses via PE section +//! headers at runtime. + +const std = @import("std"); +const hook = @import("zhook"); +const con = @import("../console.zig"); + +const WINAPI = std.builtin.CallingConvention.winapi; +extern "kernel32" fn GetModuleHandleA(lpModuleName: ?[*:0]const u8) callconv(WINAPI) ?*anyopaque; + +const Patch = struct { + /// File offset into SuperWoWhook.dll + file_offset: u32, + old: []const u8, + new: []const u8, + /// Optional mask for old-byte verification. 0xFF = must match, 0x00 = skip + /// (relocated operands). null = check all bytes exactly. + mask: ?[]const u8 = null, +}; + +const patches = [_]Patch{ + // Patch 0: Skip duplicate heal text in the SuperWoW handler + // The handler at RVA 0x3BF0 creates floating text then calls the original + // (which also creates text). JMP from 0x3C06 to the call-through at 0x3C82. + .{ + .file_offset = 0x3006, + .old = &.{ 0x8B, 0x0F }, + .new = &.{ 0xEB, 0x7A }, + }, + // Patch 1: Redirect HoT text handler pointer (fixes Renew etc. color) + .{ + .file_offset = 0x306E, + .old = &.{ 0x9C, 0xD8, 0xC4, 0x00 }, + .new = &.{ 0x06, 0x7C, 0x44, 0x00 }, + }, + // Patch 2: Redirect HoT text handler pointer (second call site) + .{ + .file_offset = 0x3123, + .old = &.{ 0x9C, 0xD8, 0xC4, 0x00 }, + .new = &.{ 0x06, 0x7C, 0x44, 0x00 }, + }, +}; + +fn printHex(prefix: []const u8, bytes: []const u8) void { + con.print(prefix); + for (bytes) |b| { + con.fmt("{x:0>2} ", .{b}); + } + con.print("\n"); +} + +/// Convert a file offset to a virtual address by walking PE section headers. +fn fileOffsetToVA(base: [*]const u8, file_offset: u32) ?[*]u8 { + // DOS header: e_lfanew at offset 0x3C + const e_lfanew = std.mem.readInt(u32, base[0x3C..0x40], .little); + const pe_base = base + e_lfanew; + + // PE signature (4) + COFF header (20) = optional header at +24 + // Number of sections at PE+6 + const num_sections = std.mem.readInt(u16, pe_base[6..8], .little); + // Size of optional header at PE+20 + const opt_hdr_size = std.mem.readInt(u16, pe_base[20..22], .little); + + // Section headers start after optional header + const sections_start = pe_base + 24 + opt_hdr_size; + + var i: u16 = 0; + while (i < num_sections) : (i += 1) { + const sec = sections_start + @as(usize, i) * 40; + const virt_size = std.mem.readInt(u32, sec[8..12], .little); + const virt_addr = std.mem.readInt(u32, sec[12..16], .little); + const raw_offset = std.mem.readInt(u32, sec[20..24], .little); + const raw_size = std.mem.readInt(u32, sec[16..20], .little); + + _ = virt_size; + if (file_offset >= raw_offset and file_offset < raw_offset + raw_size) { + const rva = virt_addr + (file_offset - raw_offset); + return @ptrFromInt(@intFromPtr(base) + rva); + } + } + return null; +} + +var g_patched: bool = false; + +pub fn installHooks() void { + con.print("[healtextfix] Module loaded (stub)\n"); +} + +/// Called from engineInitDetour (GameEngine_MainInitialize hook) — late enough +/// that SuperWoWhook.dll should be loaded if present. +pub fn lateInit() void { + const superwow_base = GetModuleHandleA("SuperWoWhook.dll"); + if (superwow_base == null) { + con.print("[healtextfix] SuperWoWhook.dll not found, skipping\n"); + return; + } + + const base: [*]const u8 = @ptrCast(superwow_base.?); + con.fmt("[healtextfix] SuperWoWhook.dll at 0x{x}\n", .{@intFromPtr(base)}); + + var applied: u32 = 0; + for (patches, 0..) |patch, idx| { + const va = fileOffsetToVA(base, patch.file_offset) orelse { + con.fmt("[healtextfix] Patch {d}: failed to resolve file offset 0x{x}\n", .{ idx, patch.file_offset }); + continue; + }; + + // Verify old bytes match (mask skips relocated operands) + const target: [*]u8 = va; + var matches = true; + for (0..patch.old.len) |j| { + const m: u8 = if (patch.mask) |mask| mask[j] else 0xFF; + if (target[j] & m != patch.old[j] & m) { + matches = false; + break; + } + } + + if (!matches) { + // Check if already patched + var already = true; + for (0..patch.new.len) |j| { + const m: u8 = if (patch.mask) |mask| mask[j] else 0xFF; + if (target[j] & m != patch.new[j] & m) { + already = false; + break; + } + } + if (already) { + con.fmt("[healtextfix] Patch {d}: already applied\n", .{idx}); + applied += 1; + } else { + con.fmt("[healtextfix] Patch {d}: unexpected bytes at VA 0x{x}\n", .{ idx, @intFromPtr(target) }); + printHex("[healtextfix] expected: ", patch.old); + printHex("[healtextfix] found: ", target[0..patch.old.len]); + } + continue; + } + + // Apply patch + hook.writeProtected(@intFromPtr(target), patch.new); + applied += 1; + con.fmt("[healtextfix] Patch {d}: applied at VA 0x{x}\n", .{ idx, @intFromPtr(target) }); + } + + g_patched = applied > 0; + con.fmt("[healtextfix] {d}/{d} patches applied\n", .{ applied, patches.len }); +} + +pub fn removeHooks() void { + if (!g_patched) return; + + const superwow_base = GetModuleHandleA("SuperWoWhook.dll"); + if (superwow_base == null) return; + + const base: [*]const u8 = @ptrCast(superwow_base.?); + + for (patches, 0..) |patch, idx| { + const va = fileOffsetToVA(base, patch.file_offset) orelse continue; + const target: [*]u8 = va; + + // Only restore if currently patched + var is_patched = true; + for (0..patch.new.len) |j| { + const m: u8 = if (patch.mask) |mask| mask[j] else 0xFF; + if (target[j] & m != patch.new[j] & m) { + is_patched = false; + break; + } + } + + if (is_patched) { + hook.writeProtected(@intFromPtr(target), patch.old); + con.fmt("[healtextfix] Patch {d}: restored\n", .{idx}); + } + } + + g_patched = false; + con.print("[healtextfix] All patches restored\n"); +}