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)
This commit is contained in:
MarcelineVQ
2026-04-04 17:31:08 -07:00
parent b58234f176
commit 1cae8c9781
2 changed files with 260 additions and 9 deletions
+12 -9
View File
@@ -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 {
+248
View File
@@ -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;
}