From 1cae8c97817a80e4146463f74f02d31e8aca04e8 Mon Sep 17 00:00:00 2001 From: MarcelineVQ Date: Sat, 4 Apr 2026 17:31:08 -0700 Subject: [PATCH] luaalloc: pure Zig slab allocator replacing WoW's 6-class pool WoW's memory_pool_allocate (0x6FAE90) does O(classes * pages) linear scan on every free/realloc to find which pool owns a pointer. Our slab stores slot size in a 4-byte header for O(1) lookup. Baseline 2000ms -> testing with allocator enabled next. - 15 size classes (16-4096) vs WoW's 6 (16-256) - @memcpy for cross-class realloc (WoW uses manual dword loop) - Large allocs (>4092 usable) fall through to game heap - No C dependencies (replaces mimalloc which couldn't cross-compile) --- build.zig | 21 ++-- src/luaalloc/luaalloc.zig | 248 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 src/luaalloc/luaalloc.zig diff --git a/build.zig b/build.zig index 1ffa62d..ddaf388 100644 --- a/build.zig +++ b/build.zig @@ -15,26 +15,29 @@ const ModuleDesc = struct { /// Single source of truth for all modules. Adding a module here is enough /// to wire up the build option, build_options passthrough, and DLL variant. +/// Versioning: major.minor.bugfix - bump bugfix for core/infra fixes that +/// affect the module's DLL, minor for feature changes, major for breaking. const module_list = [_]ModuleDesc{ - .{ .name = "pngscreenshots", .version = "1.0", .desc = "Enable screenshot module", .src_dir = "screenshot" }, + .{ .name = "pngscreenshots", .version = "1.0.1", .desc = "Enable screenshot module", .src_dir = "screenshot" }, .{ .name = "interact", .version = "1.0", .desc = "Enable interact module", .addon_name = "Interact" }, .{ .name = "outline", .version = "1.0", .desc = "Enable outline module", .default = false, .addon_name = "Outline" }, .{ .name = "worldmarkers", .version = "1.0", .desc = "Enable world markers module", .addon_name = "WorldMarkers", .addon_hidden = true }, .{ .name = "framecrash", .version = "1.0", .desc = "Enable framecrash fix", .default = false }, - .{ .name = "logsessions", .version = "1.0", .desc = "Enable log session rotation", .addon_name = "LogSessions" }, - .{ .name = "minimapicons", .version = "1.0", .desc = "Enable custom minimap icons", .addon_name = "MinimapIcons" }, - .{ .name = "transmogfix", .version = "1.0", .desc = "Enable transmog update coalescing" }, - .{ .name = "customassets", .version = "1.0", .desc = "Enable loose file loading & permissive patch glob" }, - .{ .name = "healtextfix", .version = "1.0", .desc = "Enable SuperWoW heal text fix" }, - .{ .name = "bigcursor", .version = "1.0", .desc = "Enable big cursor module" }, - .{ .name = "clickthrough", .version = "1.0", .desc = "Enable GO click-through (enlarge GO model bounds)" }, + .{ .name = "logsessions", .version = "1.0.1", .desc = "Enable log session rotation", .addon_name = "LogSessions" }, + .{ .name = "minimapicons", .version = "1.0.1", .desc = "Enable custom minimap icons", .addon_name = "MinimapIcons" }, + .{ .name = "transmogfix", .version = "1.0.1", .desc = "Enable transmog update coalescing" }, + .{ .name = "customassets", .version = "1.0.1", .desc = "Enable loose file loading & permissive patch glob" }, + .{ .name = "healtextfix", .version = "1.0.1", .desc = "Enable SuperWoW heal text fix" }, + .{ .name = "bigcursor", .version = "1.0.1", .desc = "Enable big cursor module" }, + .{ .name = "clickthrough", .version = "1.0.2", .desc = "Enable GO click-through" }, .{ .name = "dpslog", .version = "0.1", .desc = "Enable structured combat log events for addons", .default = false }, .{ .name = "transform44", .version = "1.0", .desc = "Enable transform44 profiling/A/B testing (dev only)", .default = false }, .{ .name = "addonperf", .version = "1.0", .desc = "Enable addon memory/CPU profiling API", .default = false }, .{ .name = "ssemaths", .version = "1.0", .desc = "Enable UnitXP x87 math polyfill replacements (SSE)", .default = false }, .{ .name = "silicon", .version = "1.0", .desc = "Enable SSE2 math replacements (ported from libSiliconPatch)", .default = false }, - .{ .name = "weirdperformance", .version = "1.1", .desc = "Enable production performance optimizations (SSE, inflate, filecache, timer)", .default = true }, + .{ .name = "weirdperformance", .version = "1.1.1", .desc = "Enable production performance optimizations (SSE, inflate, filecache, timer)", .default = true }, .{ .name = "superweirdo", .version = "0.1", .desc = "Enable GO loot sparkle on interactable objects", .default = false }, + .{ .name = "luaalloc", .version = "0.1", .desc = "Enable Lua slab allocator replacement", .default = false }, }; pub fn build(b: *std.Build) void { diff --git a/src/luaalloc/luaalloc.zig b/src/luaalloc/luaalloc.zig new file mode 100644 index 0000000..ff7b4ff --- /dev/null +++ b/src/luaalloc/luaalloc.zig @@ -0,0 +1,248 @@ +//! Lua allocator replacement. +//! +//! Hooks memory_pool_allocate (0x6FAE90) to replace WoW's 6-class slab +//! allocator with a faster Zig slab. Improvements over WoW's allocator: +//! +//! - O(1) free/realloc: 4-byte header stores slot size (WoW scans all +//! pools and pages to find the owning pool -- O(classes * pages)) +//! - 15 size classes vs 6: less internal fragmentation +//! - @memcpy for cross-class realloc (WoW does a manual dword loop) +//! - Slabs up to 4096 bytes (WoW falls to system heap at >256) +//! +//! LuaMemoryRealloc (the accounting wrapper at 0x6FC980) is left intact +//! so totalbytes tracking works unmodified. +//! +//! Also hooks lua_gc_step (0x6FAE00) which normally reads pool metadata to +//! calculate GC thresholds. Since pools are empty (all allocs go through us), +//! we replace it with standard Lua 5.0 threshold logic: threshold = totalbytes * 1.25. + +const std = @import("std"); +const hook = @import("zhook"); +const logging = @import("../logging.zig"); + +pub const module_name: [*:0]const u8 = "luaalloc"; + +var log: logging.Logger = .{}; + +// ============================================================================ +// Slab allocator +// +// Layout: [header: 4 bytes (u32 slot_size)][user data][padding to slot boundary] +// Free slots store a next pointer in the first 4 bytes of user area. +// +// Size classes chosen to cover Lua's common allocation sizes with <25% +// internal fragmentation at each step. Usable bytes = slot_size - HEADER. +// ============================================================================ + +const HEADER = 4; // bytes before user pointer, stores slot_size as u32 + +// Slot sizes (including header). Each class is roughly 1.5x the previous. +// Usable sizes: 12, 20, 28, 44, 60, 92, 124, 188, 252, 380, 508, 764, 1020, 2044, 4092 +const class_sizes = [_]u32{ 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 2048, 4096 }; +const NUM_CLASSES = class_sizes.len; + +// Page size for backing allocations. Each page is carved into slots of one class. +const PAGE_SIZE = 65536; + +var free_lists: [NUM_CLASSES]u32 = .{0} ** NUM_CLASSES; // head of free list (ptr as u32, 0 = empty) + +/// Find the smallest size class that fits `total` bytes (including header). +fn sizeClassIndex(total: u32) ?usize { + inline for (class_sizes, 0..) |sz, i| { + if (total <= sz) return i; + } + return null; // too large for slab +} + +/// Allocate a new page of slots for the given class, link them into the free list. +fn refillClass(class_idx: usize) bool { + const slot_size = class_sizes[class_idx]; + const page = std.heap.page_allocator.rawAlloc(PAGE_SIZE, .@"1", @returnAddress()) orelse return false; + const base = @intFromPtr(page); + const slots_per_page = PAGE_SIZE / slot_size; + + // Carve page into slots and chain them via free list. + // Build chain from last to first so the first slot is the head. + var i: u32 = slots_per_page; + while (i > 0) { + i -= 1; + const slot_addr = base + i * slot_size; + // Write next-free pointer into user area (offset HEADER from slot start) + const next_ptr: *u32 = @ptrFromInt(slot_addr + HEADER); + next_ptr.* = free_lists[class_idx]; + free_lists[class_idx] = slot_addr; + } + return true; +} + +fn slabAlloc(size: u32) ?[*]u8 { + const total = size + HEADER; + const class_idx = sizeClassIndex(total) orelse return largeMalloc(size); + const slot_size = class_sizes[class_idx]; + + // Pop from free list, refilling if empty + if (free_lists[class_idx] == 0) { + if (!refillClass(class_idx)) return null; + } + const slot_addr = free_lists[class_idx]; + const next_ptr: *const u32 = @ptrFromInt(slot_addr + HEADER); + free_lists[class_idx] = next_ptr.*; + + // Write slot size into header + const header: *u32 = @ptrFromInt(slot_addr); + header.* = slot_size; + + // Return pointer past header + return @ptrFromInt(slot_addr + HEADER); +} + +fn slabFree(user_ptr: u32) void { + const slot_addr = user_ptr - HEADER; + const header: *const u32 = @ptrFromInt(slot_addr); + const slot_size = header.*; + + // Validate it's a slab allocation (slot_size must match a known class) + const class_idx = sizeClassIndex(slot_size) orelse { + // Large allocation + largeFree(user_ptr, slot_size - HEADER); + return; + }; + if (class_sizes[class_idx] != slot_size) { + // Corrupted header or not our allocation -- fall through to large free + largeFree(user_ptr, slot_size - HEADER); + return; + } + + // Push onto free list + const next_ptr: *u32 = @ptrFromInt(user_ptr); + next_ptr.* = free_lists[class_idx]; + free_lists[class_idx] = slot_addr; +} + +fn slabRealloc(user_ptr: u32, new_size: u32) ?[*]u8 { + const slot_addr = user_ptr - HEADER; + const header: *const u32 = @ptrFromInt(slot_addr); + const old_slot_size = header.*; + const old_usable = old_slot_size - HEADER; + + // If new size fits in current slot, return same pointer + if (new_size <= old_usable) return @ptrFromInt(user_ptr); + + // Allocate new, copy, free old + const new_ptr = slabAlloc(new_size) orelse return null; + const copy_len = @min(old_usable, new_size); + const dst: [*]u8 = new_ptr; + const src: [*]const u8 = @ptrFromInt(user_ptr); + @memcpy(dst[0..copy_len], src[0..copy_len]); + slabFree(user_ptr); + return new_ptr; +} + +// Large allocations (>4092 usable bytes): fall through to WoW's system heap. +// We still prepend our 4-byte header so free/realloc can identify them. + +// M2_AllocateModelBuffer (0x6462E0): __stdcall(size, src, line, flags) -> ptr. RET 0x10. +const AllocMemory: *const fn (u32, [*:0]const u8, u32, u32) callconv(hook.cc.stdcall) ?[*]u8 = + @ptrFromInt(0x6462E0); +// FreeMemory (0x646430): __stdcall(ptr, src, line, flags). RET 0x10. +const FreeMemory: *const fn (?*anyopaque, [*:0]const u8, u32, u32) callconv(hook.cc.stdcall) void = + @ptrFromInt(0x646430); + +const large_src: [*:0]const u8 = "luaalloc"; + +fn largeMalloc(size: u32) ?[*]u8 { + const total = size + HEADER; + const mem = AllocMemory(total, large_src, 0, 0) orelse return null; + const base = @intFromPtr(mem); + const header: *u32 = @ptrFromInt(base); + header.* = total; // store total as "slot size" so realloc/free works + return @ptrFromInt(base + HEADER); +} + +fn largeFree(user_ptr: u32, _: u32) void { + FreeMemory(@ptrFromInt(user_ptr - HEADER), large_src, 0, 0); +} + +// ============================================================================ +// memory_pool_allocate hook +// +// Original at 0x6FAE90: +// __fastcall(ECX=pool_context, EDX=old_ptr, stack: new_size) +// RET 0x4 (cleans 1 stack param) +// Verified from prologue: ECX saved to [EBP-0xC], EDX->ESI, [EBP+8]=new_size. +// All 11 RET instructions are RET 0x4. +// +// Called from LuaMemoryRealloc (0x6FC980) which handles totalbytes accounting. +// Also called from non-Lua paths (ECX=NULL) for general pool allocation. +// ============================================================================ + +const PoolAllocFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) ?[*]u8; + +var pool_alloc_hook: hook.Detour(PoolAllocFn) = .{}; + +fn poolAllocDetour(pool_ctx: u32, old_ptr_raw: u32, new_size: u32) callconv(hook.cc.fastcall) ?[*]u8 { + _ = pool_ctx; + + if (new_size == 0) { + if (old_ptr_raw != 0) slabFree(old_ptr_raw); + return null; + } + + if (old_ptr_raw != 0) { + return slabRealloc(old_ptr_raw, new_size); + } + + return slabAlloc(new_size); +} + +// ============================================================================ +// lua_gc_step hook +// +// Original at 0x6FAE00: +// __fastcall(ECX=lua_State*), plain RET (no stack cleanup). +// Called from lua_gc_shrink_memory (0x6F73D6) only. +// Iterates 6 pool size classes to calculate GC threshold from pool usage. +// Crashes when pool descriptor array (global_State[0]) is NULL. +// +// Replacement: set threshold = totalbytes + totalbytes/4 (standard Lua 1.25x). +// global_State layout: [0]=pool_ptrs, ..., [9]=GCthreshold, [10]=totalbytes +// L->l_G at L+0x10. +// ============================================================================ + +const GcStepFn = fn (u32) callconv(hook.cc.fastcall) void; + +var gc_step_hook: hook.Detour(GcStepFn) = .{}; + +fn gcStepDetour(lua_state: u32) callconv(hook.cc.fastcall) void { + if (lua_state == 0) return; + const global_state: [*]u32 = @ptrFromInt(@as(*const u32, @ptrFromInt(lua_state + 0x10)).*); + const totalbytes = global_state[10]; // offset 0x28 + global_state[9] = totalbytes + (totalbytes >> 2); // offset 0x24 = GCthreshold +} + +// ============================================================================ +// Module interface +// ============================================================================ + +var g_is_hook_owner: bool = false; + +pub fn installHooks() void { + log = logging.Logger.open(module_name, .console); + _ = pool_alloc_hook.attach(0x6FAE90, &poolAllocDetour); + _ = gc_step_hook.attach(0x6FAE00, &gcStepDetour); + g_is_hook_owner = true; + log.print("hooked memory_pool_allocate + lua_gc_step\n"); +} + +pub fn removeHooks() void { + if (g_is_hook_owner) { + gc_step_hook.detach(); + pool_alloc_hook.detach(); + log.close(); + } + g_is_hook_owner = false; +} + +pub fn isActive() bool { + return g_is_hook_owner; +}