Files
WeirdUtils/src/healtextfix/healtextfix.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

279 lines
9.4 KiB
Zig

//! 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 PatchSet = struct {
version: []const u8,
patches: []const Patch,
};
const v1_5_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 },
},
};
const patch_sets = [_]PatchSet{
.{ .version = "1.5", .patches = &v1_5_patches },
};
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;
}
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "healtextfix";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
var g_applied_set: ?*const PatchSet = null;
pub fn isActive() bool {
return g_is_hook_owner;
}
/// Scan the DLL's mapped memory for SUPERWOW_VERSION="..." and extract the version string.
fn detectVersion(base: [*]const u8) ?[]const u8 {
const scan_len = 0x20000;
const needle = "SUPERWOW_VERSION=\"";
const mem = base[0..scan_len];
const pos = std.mem.indexOf(u8, mem, needle) orelse return null;
const ver_start = pos + needle.len;
// Find closing quote
const remaining = mem[ver_start..];
const end = std.mem.indexOfScalar(u8, remaining, '"') orelse return null;
return remaining[0..end];
}
pub fn installHooks() void {
con.print("[healtextfix] Module loaded\n");
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
}
/// Called from engineInitDetour (GameEngine_MainInitialize hook) - late enough
/// that SuperWoWhook.dll should be loaded if present.
pub fn lateInit() void {
if (!g_is_hook_owner) return;
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)});
// Detect SuperWoW version
const version = detectVersion(base) orelse {
con.print("[healtextfix] Could not detect SuperWoW version, skipping\n");
return;
};
con.fmt("[healtextfix] Detected SuperWoW version: {s}\n", .{version});
// Find matching patch set
const set: *const PatchSet = blk: {
for (&patch_sets) |*ps| {
if (std.mem.eql(u8, ps.version, version)) break :blk ps;
}
con.fmt("[healtextfix] No patches for version \"{s}\", skipping\n", .{version});
return;
};
var applied: u32 = 0;
for (set.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) });
}
if (applied > 0) g_applied_set = set;
con.fmt("[healtextfix] {d}/{d} patches applied\n", .{ applied, set.patches.len });
}
pub fn removeHooks() void {
if (!g_is_hook_owner) return;
const set = g_applied_set orelse {
mod_mutex.release(&g_mutex);
g_is_hook_owner = false;
return;
};
const superwow_base = GetModuleHandleA("SuperWoWhook.dll");
if (superwow_base == null) return;
const base: [*]const u8 = @ptrCast(superwow_base.?);
for (set.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_applied_set = null;
mod_mutex.release(&g_mutex);
g_is_hook_owner = false;
con.print("[healtextfix] All patches restored\n");
}