diff --git a/src/luagc/lua_offsets.zig b/src/luagc/lua_offsets.zig new file mode 100644 index 0000000..524521d --- /dev/null +++ b/src/luagc/lua_offsets.zig @@ -0,0 +1,326 @@ +//! Hardcoded addresses and struct offsets for WoW 1.12.1's Lua 5.0. +//! All addresses are client-absolute (build 5875). +//! +//! All struct offsets below are verified via fresh Ghidra disassembly +//! of the mark_table (0x6F7590), mark_closure (0x6F77B0), mark_thread +//! (0x6F7860), and mark_proto (0x6F7710) functions. Each offset is +//! annotated with the exact instruction that proves it. +//! +//! DO NOT "correct" offsets based on the Lua 5.0 source without also +//! checking the disasm: WoW's layout diverges in a few places (TValue +//! is 16 bytes including padding; globals table TValue is at thread+0x40 +//! rather than +0x38; etc.). + +/// --------------------------------------------------------------------------- +// GC function addresses (all confirmed from cross-reference) +/// --------------------------------------------------------------------------- + +pub const luaC_collectgarbage: usize = 0x6F7340; +pub const lua_gc_full_collection: usize = 0x6F73E0; +pub const lua_gc_remove_objects: usize = 0x6F7210; +pub const lua_gc_sweep_all_lists: usize = 0x6F72F0; +pub const lua_gc_shrink_memory: usize = 0x6F7370; +pub const luaCallUserDataGC: usize = 0x6F7080; +pub const lua_gc_free_object: usize = 0x6F7260; + +/// Walks rootudata list. Moves dead udata with __gc metamethods to tmudata. +/// __fastcall: ECX = global_State*. +pub const luaC_separateudata: usize = 0x6F6FF0; + +/// Re-marks tmudata list so __gc-pending userdata survive the sweep. +/// __fastcall: ECX = global_State*. +pub const marktmu: usize = 0x6F7470; + +/// Clears weak-value table values in an atomic post-mark pass. +pub const cleartablevalues: usize = 0x6F7A20; + +/// Clears weak-key table keys in an atomic post-mark pass. +pub const cleartablekeys: usize = 0x6F7970; + +/// Resize string hash table. __fastcall(ECX=L, EDX=new_size). +pub const lua_string_hash_resize: usize = 0x6F9C50; + +/// lua_setgcthreshold: __fastcall(ECX=L, EDX=newthreshold). +/// Writes (newthreshold << 10) to g->gcthreshold, then calls luaC_checkGC. +/// WoW calls this on screen transitions with threshold=256 (=262144 bytes). +pub const lua_setgcthreshold: usize = 0x6F4400; + +/// Mark functions (for reference; zluagen reimplements these to support +/// incremental chunking). +pub const markroot: usize = 0x6F7AB0; +pub const propagatemarks: usize = 0x6F7510; +pub const mark_object_gray: usize = 0x6F74A0; + +/// --------------------------------------------------------------------------- +// Write barrier hook addresses +/// --------------------------------------------------------------------------- + +pub const lua_table_set_value: usize = 0x6FA840; +pub const lua_table_set_int_key: usize = 0x6FAD80; + +/// --------------------------------------------------------------------------- +// Birth mark patch addresses +/// --------------------------------------------------------------------------- + +/// `MOV byte [EDX+0x05], 0x00` inside luaC_link. Patch immediate to 0x01 +/// to make new allocations born marked-alive during chunked sweep. +pub const birth_mark_luaC_link: usize = 0x6F7B37; + +/// `MOV byte [EBX+0x05], 0x00` inside lua_create_string_object. Same idea. +pub const birth_mark_string: usize = 0x6F9DC1; + +/// --------------------------------------------------------------------------- +// global_State struct offsets +/// --------------------------------------------------------------------------- + +/// stringtable struct starts at g+0x04 (after WoW's pool_ptrs at g+0x00). +/// Layout: {hash: GCObject**, nuse: int, size: int} = 12 bytes. +/// Verified: strt+12 = 0x04+12 = 0x10 = rootgc. +pub const GS_strt_hash: u32 = 0x04; // GCObject** bucket array +pub const GS_strt_nuse: u32 = 0x08; // int: number of strings +pub const GS_strt_size: u32 = 0x0C; // int: number of buckets +pub const GS_rootgc: u32 = 0x10; // main GC list head +pub const GS_rootudata: u32 = 0x14; // userdata list head +pub const GS_tmudata: u32 = 0x18; // finalizer-pending udata list +pub const GS_gcthreshold: u32 = 0x24; // bytes-alive threshold for next GC +pub const GS_totalbytes: u32 = 0x28; // currently allocated bytes +pub const GS_registry: u32 = 0x38; // registry table pointer +pub const GS_defaultmeta: u32 = 0x48; // default metatable pointer +pub const GS_mainthread: u32 = 0x50; // main lua_State* + +/// --------------------------------------------------------------------------- +// GCObject common header (all GC types start with this prefix) +/// --------------------------------------------------------------------------- + +pub const OBJ_next: u32 = 0x00; // linked list next pointer +pub const OBJ_tt: u32 = 0x04; // type tag (u8) + +/// GC mark byte. Layout: +/// bit 0: mark flag (1 = reachable / traversed) +/// bit 1: KEYWEAK (tables with __mode 'k') +/// bit 2: VALUEWEAK (tables with __mode 'v') +/// bit 3: FINALIZED (userdata __gc already run) +/// bit 4: propagation flag (used with bit 0 via TEST 0x11) +/// bits 5-7: UNUSED — do NOT use for age tracking! +/// +/// CRITICAL: upvalues in Lua closures use the WHOLE BYTE as a "processed" +/// flag. Any non-zero value causes mark_closure to skip the upvalue entirely +/// (see TEST AL, AL at 0x6F7828). Don't write any bits into an upvalue's +/// marked byte except 0 or 1. +pub const OBJ_marked: u32 = 0x05; + +/// Bits 0 + 4 used by TEST 0x11 at mark_table child references. +pub const MARK_MASK: u8 = 0x11; + +/// AND mask to clear mark bit 0 while preserving other bits. +pub const CLEAR_MARK: u8 = 0xFE; + +/// --------------------------------------------------------------------------- +// Type tags (from propagate dispatch at 0x6F7510 + standard Lua 5.0) +/// --------------------------------------------------------------------------- + +pub const LUA_TNONE: u8 = 0xFF; // -1 as u8, marks dead keys in hash nodes +pub const LUA_TNIL: u8 = 0; +pub const LUA_TBOOLEAN: u8 = 1; +pub const LUA_TLIGHTUSERDATA: u8 = 2; +pub const LUA_TNUMBER: u8 = 3; +pub const LUA_TSTRING: u8 = 4; +pub const LUA_TTABLE: u8 = 5; +pub const LUA_TFUNCTION: u8 = 6; +pub const LUA_TUSERDATA: u8 = 7; +pub const LUA_TTHREAD: u8 = 8; +pub const LUA_TPROTO: u8 = 9; + +/// --------------------------------------------------------------------------- +// TValue (16 bytes in WoW Lua 5.0) +// +// Verified from mark_table array loop: +// 0x6F766A: SHL ESI, 0x4 ; count * 16 = TValue stride +// 0x6F7673: SUB ESI, 0x10 ; decrement by 16 per iteration +// 0x6F7680: MOV EAX, [EAX+0x8] ; gcptr at +0x08 (earlier CMP at +0x00 for tt) +/// --------------------------------------------------------------------------- + +pub const TVALUE_size: u32 = 16; +pub const TVALUE_tt: u32 = 0x00; +pub const TVALUE_gcptr: u32 = 0x08; + +/// --------------------------------------------------------------------------- +// Table struct +// +// Verified from mark_table 0x6F7590: +// 0x6F759D: MOV EDX, [EDI+0x08] ; metatable +// 0x6F7661: MOV EAX, [EDI+0x1C] ; sizearray (used as int count) +// 0x6F7670: MOV EAX, [EDI+0x0C] ; array pointer (dereferenced) +// 0x6F7697: MOV CL, [EDI+0x07] ; lsizenode (u8) +// 0x6F769A: MOV EAX, 1 +// 0x6F769F: SHL EAX, CL ; sizenode = 1 << lsizenode +// 0x6F76B0: MOV ESI, [EDI+0x10] ; node pointer +/// --------------------------------------------------------------------------- + +pub const TABLE_flags: u32 = 0x06; // TM cache / weak flags (also in marked byte) +pub const TABLE_lsizenode: u32 = 0x07; // u8: log2 of sizenode +pub const TABLE_metatable: u32 = 0x08; // Table* metatable +pub const TABLE_array: u32 = 0x0C; // TValue* array part +pub const TABLE_node: u32 = 0x10; // Node* hash part +pub const TABLE_sizearray: u32 = 0x1C; // int size of array part + +/// --------------------------------------------------------------------------- +// Node (hash bucket, 40 bytes) +// +// Verified from mark_table node loop (0x6F76B0-0x6F76FE): +// 0x6F76A5: LEA EBX, [EAX+EAX*4] ; count*5 +// 0x6F76A8: SHL EBX, 0x3 ; count*40 = Node size * count +// 0x6F76B6: MOV EAX, [ESI+EBX+0x10] ; value.tt at node+0x10 +// 0x6F76BC: TEST EAX, EAX ; skip dead nodes (nil value) +// 0x6F76C0: CMP [ESI], 0x4 ; key.tt at node+0x00 +// 0x6F76C5: MOV EDX, [ESI+0x08] ; key.gcptr at node+0x08 +// 0x6F76E3: MOV EDX, [ESI+0x18] ; value.gcptr at node+0x18 +// +// Iteration goes count-1 down to 0 (full 0-indexed range), NOT starting at 1. +/// --------------------------------------------------------------------------- + +pub const NODE_size: u32 = 40; +pub const NODE_key_tt: u32 = 0x00; +pub const NODE_key_gcptr: u32 = 0x08; +pub const NODE_value_tt: u32 = 0x10; +pub const NODE_value_gcptr: u32 = 0x18; +pub const NODE_next: u32 = 0x20; + +/// --------------------------------------------------------------------------- +// Closure (from mark_closure 0x6F77B0) +// +// Verified: +// 0x6F77B9: MOV AL, [EDI+0x06] ; isC flag +// 0x6F77C0: MOV AL, [EDI+0x07] ; nupvalues (u8) +// +// C closure branch (LEA ESI, [EDI+0x18] then [ESI-8]/[ESI] with stride 16): +// elements start at cl+0x10, each 16 bytes (TValue) +// tt at element+0x00, gcptr at element+0x08 +// +// Lua closure branch (0x6F77F6-0x6F7854): +// 0x6F77F6: MOV EDX, [EDI+0x18] ; mark this pointer +// 0x6F7804: MOV EDX, [EDI+0x0C] ; mark this pointer +// 0x6F7820: LEA EBX, [EDI+0x20] ; upval pointer array start +// 0x6F7823: MOV ESI, [EBX] ; ESI = upvals[i] (the UpVal* itself) +// upval ptr array stride = 4 bytes per entry +/// --------------------------------------------------------------------------- + +pub const CLOSURE_isC: u32 = 0x06; +pub const CLOSURE_nupvalues: u32 = 0x07; + +/// First pointer marked for Lua closures (see disasm above). +pub const CLOSURE_lua_mark1: u32 = 0x18; +/// Second pointer marked for Lua closures. +pub const CLOSURE_lua_mark2: u32 = 0x0C; + +/// C closure: upvalue array (inline TValues, each 16 bytes) starts here. +pub const CLOSURE_c_upvals: u32 = 0x10; + +/// Lua closure: upvalue POINTER array (each 4 bytes = UpVal*) starts here. +pub const CLOSURE_lua_upval_ptrs: u32 = 0x20; + +/// --------------------------------------------------------------------------- +// UpVal (verified from mark_closure's Lua upvalue loop 0x6F7820-0x6F7840) +// +// 0x6F7823: MOV ESI, [EBX] ; ESI = UpVal* (upvals[i]) +// 0x6F7825: MOV AL, [ESI+0x05] ; marked byte +// 0x6F7828: TEST AL, AL ; any non-zero = skip (whole byte) +// 0x6F782C: CMP [ESI+0x10], 0x4 ; value.tt at upv+0x10 +// 0x6F7832: MOV EDX, [ESI+0x18] ; value.gcptr at upv+0x18 +// 0x6F7840: MOV byte [ESI+0x05], 0x01 ; mark as processed +/// --------------------------------------------------------------------------- + +pub const UPVAL_marked: u32 = 0x05; +pub const UPVAL_value_tt: u32 = 0x10; +pub const UPVAL_value_gcptr: u32 = 0x18; + +/// --------------------------------------------------------------------------- +// Thread / lua_State (from mark_thread 0x6F7860) +// +// Verified: +// 0x6F7869: CMP [EDI+0x40], 0x4 ; gt.tt at thread+0x40 (TValue) +// 0x6F7872: MOV EDX, [EDI+0x48] ; gt.gcptr at thread+0x48 +// 0x6F7880: MOV EAX, [EDI+0x28] ; base_ci pointer (CallInfo*) +// 0x6F7883: MOV ESI, [EDI+0x14] ; ci pointer (CallInfo*) +// 0x6F7888: MOV ECX, [EDI+0x08] ; top (StkId) +// 0x6F78A6: MOV ESI, [EDI+0x1C] ; stack base (StkId) +// +// Stack iteration (0x6F78B0-0x6F78CE): +// stride 16 bytes per slot (TValue) +// tt at slot+0x00, gcptr at slot+0x08 +// loop: from [thread+0x1C] to [thread+0x08] +// +// CallInfo iteration stride: 24 bytes (0x18) — used to find max stack top. +/// --------------------------------------------------------------------------- + +pub const THREAD_top: u32 = 0x08; // StkId top +pub const THREAD_ci: u32 = 0x14; // CallInfo* current +pub const THREAD_stack: u32 = 0x1C; // StkId base (first slot) +pub const THREAD_base_ci: u32 = 0x28; // CallInfo* base +pub const THREAD_gt_tt: u32 = 0x40; // gt.tt (TValue holding globals) +pub const THREAD_gt_gcptr: u32 = 0x48; // gt.gcptr + +/// --------------------------------------------------------------------------- +// Proto (from mark_proto 0x6F7710) +// +// Verified: +// 0x6F7714: MOV EAX, [ESI+0x20] ; source (TString*) +// 0x6F7727: MOV EAX, [ESI+0x08] ; k (constants array TValue*) +// 0x6F772A: tt read from EAX + offset (start of TValue) +// 0x6F772F: CMP EBX, 0x4 ; check tt == LUA_TSTRING (stringmark only) +// 0x6F7734: MOV EAX, [EAX+0x08] ; gcptr at TValue+0x08 +// 0x6F773F: ADD EDX, 0x10 ; stride 16 (TValue) +// 0x6F773B: MOV EAX, [ESI+0x28] ; sizek at proto+0x28 +// 0x6F7746: MOV EDX, [ESI+0x24] ; sizeupvalues at proto+0x24 +// 0x6F7750: MOV EDX, [ESI+0x1C] ; upvalues array (TString*) at proto+0x1C +// 0x6F7762: MOV EAX, [ESI+0x34] ; sizep at proto+0x34 +// 0x6F7770: MOV EAX, [ESI+0x10] ; p (nested Proto** array) at proto+0x10 +// 0x6F7789: MOV EAX, [ESI+0x38] ; sizelocvars at proto+0x38 +// 0x6F7794: MOV EAX, [ESI+0x18] ; locvars array at proto+0x18 +// 0x6F77A2: ADD EDX, 0x0C ; LocVar stride 12 +/// --------------------------------------------------------------------------- + +pub const PROTO_k: u32 = 0x08; // TValue* constants array +pub const PROTO_p: u32 = 0x10; // Proto** nested protos array +pub const PROTO_locvars: u32 = 0x18; // LocVar* (12 bytes each) +pub const PROTO_upvalues: u32 = 0x1C; // TString** upvalue names +pub const PROTO_source: u32 = 0x20; // TString* source +pub const PROTO_sizeupvalues: u32 = 0x24; // int +pub const PROTO_sizek: u32 = 0x28; // int +pub const PROTO_sizep: u32 = 0x34; // int +pub const PROTO_sizelocvars: u32 = 0x38; // int + +pub const LOCVAR_size: u32 = 12; + +/// --------------------------------------------------------------------------- +// lua_State non-thread fields (not all threads, just the active L pointer) +/// --------------------------------------------------------------------------- + +/// Pointer to global_State (at L + 0x10). +pub const L_global: u32 = 0x10; + +/// Check if a lua_State is active. When the state is being destroyed, +/// the game stores 0 in L+0x60. +pub const L_active_check: u32 = 0x60; + +/// --------------------------------------------------------------------------- +// Userdata (from reallymarkobject LUA_TUSERDATA case) +// +// Udata layout (Lua 5.0): +// CommonHeader (next+0x00, tt+0x04, marked+0x05) +// metatable at +0x08 (same offset as Table.metatable) +// len at +0x0C +/// --------------------------------------------------------------------------- + +pub const UDATA_metatable: u32 = 0x08; + +/// --------------------------------------------------------------------------- +// Constants +/// --------------------------------------------------------------------------- + +/// Number of objects to process per incremental step. +/// ~200k objects in ~80ms = ~400ns/object → 5000 ≈ 2ms/step. +pub const CHUNK_SIZE: u32 = 5000; + +/// Headroom added to totalbytes when setting gcthreshold between steps. +pub const BATCH_HEADROOM: u32 = 128 * 1024; diff --git a/src/luagc/luaalloc.zig b/src/luagc/luaalloc.zig new file mode 100644 index 0000000..5e7864d --- /dev/null +++ b/src/luagc/luaalloc.zig @@ -0,0 +1,268 @@ +//! Lua slab allocator for the incremental GC module. +//! +//! Copied from weirdperformance/luaalloc.zig and stripped of generational +//! age-bitmap infrastructure (not needed for incremental GC). +//! +//! Hooks memory_pool_allocate (0x6FAE90) to replace WoW's 6-class slab +//! allocator with a faster Zig slab. Also hooks lua_gc_step (0x6FAE00) +//! which normally reads pool metadata to calculate GC thresholds. +//! +//! LuaMemoryRealloc (the accounting wrapper at 0x6FC980) is left intact +//! so totalbytes tracking works unmodified. + +const hook = @import("zhook"); + +// ============================================================================ +// Slab allocator -- segment-based lookup +// +// Each 64KB page is dedicated to one size class. A 64KB segment table +// maps (ptr >> 16) -> class index, giving O(1) lookup with zero +// per-allocation overhead. Free slots store a next pointer in the +// slot body (minimum slot size is 4 bytes, smallest class is 16). +// +// Pages are allocated via VirtualAlloc which guarantees 64KB alignment +// on Windows (allocation granularity). Large allocations (>4096 bytes) +// also use VirtualAlloc with a size header. +// ============================================================================ + +const MEM_COMMIT = 0x1000; +const MEM_RESERVE = 0x2000; +const MEM_RELEASE = 0x8000; +const PAGE_READWRITE = 0x04; +extern "kernel32" fn VirtualAlloc(lpAddress: ?*anyopaque, dwSize: u32, flAllocationType: u32, flProtect: u32) callconv(hook.cc.stdcall) ?[*]u8; +extern "kernel32" fn VirtualFree(lpAddress: *anyopaque, dwSize: u32, dwFreeType: u32) callconv(hook.cc.stdcall) i32; + +const SEGMENT_SHIFT = 16; // 64KB pages +const PAGE_SIZE = 1 << SEGMENT_SHIFT; // 65536 + +// Class index values: 1-15 = slab classes, LARGE_CLASS = large alloc, 0 = unowned +const LARGE_CLASS = 0xFF; + +// Segment table: one byte per 64KB of 32-bit address space = 64KB table. +var segment_table: [65536]u8 = .{0} ** 65536; + +// Slot sizes tuned from runtime profiling of 6M Lua allocations. +const class_sizes = [_]u32{ 16, 24, 32, 48, 64, 80, 96, 128, 160, 192, 256, 320, 384, 512, 640, 768, 1024, 1280, 2048, 2560, 4096 }; +const NUM_CLASSES = class_sizes.len; + +// Precomputed lookup table: size_class_lut[n] = class index for size n. +const LUT_SIZE = 4097; // covers sizes 0-4096 +const size_class_lut: [LUT_SIZE]u8 = blk: { + @setEvalBranchQuota(100000); + var lut: [LUT_SIZE]u8 = .{0xFF} ** LUT_SIZE; + lut[0] = 0; // size 0 -> class 0 + for (1..LUT_SIZE) |sz| { + for (class_sizes, 0..) |cs, ci| { + if (sz <= cs) { + lut[sz] = @intCast(ci); + break; + } + } + } + break :blk lut; +}; + +/// Find the smallest size class index that fits `size` bytes. +inline fn sizeClassIndex(size: u32) ?usize { + if (size >= LUT_SIZE) return null; + const cls = size_class_lut[size]; + if (cls == 0xFF) return null; + return cls; +} + +/// Look up class index from pointer via segment table. +inline fn classFromPtr(ptr: u32) u8 { + return segment_table[ptr >> SEGMENT_SHIFT]; +} + +var free_lists: [NUM_CLASSES]u32 = .{0} ** NUM_CLASSES; + +/// Allocate a new 64KB page for the given class, register in segment table, +/// and link all slots into the free list. +fn refillClass(class_idx: usize) bool { + const slot_size = class_sizes[class_idx]; + const page = VirtualAlloc(null, PAGE_SIZE, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE) orelse return false; + const base = @intFromPtr(page); + + // Register this page in the segment table (class indices are 0-based, + // store as idx+1 so 0 remains "unowned") + const seg = base >> SEGMENT_SHIFT; + segment_table[seg] = @intCast(class_idx + 1); + + // Carve page into slots, chain from last to first + const slots_per_page = PAGE_SIZE / slot_size; + var i: u32 = slots_per_page; + while (i > 0) { + i -= 1; + const slot_addr = base + i * slot_size; + const next_ptr: *u32 = @ptrFromInt(slot_addr); + next_ptr.* = free_lists[class_idx]; + free_lists[class_idx] = slot_addr; + } + return true; +} + +fn slabAlloc(size: u32) ?[*]u8 { + const class_idx = sizeClassIndex(size) orelse return largeMalloc(size); + + 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); + free_lists[class_idx] = next_ptr.*; + + return @ptrFromInt(slot_addr); +} + +fn slabFree(ptr: u32) void { + const seg_val = classFromPtr(ptr); + if (seg_val == LARGE_CLASS) { + largeFree(ptr); + return; + } + if (seg_val == 0) return; // not ours (shouldn't happen) + const class_idx: usize = seg_val - 1; + + const next_ptr: *u32 = @ptrFromInt(ptr); + next_ptr.* = free_lists[class_idx]; + free_lists[class_idx] = ptr; +} + +fn slabRealloc(ptr: u32, new_size: u32) ?[*]u8 { + const seg_val = classFromPtr(ptr); + if (seg_val == LARGE_CLASS) return largeRealloc(ptr, new_size); + if (seg_val == 0) return null; // not ours (shouldn't happen) + + const class_idx: usize = seg_val - 1; + const old_slot_size = class_sizes[class_idx]; + + // If new size fits in current slot, return same pointer + if (new_size <= old_slot_size) return @ptrFromInt(ptr); + + // Allocate new, copy, free old + const new_ptr = slabAlloc(new_size) orelse return null; + const copy_len = @min(old_slot_size, new_size); + const dst: [*]u8 = new_ptr; + const src: [*]const u8 = @ptrFromInt(ptr); + @memcpy(dst[0..copy_len], src[0..copy_len]); + slabFree(ptr); + return new_ptr; +} + +// ============================================================================ +// Large allocations (>4096 bytes): VirtualAlloc with 8-byte header. +// ============================================================================ + +const LARGE_HEADER = 8; +const LARGE_MAGIC: u32 = 0x4C554121; // "LUA!" + +fn largeMalloc(size: u32) ?[*]u8 { + const total = size + LARGE_HEADER; + const alloc_size = (total + 0xFFF) & ~@as(u32, 0xFFF); + const mem = VirtualAlloc(null, alloc_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE) orelse return null; + const base = @intFromPtr(mem); + + var seg = base >> SEGMENT_SHIFT; + const end_seg = (base + alloc_size - 1) >> SEGMENT_SHIFT; + while (seg <= end_seg) : (seg += 1) { + segment_table[seg] = LARGE_CLASS; + } + + const hdr_size: *u32 = @ptrFromInt(base); + const hdr_magic: *u32 = @ptrFromInt(base + 4); + hdr_size.* = size; + hdr_magic.* = LARGE_MAGIC; + return @ptrFromInt(base + LARGE_HEADER); +} + +fn largeFree(ptr: u32) void { + const base = ptr - LARGE_HEADER; + const hdr_magic: *const u32 = @ptrFromInt(base + 4); + if (hdr_magic.* != LARGE_MAGIC) return; + const hdr_size: *const u32 = @ptrFromInt(base); + const total = hdr_size.* + LARGE_HEADER; + const alloc_size = (total + 0xFFF) & ~@as(u32, 0xFFF); + + var seg = base >> SEGMENT_SHIFT; + const end_seg = (base + alloc_size - 1) >> SEGMENT_SHIFT; + while (seg <= end_seg) : (seg += 1) { + segment_table[seg] = 0; + } + + _ = VirtualFree(@ptrFromInt(base), 0, MEM_RELEASE); +} + +fn largeRealloc(ptr: u32, new_size: u32) ?[*]u8 { + const base = ptr - LARGE_HEADER; + const hdr_magic: *const u32 = @ptrFromInt(base + 4); + if (hdr_magic.* != LARGE_MAGIC) return null; + const hdr_size: *const u32 = @ptrFromInt(base); + const old_size = hdr_size.*; + + if (new_size <= old_size) return @ptrFromInt(ptr); + + const new_ptr = slabAlloc(new_size) orelse return null; + const copy_len = @min(old_size, new_size); + const dst: [*]u8 = new_ptr; + const src: [*]const u8 = @ptrFromInt(ptr); + @memcpy(dst[0..copy_len], src[0..copy_len]); + largeFree(ptr); + return new_ptr; +} + +// ============================================================================ +// memory_pool_allocate hook +// +// Original at 0x6FAE90: +// __fastcall(ECX=pool_context, EDX=old_ptr, stack: new_size) +// RET 0x4 (cleans 1 stack param) +// ============================================================================ + +const PoolAllocFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) ?[*]u8; + +var pool_alloc_hook: hook.Detour(PoolAllocFn) = .{}; + +fn poolAllocDetour(_: u32, old_ptr_raw: u32, new_size: u32) callconv(hook.cc.fastcall) ?[*]u8 { + 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). +// Original reads pool descriptor array (g+0x00) which is NULL with our +// allocator, causing a crash. We intercept and no-op: our GC module +// manages the threshold directly via setFinalThreshold. +// ============================================================================ + +const GcStepFn = fn (u32) callconv(hook.cc.fastcall) void; + +var gc_step_hook: hook.Detour(GcStepFn) = .{}; + +fn gcStepDetour(_: u32) callconv(hook.cc.fastcall) void { + // No-op. Threshold is managed by luagc.zig's setFinalThreshold. + // Must still intercept to prevent crash from NULL pool descriptors. +} + +pub fn install() u32 { + var installed: u32 = 0; + if (pool_alloc_hook.attach(0x6FAE90, &poolAllocDetour) == .ok) installed += 1; + if (gc_step_hook.attach(0x6FAE00, &gcStepDetour) == .ok) installed += 1; + return installed; +} + +pub fn remove() void { + gc_step_hook.detach(); + pool_alloc_hook.detach(); +} diff --git a/src/luagc/luagc.zig b/src/luagc/luagc.zig new file mode 100644 index 0000000..2c9990e --- /dev/null +++ b/src/luagc/luagc.zig @@ -0,0 +1,1331 @@ +//! From-scratch incremental GC for Lua 5.0 (WoW 1.12.1 build 5875). +//! +//! Replaces WoW's stop-the-world mark+sweep with a chunked incremental GC. +//! Each call to luaC_collectgarbage processes one chunk of work (~5000 objects, +//! target <2ms) then yields. A state machine tracks progress across calls: +//! +//! idle -> marking -> atomic -> sweeping -> sweep_strings -> finalize -> idle +//! +//! The module also owns allocation via a slab allocator (luaalloc.zig). +//! +//! Only two native GC functions are called: +//! - lua_gc_free_object (0x6F7260): type-dispatch free +//! - luaCallUserDataGC (0x6F7080): runs __gc metamethods +//! - luaC_separateudata (0x6F6FF0): moves dead __gc udata to tmudata +//! +//! Everything else (mark, sweep, string sweep, threshold, marktmu, weak table +//! clearing) is implemented from scratch. +//! +//! ## Tri-color incremental mark +//! WHITE = marked bit 0 (unprocessed) +//! GRAY = in gray stack (pending traversal) +//! BLACK = marked bit 1 (processed, children traversed) +//! +//! Write barriers on table writes push BLACK tables back to gray during mark. + +const std = @import("std"); +const offsets = @import("lua_offsets.zig"); +const hook = @import("zhook"); +const lua = @import("../lua.zig"); +const logging = @import("../logging.zig"); +const mod_mutex = @import("../mutex.zig"); +const luaalloc = @import("luaalloc.zig"); + +pub const module_name: [*:0]const u8 = "luagc"; + +var g_mutex: ?*anyopaque = null; +var g_is_hook_owner: bool = false; +var log: logging.Logger = .{}; + +// ============================================================================= +// Runtime stats (exposed to Lua via ZGCStats() global function) +// ============================================================================= + +const Stats = struct { + cycles_total: u32 = 0, + mark_steps_last: u32 = 0, + sweep_steps_last: u32 = 0, + gray_peak: u32 = 0, + dead_freed_last: u32 = 0, + strings_freed_last: u32 = 0, +}; + +var stats: Stats = .{}; + +// In-progress counters copied to Stats at cycle end. +var cur_mark_steps: u32 = 0; +var cur_sweep_steps: u32 = 0; +var cur_dead_freed: u32 = 0; +var cur_strings_freed: u32 = 0; + +// ============================================================================= +// GC State Machine +// ============================================================================= + +const Phase = enum(u8) { + /// No collection in progress. Next trigger starts a new cycle. + idle = 0, + /// Incremental mark: draining gray stack in chunks. + marking = 1, + /// Atomic phase: weak table clear, separateudata, marktmu, final propagate. + /// Runs to completion in a single call (bounded by tmudata size). + atomic = 2, + /// Incremental sweep of rootgc list. + sweeping = 3, + /// Incremental sweep of string hash buckets. + sweep_strings = 4, + /// Atomic: run __gc finalizers + set threshold. Short. + finalize = 5, +}; + +var phase: Phase = .idle; +var saved_L: u32 = 0; +var saved_g: u32 = 0; + +/// Objects to process per step. ~5000 = ~2ms target per step. +const CHUNK_SIZE: u32 = 1000000; + +// ============================================================================= +// Gray Stack +// ============================================================================= + +const GRAY_STACK_SIZE: u32 = 524288; // 2MB, covers 200k object heap +var gray_stack: [GRAY_STACK_SIZE]u32 = undefined; +var gray_count: u32 = 0; + +fn grayStackPush(obj: u32) void { + if (gray_count < GRAY_STACK_SIZE) { + gray_stack[gray_count] = obj; + gray_count += 1; + if (gray_count > stats.gray_peak) stats.gray_peak = gray_count; + } +} + +fn grayStackPop() u32 { + if (gray_count == 0) return 0; + gray_count -= 1; + return gray_stack[gray_count]; +} + +// ============================================================================= +// Incremental Sweep Cursors +// ============================================================================= + +/// For rootgc sweep: pointer to the "next" field that points to current obj. +/// This is &(prev->next) or &(g->rootgc). Allows in-place unlinking. +var sweep_prev_next: u32 = 0; + +/// For string sweep: current bucket index. +var sweep_string_bucket: u32 = 0; + +// ============================================================================= +// Weak Table Tracking +// ============================================================================= + +const WEAK_TABLE_LIST_SIZE: u32 = 4096; +var weak_table_list: [WEAK_TABLE_LIST_SIZE]u32 = undefined; +var weak_table_count: u32 = 0; + +fn weakTableListAdd(table: u32) void { + if (weak_table_count < WEAK_TABLE_LIST_SIZE) { + weak_table_list[weak_table_count] = table; + weak_table_count += 1; + } +} + +// ============================================================================= +// Birth Mark +// ============================================================================= + +var birth_mark_active: bool = false; + +fn setBirthMark(active: bool) void { + const val: u8 = if (active) 0x01 else 0x00; + var patch1 = [1]u8{val}; + hook.writeProtected(offsets.birth_mark_luaC_link, &patch1); + var patch2 = [1]u8{val}; + hook.writeProtected(offsets.birth_mark_string, &patch2); + birth_mark_active = active; +} + +// ============================================================================= +// Re-entrance Guard +// ============================================================================= + +var in_gc: bool = false; + +// ============================================================================= +// Forward declarations of native functions +// ============================================================================= + +const lua_gc_free_object_fn = *const fn (u32, u32) callconv(hook.cc.fastcall) void; +const luaCallUserDataGC_fn = *const fn (u32) callconv(hook.cc.fastcall) void; +const luaC_separateudata_fn = *const fn (u32) callconv(hook.cc.fastcall) void; +const lua_string_hash_resize_fn = *const fn (u32, u32) callconv(hook.cc.fastcall) void; + +const native_mark_fn = *const fn (u32) callconv(hook.cc.fastcall) u32; // mark() returns deadmem +const native_sweep_fn = *const fn (u32, u32) callconv(hook.cc.fastcall) void; // luaC_sweep(L, all) +const native_checkSizes_fn = *const fn (u32) callconv(hook.cc.fastcall) void; // checkSizes wraps shrink + +const native_free_object: lua_gc_free_object_fn = @ptrFromInt(offsets.lua_gc_free_object); +const native_callGCTM: luaCallUserDataGC_fn = @ptrFromInt(offsets.luaCallUserDataGC); +const native_separateudata: luaC_separateudata_fn = @ptrFromInt(offsets.luaC_separateudata); +const native_string_hash_resize: lua_string_hash_resize_fn = @ptrFromInt(offsets.lua_string_hash_resize); +const native_mark: native_mark_fn = @ptrFromInt(offsets.lua_gc_full_collection); +const native_shrink_memory: native_checkSizes_fn = @ptrFromInt(offsets.lua_gc_shrink_memory); + +// ============================================================================= +// Utility +// ============================================================================= + +inline fn readU32(addr: u32) u32 { + return @as(*const u32, @ptrFromInt(addr)).*; +} + +inline fn writeU32(addr: u32, val: u32) void { + @as(*u32, @ptrFromInt(addr)).* = val; +} + +inline fn readU8(addr: u32) u8 { + return @as(*const u8, @ptrFromInt(addr)).*; +} + +inline fn writeU8(addr: u32, val: u8) void { + @as(*u8, @ptrFromInt(addr)).* = val; +} + +fn getGlobalState(L: u32) u32 { + return readU32(L + offsets.L_global); +} + +inline fn objType(obj: u32) u8 { + return readU8(obj + offsets.OBJ_tt); +} + +/// Check if object has been marked by our traversal OR is fixed (bit 4). +/// Matches Lua 5.0's ismarked: `marked & ((1<<4)|1)`. +/// Used during mark to avoid re-pushing already-processed objects. +inline fn isMarked(obj: u32) bool { + return (readU8(obj + offsets.OBJ_marked) & 0x11) != 0; +} + +/// Check if object should survive sweep. Matches WoW binary's sweeplist at +/// 0x6F7222: `CMP marked, limit; JLE free` with limit=0. +/// The binary does NOT mask with ~(KEYWEAK|VALUEWEAK) despite the Lua 5.0 +/// source saying so. ANY non-zero marked byte keeps the object alive. +inline fn isAlive(obj: u32) bool { + return readU8(obj + offsets.OBJ_marked) != 0; +} + +inline fn setMarked(obj: u32) void { + writeU8(obj + offsets.OBJ_marked, readU8(obj + offsets.OBJ_marked) | 0x01); +} + +/// Clear mark bit 0 only. Preserves fixed (bit 4) and all other flags. +inline fn clearMarked(obj: u32) void { + writeU8(obj + offsets.OBJ_marked, readU8(obj + offsets.OBJ_marked) & offsets.CLEAR_MARK); +} + +fn fmt(buf: []u8, comptime f: []const u8, args: anytype) []const u8 { + return std.fmt.bufPrint(buf, f, args) catch "???"; +} + +// ============================================================================= +// Mark: Traverse One Object's Children +// ============================================================================= + +// Debug counters per type (reset each cycle) +var dbg_table_count: u32 = 0; +var dbg_closure_count: u32 = 0; +var dbg_thread_count: u32 = 0; +var dbg_proto_count: u32 = 0; +var dbg_udata_count: u32 = 0; +var dbg_other_count: u32 = 0; + +fn traverseAndPushChildren(obj: u32) void { + const tt = objType(obj); + switch (tt) { + offsets.LUA_TTABLE => { + dbg_table_count += 1; + traverseTable(obj); + }, + offsets.LUA_TFUNCTION => { + dbg_closure_count += 1; + traverseClosure(obj); + }, + offsets.LUA_TTHREAD => { + dbg_thread_count += 1; + traverseThread(obj); + }, + offsets.LUA_TPROTO => { + dbg_proto_count += 1; + traverseProto(obj); + }, + offsets.LUA_TUSERDATA => { + // Userdata: mark metatable (at udata+0x08). + // Lua 5.0 reallymarkobject does: markvalue(st, gcotou(o)->uv.metatable) + dbg_udata_count += 1; + const mt = readU32(obj + offsets.UDATA_metatable); + if (mt != 0 and !isMarked(mt)) { + setMarked(mt); + grayStackPush(mt); + } + }, + else => { + // Strings: no children. Just count. + dbg_other_count += 1; + }, + } +} + +/// Detect __mode on a table's metatable and set KEYWEAK/VALUEWEAK bits. +/// Matches native traversetable behavior (lgc.c:157-170). +/// +/// metatable->flags byte (Table+0x06) caches TM presence: if bit 3 (TM_MODE) +/// is set, __mode definitely doesn't exist. If not set, we scan the metatable's +/// hash nodes for a string key matching "__mode". +/// +/// TString layout: content at +0x10, length at +0x0C. +fn detectAndSetWeakFlags(table: u32, mt: u32) void { + // Fast path: check metatable flags cache for TM_MODE (bit 3) + const mt_flags = readU8(mt + offsets.TABLE_flags); + if ((mt_flags & 0x08) != 0) return; // TM_MODE cached as absent + + // Scan metatable hash nodes for __mode key + const mt_node_ptr = readU32(mt + offsets.TABLE_node); + if (mt_node_ptr == 0) return; + const mt_lsize = readU8(mt + offsets.TABLE_lsizenode); + const mt_sizenode: u32 = @as(u32, 1) << @as(u5, @intCast(mt_lsize)); + + var mode_str: u32 = 0; // the TString* of the __mode value + var ni: u32 = 0; + while (ni < mt_sizenode) : (ni += 1) { + const node = mt_node_ptr + ni * offsets.NODE_size; + // Key must be a string (type 4) + if (readU8(node + offsets.NODE_key_tt) != offsets.LUA_TSTRING) continue; + // Value must be non-nil + if (readU8(node + offsets.NODE_value_tt) == 0) continue; + + const key_str = readU32(node + offsets.NODE_key_gcptr); + if (key_str == 0) continue; + + // Check string content: length must be 6 ("__mode") + const len = readU32(key_str + 0x0C); + if (len != 6) continue; + + // Compare content at TString+0x10 + const data: [*]const u8 = @ptrFromInt(key_str + 0x10); + if (data[0] == '_' and data[1] == '_' and data[2] == 'm' and + data[3] == 'o' and data[4] == 'd' and data[5] == 'e') + { + // Found __mode. Value must be a string. + if (readU8(node + offsets.NODE_value_tt) != offsets.LUA_TSTRING) break; + mode_str = readU32(node + offsets.NODE_value_gcptr); + break; + } + } + + // Clear old weak bits, then set based on __mode string content + var marked = readU8(table + offsets.OBJ_marked); + marked &= ~@as(u8, 0x06); // clear KEYWEAK|VALUEWEAK + + if (mode_str != 0) { + const mode_len = readU32(mode_str + 0x0C); + const mode_data: [*]const u8 = @ptrFromInt(mode_str + 0x10); + var weakkey = false; + var weakvalue = false; + var mi: u32 = 0; + while (mi < mode_len) : (mi += 1) { + if (mode_data[mi] == 'k') weakkey = true; + if (mode_data[mi] == 'v') weakvalue = true; + } + if (weakkey) marked |= 0x02; // KEYWEAK + if (weakvalue) marked |= 0x04; // VALUEWEAK + } + + writeU8(table + offsets.OBJ_marked, marked); +} + +/// Traverse a table's children (metatable, array slots, hash nodes). +/// Weak tables are deferred to atomicClearWeakTables. +fn traverseTable(table: u32) void { + const mt = readU32(table + offsets.TABLE_metatable); + if (mt != 0 and !isMarked(mt)) { + setMarked(mt); + grayStackPush(mt); + } + + // Detect weak mode from metatable, matching native traversetable behavior. + // The native GC checks gfasttm(g, metatable, TM_MODE) and sets weak bits + // in marked. We replicate this so new tables get weak flags set. + if (mt != 0) { + detectAndSetWeakFlags(table, mt); + } + + const marked = readU8(table + offsets.OBJ_marked); + const weakkey = (marked & 0x02) != 0; // KEYWEAK + const weakvalue = (marked & 0x04) != 0; // VALUEWEAK + const is_weak = weakkey or weakvalue; + + // Array section: skip only if value-weak (matches native: `if (!weakvalue)`) + if (!weakvalue) { + const array_ptr = readU32(table + offsets.TABLE_array); + const sizearray = readU32(table + offsets.TABLE_sizearray); + if (array_ptr != 0) { + var i: u32 = 0; + while (i < sizearray) : (i += 1) { + const tv = array_ptr + i * offsets.TVALUE_size; + const tt = readU8(tv + offsets.TVALUE_tt); + if (tt >= offsets.LUA_TSTRING) { + const gc = readU32(tv + offsets.TVALUE_gcptr); + if (gc != 0 and !isMarked(gc)) { + setMarked(gc); + grayStackPush(gc); + } + } + } + } + } + + // Hash section: mark the STRONG dimension, skip the weak dimension. + // Native: condmarkobject(key, !weakkey); condmarkobject(value, !weakvalue) + const node_ptr = readU32(table + offsets.TABLE_node); + if (node_ptr == 0) { + if (is_weak) weakTableListAdd(table); + return; + } + const lsizenode = readU8(table + offsets.TABLE_lsizenode); + const sizenode: u32 = @as(u32, 1) << @as(u5, @intCast(lsizenode)); + + var ni: u32 = 0; + while (ni < sizenode) : (ni += 1) { + const node = node_ptr + ni * offsets.NODE_size; + const val_tt = readU8(node + offsets.NODE_value_tt); + if (val_tt == 0) continue; // dead node (nil value) + + // Mark key if collectable AND not key-weak + if (!weakkey) { + const key_tt = readU8(node + offsets.NODE_key_tt); + if (key_tt >= offsets.LUA_TSTRING) { + const key_gc = readU32(node + offsets.NODE_key_gcptr); + if (key_gc != 0 and !isMarked(key_gc)) { + setMarked(key_gc); + grayStackPush(key_gc); + } + } + } + + // Mark value if collectable AND not value-weak + if (!weakvalue) { + if (val_tt >= offsets.LUA_TSTRING) { + const val_gc = readU32(node + offsets.NODE_value_gcptr); + if (val_gc != 0 and !isMarked(val_gc)) { + setMarked(val_gc); + grayStackPush(val_gc); + } + } + } + } + + if (is_weak) weakTableListAdd(table); +} + +/// Traverse closure children (C upvalues or Lua env+proto+upvals). +fn traverseClosure(cl: u32) void { + const isC = readU8(cl + offsets.CLOSURE_isC); + const nups = readU8(cl + offsets.CLOSURE_nupvalues); + + if (isC != 0) { + var i: u32 = 0; + while (i < nups) : (i += 1) { + const tv = cl + offsets.CLOSURE_c_upvals + i * offsets.TVALUE_size; + const tt = readU8(tv + offsets.TVALUE_tt); + if (tt >= offsets.LUA_TSTRING) { + const gc = readU32(tv + offsets.TVALUE_gcptr); + if (gc != 0 and !isMarked(gc)) { + setMarked(gc); + grayStackPush(gc); + } + } + } + } else { + // Lua closure: mark two header pointers (env table + proto) + const m1 = readU32(cl + offsets.CLOSURE_lua_mark1); + if (m1 != 0 and !isMarked(m1)) { + setMarked(m1); + grayStackPush(m1); + } + const m2 = readU32(cl + offsets.CLOSURE_lua_mark2); + if (m2 != 0 and !isMarked(m2)) { + setMarked(m2); + grayStackPush(m2); + } + + // UpVal pointer array at cl+0x20, stride 4. + // Always process every upvalue -- do NOT skip based on marked byte. + // Open upvalues (pointing into the stack) are not in rootgc, so our + // sweep never clears their marked byte. Skipping "already processed" + // upvalues would cause their values to go unmarked in cycle 2+. + // The isMarked check on the held value prevents redundant gray pushes + // when multiple closures share the same upvalue. + var i: u32 = 0; + while (i < nups) : (i += 1) { + const upv = readU32(cl + offsets.CLOSURE_lua_upval_ptrs + i * 4); + if (upv == 0) continue; + + const val_tt = readU8(upv + offsets.UPVAL_value_tt); + if (val_tt >= offsets.LUA_TSTRING) { + const val_gc = readU32(upv + offsets.UPVAL_value_gcptr); + if (val_gc != 0 and !isMarked(val_gc)) { + setMarked(val_gc); + grayStackPush(val_gc); + } + } + + writeU8(upv + offsets.UPVAL_marked, 0x01); + } + } +} + +/// Traverse a thread: globals table + stack slots up to max(top, all ci->top). +fn traverseThread(th: u32) void { + // Globals table TValue at thread+0x40..+0x48 + const gt_tt = readU8(th + offsets.THREAD_gt_tt); + if (gt_tt >= offsets.LUA_TSTRING) { + const gt_gc = readU32(th + offsets.THREAD_gt_gcptr); + if (gt_gc != 0 and !isMarked(gt_gc)) { + setMarked(gt_gc); + grayStackPush(gt_gc); + } + } + + // lim = max(L->top, all ci->top) + var lim: u32 = readU32(th + offsets.THREAD_top); + const base_ci = readU32(th + offsets.THREAD_base_ci); + const ci = readU32(th + offsets.THREAD_ci); + if (base_ci != 0 and ci != 0 and base_ci <= ci) { + var ca: u32 = base_ci; + while (ca <= ci) : (ca += 24) { + const ci_top = readU32(ca + 0x04); + if (ci_top > lim) lim = ci_top; + } + } + + const stack_base = readU32(th + offsets.THREAD_stack); + const top = readU32(th + offsets.THREAD_top); + if (stack_base == 0 or top == 0 or stack_base >= top) return; + + // Mark active stack slots: [stack, top) + // Matches native: `for (o = L1->stack; o < L1->top; o++) markobject(st, o);` + var sp: u32 = stack_base; + while (sp < top) : (sp += offsets.TVALUE_size) { + const tt = readU8(sp + offsets.TVALUE_tt); + if (tt >= offsets.LUA_TSTRING) { + const gc = readU32(sp + offsets.TVALUE_gcptr); + if (gc != 0 and !isMarked(gc)) { + setMarked(gc); + grayStackPush(gc); + } + } + } + + // Nil stale slots above top: [top, lim] + // Matches native: `for (; o <= lim; o++) setnilvalue(o);` + // Without this, stale GC pointers from dead call frames keep dead objects alive. + if (lim > top) { + sp = top; + while (sp <= lim) : (sp += offsets.TVALUE_size) { + writeU32(sp + offsets.TVALUE_tt, 0); // tt = LUA_TNIL + writeU32(sp + offsets.TVALUE_gcptr, 0); // value = 0 + } + } +} + +/// Traverse proto: source, constants, nested protos, upvalue names, locvars. +fn traverseProto(proto: u32) void { + // source (TString*) -- stringmark + const source = readU32(proto + offsets.PROTO_source); + if (source != 0) { + writeU8(source + offsets.OBJ_marked, readU8(source + offsets.OBJ_marked) | 0x01); + } + + // Constants: only string constants + const k_ptr = readU32(proto + offsets.PROTO_k); + const sizek = readU32(proto + offsets.PROTO_sizek); + if (k_ptr != 0) { + var i: u32 = 0; + while (i < sizek) : (i += 1) { + const tv = k_ptr + i * offsets.TVALUE_size; + if (readU8(tv + offsets.TVALUE_tt) == offsets.LUA_TSTRING) { + const s = readU32(tv + offsets.TVALUE_gcptr); + if (s != 0) { + writeU8(s + offsets.OBJ_marked, readU8(s + offsets.OBJ_marked) | 0x01); + } + } + } + } + + // Upvalue names (TString**) + const upv_ptr = readU32(proto + offsets.PROTO_upvalues); + const sizeup = readU32(proto + offsets.PROTO_sizeupvalues); + if (upv_ptr != 0) { + var i: u32 = 0; + while (i < sizeup) : (i += 1) { + const s = readU32(upv_ptr + i * 4); + if (s != 0) { + writeU8(s + offsets.OBJ_marked, readU8(s + offsets.OBJ_marked) | 0x01); + } + } + } + + // Nested protos + const p_ptr = readU32(proto + offsets.PROTO_p); + const sizep = readU32(proto + offsets.PROTO_sizep); + if (p_ptr != 0) { + var i: u32 = 0; + while (i < sizep) : (i += 1) { + const sub = readU32(p_ptr + i * 4); + if (sub != 0 and !isMarked(sub)) { + setMarked(sub); + grayStackPush(sub); + } + } + } + + // LocVar names (12 bytes each, TString* at first field) + const lv_ptr = readU32(proto + offsets.PROTO_locvars); + const sizelv = readU32(proto + offsets.PROTO_sizelocvars); + if (lv_ptr != 0) { + var i: u32 = 0; + while (i < sizelv) : (i += 1) { + const s = readU32(lv_ptr + i * offsets.LOCVAR_size); + if (s != 0) { + writeU8(s + offsets.OBJ_marked, readU8(s + offsets.OBJ_marked) | 0x01); + } + } + } +} + +// ============================================================================= +// Root Mark +// ============================================================================= + +fn pushAllRoots(g: u32) void { + const before = gray_count; + + // defaultmeta (TValue at g+0x40/+0x48) + if (readU8(g + 0x40) >= offsets.LUA_TTABLE) { + const dm = readU32(g + offsets.GS_defaultmeta); + if (dm != 0 and !isMarked(dm)) { + setMarked(dm); + grayStackPush(dm); + } + } + + // registry (TValue at g+0x30/+0x38) + if (readU8(g + 0x30) >= offsets.LUA_TTABLE) { + const reg = readU32(g + offsets.GS_registry); + if (reg != 0 and !isMarked(reg)) { + setMarked(reg); + grayStackPush(reg); + } + } + + // mainthread + const mt = readU32(g + offsets.GS_mainthread); + const mt_marked_before = readU8(mt + offsets.OBJ_marked); + const mt_tt = readU8(mt + offsets.OBJ_tt); + if (!isMarked(mt)) { + setMarked(mt); + grayStackPush(mt); + } + + // current L if different + if (saved_L != 0 and saved_L != mt) { + if (!isMarked(saved_L)) { + setMarked(saved_L); + grayStackPush(saved_L); + } + } + + // Diagnostic: log root push details for first 15 cycles + if (stats.cycles_total < 15) { + var buf: [200]u8 = undefined; + log.print(fmt(&buf, " pushRoots cycle {d}: mt=0x{x} tt={d} marked=0x{x} pushed={d} gray={d}\n", .{ + stats.cycles_total + 1, mt, mt_tt, mt_marked_before, + gray_count - before, gray_count, + })); + } +} + +// ============================================================================= +// Incremental Mark Step +// ============================================================================= + +/// Process up to CHUNK_SIZE objects from the gray stack. +/// Returns true when gray stack is drained (mark complete). +fn markStep() bool { + cur_mark_steps += 1; + var processed: u32 = 0; + + while (gray_count > 0 and processed < CHUNK_SIZE) { + const obj = grayStackPop(); + if (obj == 0) break; + traverseAndPushChildren(obj); + processed += 1; + } + + return gray_count == 0; +} + +// ============================================================================= +// Atomic Phase (between mark and sweep) +// ============================================================================= +// +// Matches Lua 5.0 lgc.c mark() ordering: +// 1. cleartablevalues(wkv, wv) +// 2. luaC_separateudata(L) +// 3. marktmu +// 4. propagatemarks (drain gray stack from resurrected udata) +// 5. cleartablekeys(wk, wkv) + any newly weak tables + +fn atomicPhase(L: u32, g: u32) void { + // (1) Clear weak table VALUES + atomicClearWeakTables(); + + // (2) Separate dead userdata with __gc into tmudata + native_separateudata(L); + + // (3) Re-mark tmudata so their refs survive sweep + marktmuImpl(g); + + // (4) Drain gray stack (bounded by tmudata size, typically small) + while (gray_count > 0) { + const obj = grayStackPop(); + if (obj == 0) break; + traverseAndPushChildren(obj); + } + + // Weak table count was cleared in atomicClearWeakTables, no more to do. + weak_table_count = 0; +} + +/// Walk tmudata and re-mark each entry so references survive sweep. +fn marktmuImpl(g: u32) void { + var obj = readU32(g + offsets.GS_tmudata); + while (obj != 0) { + clearMarked(obj); + setMarked(obj); + grayStackPush(obj); + obj = readU32(obj + offsets.OBJ_next); + } +} + +/// Clear dead entries from weak tables collected during mark. +fn atomicClearWeakTables() void { + var i: u32 = 0; + while (i < weak_table_count) : (i += 1) { + const table = weak_table_list[i]; + const marked = readU8(table + offsets.OBJ_marked); + if ((marked & 0x04) != 0) clearWeakValues(table); // VALUEWEAK + if ((marked & 0x02) != 0) clearWeakKeys(table); // KEYWEAK + } +} + +/// Clear dead entries from weak-value tables. Matches native cleartablevalues. +/// For hash nodes, uses removekey semantics: nil value + set dead key to TNONE. +fn clearWeakValues(table: u32) void { + // Array part: just nil dead values + const array_ptr = readU32(table + offsets.TABLE_array); + const sizearray = readU32(table + offsets.TABLE_sizearray); + if (array_ptr != 0) { + var i: u32 = 0; + while (i < sizearray) : (i += 1) { + const tv = array_ptr + i * offsets.TVALUE_size; + const tt = readU8(tv + offsets.TVALUE_tt); + if (tt >= offsets.LUA_TSTRING) { + const gc = readU32(tv + offsets.TVALUE_gcptr); + if (gc != 0 and !isMarked(gc)) { + writeU8(tv + offsets.TVALUE_tt, offsets.LUA_TNIL); + writeU32(tv + offsets.TVALUE_gcptr, 0); + } + } + } + } + + // Hash part: removekey -- nil value AND mark collectable keys as TNONE + const node_ptr = readU32(table + offsets.TABLE_node); + if (node_ptr == 0) return; + const lsizenode = readU8(table + offsets.TABLE_lsizenode); + const sizenode: u32 = @as(u32, 1) << @as(u5, @intCast(lsizenode)); + + var ni: u32 = 0; + while (ni < sizenode) : (ni += 1) { + const node = node_ptr + ni * offsets.NODE_size; + const val_tt = readU8(node + offsets.NODE_value_tt); + if (val_tt < offsets.LUA_TSTRING) continue; + const val_gc = readU32(node + offsets.NODE_value_gcptr); + if (val_gc == 0) continue; + if (!isMarked(val_gc)) { + // removekey: nil value + writeU8(node + offsets.NODE_value_tt, offsets.LUA_TNIL); + writeU32(node + offsets.NODE_value_gcptr, 0); + // removekey: mark collectable key as dead + const key_tt = readU8(node + offsets.NODE_key_tt); + if (key_tt >= offsets.LUA_TSTRING) { + writeU8(node + offsets.NODE_key_tt, offsets.LUA_TNONE); + } + } + } +} + +/// Clear dead entries from weak-key tables. Matches native cleartablekeys. +/// Uses removekey semantics: nil value + set dead collectable key to TNONE. +fn clearWeakKeys(table: u32) void { + const node_ptr = readU32(table + offsets.TABLE_node); + if (node_ptr == 0) return; + const lsizenode = readU8(table + offsets.TABLE_lsizenode); + const sizenode: u32 = @as(u32, 1) << @as(u5, @intCast(lsizenode)); + + var ni: u32 = 0; + while (ni < sizenode) : (ni += 1) { + const node = node_ptr + ni * offsets.NODE_size; + const key_tt = readU8(node + offsets.NODE_key_tt); + if (key_tt < offsets.LUA_TSTRING) continue; + const key_gc = readU32(node + offsets.NODE_key_gcptr); + if (key_gc == 0) continue; + if (!isMarked(key_gc)) { + // removekey: nil value + writeU8(node + offsets.NODE_value_tt, offsets.LUA_TNIL); + writeU32(node + offsets.NODE_value_gcptr, 0); + // removekey: mark collectable key as dead + writeU8(node + offsets.NODE_key_tt, offsets.LUA_TNONE); + } + } +} + +// ============================================================================= +// From-Scratch Sweep: rootgc (incremental, cursor-based) +// ============================================================================= +// +// Walks rootgc in place using a prev_next pointer. For each object: +// - If marked: clear mark bit, advance cursor (object survives) +// - If unmarked: unlink from list, call lua_gc_free_object, count freed +// +// No list splitting. sweep_prev_next points to the address of the "next" +// field that leads to the current object (initially &g->rootgc). + +fn sweepRootgcStep(L: u32) bool { + cur_sweep_steps += 1; + var processed: u32 = 0; + + while (processed < CHUNK_SIZE) { + const obj = readU32(sweep_prev_next); + if (obj == 0) return true; // end of list, sweep done + + if (isAlive(obj)) { + // Alive: clear mark bit for next cycle, advance. + // Preserves fixed (bit 4) and other flags. + clearMarked(obj); + sweep_prev_next = obj + offsets.OBJ_next; + } else { + // Dead: unlink and free + const next = readU32(obj + offsets.OBJ_next); + writeU32(sweep_prev_next, next); + native_free_object(L, obj); + cur_dead_freed += 1; + } + processed += 1; + } + + // Bump threshold so GC doesn't re-trigger immediately + bumpThresholdHeadroom(saved_g); + return false; // more to sweep +} + +// ============================================================================= +// From-Scratch Sweep: rootudata (atomic, typically small) +// ============================================================================= + +fn sweepRootudata(L: u32, g: u32) void { + var prev_next: u32 = g + offsets.GS_rootudata; + while (true) { + const obj = readU32(prev_next); + if (obj == 0) break; + if (isAlive(obj)) { + clearMarked(obj); + prev_next = obj + offsets.OBJ_next; + } else { + const next = readU32(obj + offsets.OBJ_next); + writeU32(prev_next, next); + native_free_object(L, obj); + cur_dead_freed += 1; + } + } +} + +// ============================================================================= +// From-Scratch Sweep: string hash table (incremental, chunked by bucket) +// ============================================================================= +// +// Walks g->strt.hash[bucket] chains. For each bucket: +// walk chain, unlink unmarked strings, call lua_gc_free_object. +// Decrement strt.nuse for each freed string. +// Chunks by processing N buckets per call. + +fn sweepStringsStep(L: u32, g: u32) bool { + cur_sweep_steps += 1; + const hash_array = readU32(g + offsets.GS_strt_hash); + const bucket_count = readU32(g + offsets.GS_strt_size); + + if (hash_array == 0 or bucket_count == 0) return true; + + var buckets_done: u32 = 0; + while (sweep_string_bucket < bucket_count and buckets_done < CHUNK_SIZE) { + var prev_next: u32 = hash_array + sweep_string_bucket * 4; + while (true) { + const obj = readU32(prev_next); + if (obj == 0) break; + if (isAlive(obj)) { + clearMarked(obj); + prev_next = obj + offsets.OBJ_next; + } else { + const next = readU32(obj + offsets.OBJ_next); + writeU32(prev_next, next); + // Decrement strt.nuse + const nuse = readU32(g + offsets.GS_strt_nuse); + if (nuse > 0) writeU32(g + offsets.GS_strt_nuse, nuse - 1); + native_free_object(L, obj); + cur_strings_freed += 1; + } + } + sweep_string_bucket += 1; + buckets_done += 1; + } + + if (sweep_string_bucket >= bucket_count) return true; + + bumpThresholdHeadroom(g); + return false; +} + +// ============================================================================= +// Threshold Management +// ============================================================================= + +/// After sweep: threshold = 2 * totalbytes (matches Lua 5.0 checkSizes). +/// The native formula is `2 * nblocks - deadmem` but deadmem (size of +/// finalized udata) is typically negligible, so 2x is close enough. +fn setFinalThreshold(g: u32) void { + const totalbytes = readU32(g + offsets.GS_totalbytes); + writeU32(g + offsets.GS_gcthreshold, totalbytes *| 2); +} + +/// Between incremental steps: bump threshold just enough to not retrigger. +fn bumpThresholdHeadroom(g: u32) void { + const totalbytes = readU32(g + offsets.GS_totalbytes); + writeU32(g + offsets.GS_gcthreshold, totalbytes + offsets.BATCH_HEADROOM); +} + +/// Shrink string table if load factor is low (nuse < size/4). +fn maybeShrinkStringTable(L: u32, g: u32) void { + const nuse = readU32(g + offsets.GS_strt_nuse); + const size = readU32(g + offsets.GS_strt_size); + // Minimum string table size is 32 (MINSTRTABSIZE*2 in Lua 5.0) + if (nuse < size / 4 and size > 32) { + native_string_hash_resize(L, size / 2); + } +} + +// ============================================================================= +// luaC_collectgarbage Detour -- Incremental State Machine +// ============================================================================= + +/// Diagnostic modes: +/// 0 = our GC (normal) +/// 1 = full native passthrough (allocator only) +/// 2 = native mark + our sweep (isolates sweep bugs) +/// 3 = our mark + native sweep (isolates mark bugs) +const DIAG_MODE: u32 = 0; + +fn collectGarbageDetour(L: u32) callconv(hook.cc.fastcall) void { + if (DIAG_MODE == 1) return collect_hook.callOriginal(.{L}); + + if (in_gc) return; + if (readU32(L + offsets.L_active_check) == 0) return; + + in_gc = true; + defer in_gc = false; + + const g = getGlobalState(L); + saved_L = L; + saved_g = g; + + // Mode 2: native mark + our sweep -- isolates sweep bugs + if (DIAG_MODE == 2) { + _ = native_mark(L); // full native mark + atomic (weak clear, separateudata, marktmu) + // Our sweep + sweep_prev_next = g + offsets.GS_rootgc; + var done = false; + while (!done) done = sweepRootgcStep(L); + sweepRootudata(L, g); + sweep_string_bucket = 0; + done = false; + while (!done) done = sweepStringsStep(L, g); + setBirthMark(false); + const mt = readU32(g + offsets.GS_mainthread); + clearMarked(mt); + maybeShrinkStringTable(L, g); + setFinalThreshold(g); + native_callGCTM(L); + return; + } + + // Mode 3: our mark + native sweep -- isolates mark bugs + if (DIAG_MODE == 3) { + // Our mark + atomic + gray_count = 0; + weak_table_count = 0; + pushAllRoots(g); + while (gray_count > 0) { + const obj = grayStackPop(); + if (obj == 0) break; + traverseAndPushChildren(obj); + } + atomicPhase(L, g); + // Native sweep + checkSizes + callGCTM + const native_sweep: *const fn (u32, u32) callconv(hook.cc.fastcall) void = @ptrFromInt(offsets.lua_gc_sweep_all_lists); + _ = native_sweep; // sweepstrings only + // Use the individual native sweep functions + const sweeplist_fn = *const fn (u32, u32, u32) callconv(hook.cc.fastcall) u32; + const native_sweeplist: sweeplist_fn = @ptrFromInt(offsets.lua_gc_remove_objects); + const native_sweepstrings: *const fn (u32, u32) callconv(hook.cc.fastcall) void = @ptrFromInt(offsets.lua_gc_sweep_all_lists); + _ = native_sweeplist(L, g + offsets.GS_rootudata, 0); + native_sweepstrings(L, 0); + _ = native_sweeplist(L, g + offsets.GS_rootgc, 0); + const mt = readU32(g + offsets.GS_mainthread); + clearMarked(mt); + native_shrink_memory(L); + native_callGCTM(L); + return; + } + + // Mode 4: our mark + our atomic + our sweep, but all-at-once (no state machine) + // Tests whether the state machine itself is the bug. + if (DIAG_MODE == 4) { + gray_count = 0; + weak_table_count = 0; + pushAllRoots(g); + while (gray_count > 0) { + const obj = grayStackPop(); + if (obj == 0) break; + traverseAndPushChildren(obj); + } + atomicPhase(L, g); + sweep_prev_next = g + offsets.GS_rootgc; + var done = false; + while (!done) done = sweepRootgcStep(L); + sweepRootudata(L, g); + sweep_string_bucket = 0; + done = false; + while (!done) done = sweepStringsStep(L, g); + setBirthMark(false); + const mt = readU32(g + offsets.GS_mainthread); + clearMarked(mt); + if (saved_L != 0 and saved_L != mt) clearMarked(saved_L); + maybeShrinkStringTable(L, g); + setFinalThreshold(g); + native_callGCTM(L); + return; + } + + // Log entry state for first 20 cycles + if (stats.cycles_total < 20 and phase == .idle) { + const tb = readU32(g + offsets.GS_totalbytes); + const thr = readU32(g + offsets.GS_gcthreshold); + var buf: [128]u8 = undefined; + log.print(fmt(&buf, " trigger: totalbytes={d} threshold={d}\n", .{ tb, thr })); + } + + switch (phase) { + .idle => { + // Start a new GC cycle + gray_count = 0; + weak_table_count = 0; + cur_mark_steps = 0; + cur_sweep_steps = 0; + cur_dead_freed = 0; + cur_strings_freed = 0; + dbg_table_count = 0; + dbg_closure_count = 0; + dbg_thread_count = 0; + dbg_proto_count = 0; + dbg_udata_count = 0; + dbg_other_count = 0; + + // Push roots and start marking. + // Birth mark stays OFF during mark and rootgc sweep. New rootgc + // objects prepend to the head (behind our forward cursor) and are + // safe. Birth mark is only needed during string sweep where new + // strings can land in unswept hash buckets ahead of the cursor. + pushAllRoots(g); + phase = .marking; + + // Do one mark step immediately + if (markStep()) { + phase = .atomic; + runAtomicAndStartSweep(L, g); + } else { + bumpThresholdHeadroom(g); + } + }, + + .marking => { + if (markStep()) { + phase = .atomic; + runAtomicAndStartSweep(L, g); + } else { + bumpThresholdHeadroom(g); + } + }, + + .atomic => { + // Should not be called in this phase (atomic runs to completion) + // but handle gracefully + runAtomicAndStartSweep(L, g); + }, + + .sweep_strings => { + if (sweepStringsStep(L, g)) { + // Strings done, turn off birth mark before rootgc sweep + setBirthMark(false); + sweep_prev_next = g + offsets.GS_rootgc; + phase = .sweeping; + if (sweepRootgcStep(L)) { + phase = .finalize; + finalizeCycle(L, g); + } + } + }, + + .sweeping => { + if (sweepRootgcStep(L)) { + phase = .finalize; + finalizeCycle(L, g); + } + }, + + .finalize => { + // Should not happen (finalize runs to completion), handle gracefully + finalizeCycle(L, g); + }, + } +} + +/// Run atomic phase then transition to incremental sweep. +/// Sweep order matches native: rootudata (atomic) -> strings -> rootgc. +/// +/// Birth mark is ON only during string sweep: new strings can hash into +/// unswept buckets ahead of the cursor and would be incorrectly freed. +/// For rootgc, new objects prepend to the head (behind the forward cursor) +/// so birth mark is unnecessary and harmful (causes next cycle to skip +/// traversal of born-marked objects). +fn runAtomicAndStartSweep(L: u32, g: u32) void { + atomicPhase(L, g); + + // Sweep rootudata atomically (small list, fast) + sweepRootudata(L, g); + + // String sweep needs birth mark: new strings can land in unswept buckets + setBirthMark(true); + sweep_string_bucket = 0; + phase = .sweep_strings; + + // Do one string step immediately + if (sweepStringsStep(L, g)) { + // Strings done, birth mark no longer needed + setBirthMark(false); + // Start rootgc sweep (no birth mark needed) + sweep_prev_next = g + offsets.GS_rootgc; + phase = .sweeping; + if (sweepRootgcStep(L)) { + phase = .finalize; + finalizeCycle(L, g); + } + } +} + +/// Final cleanup: shrink string table, set threshold, run finalizers. +fn finalizeCycle(L: u32, g: u32) void { + setBirthMark(false); + + // The mainthread is always a root but may not be in rootgc (WoW manages + // it separately). Clear its mark so pushAllRoots can push it next cycle. + // Also clear the current L if different. + const mt = readU32(g + offsets.GS_mainthread); + clearMarked(mt); + if (saved_L != 0 and saved_L != mt) { + clearMarked(saved_L); + } + + maybeShrinkStringTable(L, g); + setFinalThreshold(g); + + // Run __gc finalizers + native_callGCTM(L); + + // Commit stats + stats.cycles_total += 1; + stats.mark_steps_last = cur_mark_steps; + stats.sweep_steps_last = cur_sweep_steps; + stats.dead_freed_last = cur_dead_freed; + stats.strings_freed_last = cur_strings_freed; + + if (stats.cycles_total <= 20 or stats.cycles_total % 100 == 0) { + const tb = readU32(g + offsets.GS_totalbytes); + const thr = readU32(g + offsets.GS_gcthreshold); + var buf: [300]u8 = undefined; + log.print(fmt(&buf, "cycle {d}: mark={d} sweep={d} | freed={d}+{d}str | totalbytes={d} threshold={d} | tbl={d} cl={d} th={d} proto={d} ud={d} other={d}\n", .{ + stats.cycles_total, stats.mark_steps_last, stats.sweep_steps_last, + stats.dead_freed_last, stats.strings_freed_last, tb, + thr, dbg_table_count, dbg_closure_count, + dbg_thread_count, dbg_proto_count, dbg_udata_count, + dbg_other_count, + })); + } + + phase = .idle; +} + +// ============================================================================= +// Write Barriers (tri-color invariant during incremental mark) +// ============================================================================= + +const TableSetFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) u32; + +var table_set_hook: hook.Detour(TableSetFn) = .{}; +var table_set_int_hook: hook.Detour(TableSetFn) = .{}; + +fn tableSetBarrier(L: u32, table: u32, key: u32) callconv(hook.cc.fastcall) u32 { + if (phase == .marking and isMarked(table)) { + grayStackPush(table); + } + return table_set_hook.callOriginal(.{ L, table, key }); +} + +fn tableSetIntBarrier(L: u32, table: u32, int_key: u32) callconv(hook.cc.fastcall) u32 { + if (phase == .marking and isMarked(table)) { + grayStackPush(table); + } + return table_set_int_hook.callOriginal(.{ L, table, int_key }); +} + +// ============================================================================= +// lua_setgcthreshold hook +// ============================================================================= +// +// WoW (and the Lua collectgarbage() function) calls lua_setgcthreshold to +// force GC by setting a low threshold. We intercept this: if the caller wants +// to force GC (low threshold), we run our GC cycle directly. Otherwise we +// let the threshold through but cap it to at least our 2x floor. + +const SetGcThresholdFn = fn (u32, u32) callconv(hook.cc.fastcall) void; +var setgcthreshold_hook: hook.Detour(SetGcThresholdFn) = .{}; + +fn setgcthresholdDetour(L: u32, newthreshold: u32) callconv(hook.cc.fastcall) void { + // The caller wants to set gcthreshold = newthreshold << 10. + // If it's a low value (forcing GC), just run our GC cycle. + // The original also calls luaC_checkGC after setting, which would + // trigger our collectGarbageDetour anyway. Skip the threshold write + // entirely -- our GC owns the threshold. + _ = newthreshold; + if (readU32(L + offsets.L_active_check) == 0) return; + const g = getGlobalState(L); + const tb = readU32(g + offsets.GS_totalbytes); + const thr = readU32(g + offsets.GS_gcthreshold); + // If threshold is already above totalbytes, no need to force GC + if (thr > tb) return; + // Otherwise trigger a GC by calling our detour path + collectGarbageDetour(L); +} + +// ============================================================================= +// ZGCStats Lua C function +// ============================================================================= + +pub fn luaZGCStats(L: lua.State) callconv(hook.cc.fastcall) i32 { + lua.pushnumber(L, @floatFromInt(stats.cycles_total)); + lua.pushnumber(L, @floatFromInt(stats.mark_steps_last)); + lua.pushnumber(L, @floatFromInt(stats.sweep_steps_last)); + lua.pushnumber(L, @floatFromInt(stats.gray_peak)); + lua.pushnumber(L, @floatFromInt(stats.dead_freed_last)); + lua.pushnumber(L, @floatFromInt(stats.strings_freed_last)); + lua.pushnumber(L, @floatFromInt(@intFromEnum(phase))); + return 7; +} + +// ============================================================================= +// Installation +// ============================================================================= + +const CollectFn = fn (u32) callconv(hook.cc.fastcall) void; +var collect_hook: hook.Detour(CollectFn) = .{}; + +pub fn isActive() bool { + return g_is_hook_owner; +} + +pub fn installHooks() void { + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return; + + log = logging.Logger.open(module_name, .both); + var installed: u32 = 0; + + // Install slab allocator + installed += luaalloc.install(); + + if (collect_hook.attach(offsets.luaC_collectgarbage, &collectGarbageDetour) == .ok) { + installed += 1; + log.print("hooked luaC_collectgarbage\n"); + } + if (table_set_hook.attach(offsets.lua_table_set_value, &tableSetBarrier) == .ok) { + installed += 1; + log.print("hooked lua_table_set_value\n"); + } + if (table_set_int_hook.attach(offsets.lua_table_set_int_key, &tableSetIntBarrier) == .ok) { + installed += 1; + log.print("hooked lua_table_set_int_key\n"); + } + if (setgcthreshold_hook.attach(offsets.lua_setgcthreshold, &setgcthresholdDetour) == .ok) { + installed += 1; + log.print("hooked lua_setgcthreshold\n"); + } + + var buf: [64]u8 = undefined; + log.print(fmt(&buf, "installed {d} hooks\n", .{installed})); +} + +pub fn removeHooks() void { + if (!g_is_hook_owner) return; + + // If mid-cycle, restore birth mark + if (birth_mark_active) { + setBirthMark(false); + } + + collect_hook.detach(); + table_set_hook.detach(); + table_set_int_hook.detach(); + setgcthreshold_hook.detach(); + luaalloc.remove(); + + phase = .idle; + in_gc = false; + gray_count = 0; + + log.close(); + mod_mutex.release(&g_mutex); +}