//! 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, max_step_us: u32 = 0, // longest single step in microseconds (last cycle) atomic_us: u32 = 0, // atomic phase duration in microseconds (last cycle) }; 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; var cur_max_step_cycles: u64 = 0; var cur_atomic_cycles: u64 = 0; /// Read CPU timestamp counter for timing. inline fn rdtsc() u64 { var lo: u32 = undefined; var hi: u32 = undefined; asm volatile ("rdtsc" : [lo] "={eax}" (lo), [hi] "={edx}" (hi), ); return @as(u64, hi) << 32 | lo; } /// Convert TSC cycles to microseconds (approximate, assumes ~3GHz). /// Good enough for relative comparisons. fn cyclesToUs(cycles: u64) u32 { return @intCast(@min(cycles / 3000, 0xFFFFFFFF)); } // ============================================================================= // 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; /// Step size in bytes -- matches 5.1 GCSTEPSIZE (default 1024). /// Controls how often GC triggers between steps: threshold = totalbytes + stepsize. var gcstepsize: u32 = 1024; /// Step multiplier -- matches 5.1 gcstepmul (default 200). /// Budget per trigger = (stepsize / 100) * stepmul bytes of work. var gcstepmul: u32 = 200; /// Pause factor -- matches 5.1 gcpause (default 200 = 2x). /// Between cycles: threshold = (totalbytes / 100) * gcpause. var gcpause: u32 = 200; /// Max objects to sweep per singlestep (5.1 GCSWEEPMAX = 40). const GCSWEEPMAX: u32 = 40; // ============================================================================= // 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]; } // ============================================================================= // Gray-Again List (barrier back) // ============================================================================= // // During incremental mark, the write barrier does NOT push tables back to the // main gray stack (that causes livelock under heavy allocation). Instead, // written-to BLACK tables go on the grayagain list. They're re-traversed once // during the atomic phase, catching all mutations since their last traversal. // This matches Lua 5.1's luaC_barrierback / grayagain pattern. const GRAYAGAIN_SIZE: u32 = 65536; var grayagain_list: [GRAYAGAIN_SIZE]u32 = undefined; var grayagain_count: u32 = 0; fn grayagainAdd(table: u32) void { if (grayagain_count < GRAYAGAIN_SIZE) { grayagain_list[grayagain_count] = table; grayagain_count += 1; } } // ============================================================================= // 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; } } // ============================================================================= // Bootstrap // ============================================================================= /// First cycle flag: existing objects from native GC have marked=0 (neither /// white). On the first cycle, walk all objects and set them to currentwhite /// so our mark can find them. var needs_bootstrap: bool = true; /// Update the string creation birth mark byte to match currentwhite. /// Strings bypass luaC_link and have their own `MOV byte [EBX+5], 0x00` /// at 0x6F9DC1. We patch this immediate to currentwhite. /// Sync birth mark patches for objects that bypass luaC_link. /// Strings: lua_create_string_object writes marked at 0x6F9DC1. /// Upvalues: lua_create_open_upvalue writes marked at 0x6F9F48. fn syncBirthMarks() void { var patch = [1]u8{currentwhite}; hook.writeProtected(offsets.birth_mark_string, &patch); hook.writeProtected(offsets.birth_mark_upvalue, &patch); } fn bootstrapWhite(g: u32) void { // Walk rootgc and set currentwhite, preserving non-color flags. // Uses makewhite pattern: (marked & MASKMARKS) | currentwhite // This preserves KEYWEAK, VALUEWEAK, FINALIZED, FIXED bits. var obj = readU32(g + offsets.GS_rootgc); while (obj != 0) { writeU8(obj + offsets.OBJ_marked, (readU8(obj + offsets.OBJ_marked) & offsets.MASKMARKS) | currentwhite); obj = readU32(obj + offsets.OBJ_next); } // Walk rootudata obj = readU32(g + offsets.GS_rootudata); while (obj != 0) { writeU8(obj + offsets.OBJ_marked, (readU8(obj + offsets.OBJ_marked) & offsets.MASKMARKS) | currentwhite); obj = readU32(obj + offsets.OBJ_next); } // Walk string hash table const hash_array = readU32(g + offsets.GS_strt_hash); const bucket_count = readU32(g + offsets.GS_strt_size); if (hash_array != 0) { var bi: u32 = 0; while (bi < bucket_count) : (bi += 1) { var s = readU32(hash_array + bi * 4); while (s != 0) { // Preserve FIXED bit (bit 4), set currentwhite const old = readU8(s + offsets.OBJ_marked); writeU8(s + offsets.OBJ_marked, (old & (1 << offsets.FIXEDBIT)) | currentwhite); s = readU32(s + offsets.OBJ_next); } } } // Mainthread (not in rootgc in WoW) const mt = readU32(g + offsets.GS_mainthread); if (mt != 0) writeU8(mt + offsets.OBJ_marked, currentwhite); needs_bootstrap = false; log.print("bootstrap: set all objects to currentwhite\n"); } // ============================================================================= // 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 volatile u32, @ptrFromInt(addr)).*; } inline fn writeU32(addr: u32, val: u32) void { @as(*volatile u32, @ptrFromInt(addr)).* = val; } inline fn readU8(addr: u32) u8 { return @as(*const volatile u8, @ptrFromInt(addr)).*; } inline fn writeU8(addr: u32, val: u8) void { @as(*volatile 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); } // ============================================================================= // Tri-color system (Lua 5.1 lgc.h port) // ============================================================================= // // Colors: // WHITE = has a white bit set (WHITE0 or WHITE1). Unprocessed / new. // GRAY = no white bits, no black bit. Found but children not yet processed. // BLACK = bit 2 set. Fully traversed. // // Two whites: currentwhite alternates between WHITE0 (0x01) and WHITE1 (0x02) // each cycle. New objects are born with currentwhite. Sweep kills objects // with "otherwhite" (the previous cycle's white). Objects with currentwhite // (born during this cycle) or BLACK survive. // // This eliminates birth mark entirely: objects born during sweep have // currentwhite which is NOT otherwhite, so they survive automatically. /// Current white color bit. Alternates between 0x01 (WHITE0) and 0x02 (WHITE1). var currentwhite: u8 = 0x01; /// The dead color for sweep: whichever white ISN'T current. inline fn otherwhite() u8 { return currentwhite ^ offsets.WHITEBITS; } /// Is the object white (has either white bit set)? inline fn isWhite(obj: u32) bool { return (readU8(obj + offsets.OBJ_marked) & offsets.WHITEBITS) != 0; } /// Is the object black (bit 2 set)? inline fn isBlack(obj: u32) bool { return (readU8(obj + offsets.OBJ_marked) & (1 << offsets.BLACKBIT)) != 0; } /// Is the object dead? Has otherwhite set AND is not fixed. /// Base check from 5.1: `(marked & otherwhite(g) & WHITEBITS)`. /// WoW extension: FIXED objects (bit 4, luaS_fix) are never dead -- they /// aren't traversed as roots but must survive (reserved words, tmnames). inline fn isDead(obj: u32) bool { const marked = readU8(obj + offsets.OBJ_marked); if ((marked & (1 << offsets.FIXEDBIT)) != 0) return false; return (marked & otherwhite() & offsets.WHITEBITS) != 0; } /// WHITE -> GRAY: clear both white bits. Object is now gray (pending traversal). inline fn white2gray(obj: u32) void { writeU8(obj + offsets.OBJ_marked, readU8(obj + offsets.OBJ_marked) & ~offsets.WHITEBITS); } /// GRAY -> BLACK: set the black bit. inline fn gray2black(obj: u32) void { writeU8(obj + offsets.OBJ_marked, readU8(obj + offsets.OBJ_marked) | (1 << offsets.BLACKBIT)); } /// BLACK -> GRAY: clear the black bit. Used for barrier-back (table written to). inline fn black2gray(obj: u32) void { writeU8(obj + offsets.OBJ_marked, readU8(obj + offsets.OBJ_marked) & ~@as(u8, 1 << offsets.BLACKBIT)); } /// Set object to current white, clearing all color bits first. /// Preserves non-color flags (KEYWEAK, VALUEWEAK, FIXED, FINALIZED). /// Used by sweep on surviving objects. inline fn makewhite(obj: u32) void { writeU8(obj + offsets.OBJ_marked, (readU8(obj + offsets.OBJ_marked) & offsets.MASKMARKS) | currentwhite); } /// Mark an object: matches Lua 5.1 reallymarkobject (lgc.c:69-112). /// Strings just get stringmarked (no gray). Userdata go black immediately. /// Tables, closures, threads, protos go to gray stack. fn markObject(obj: u32) bool { if (!isWhite(obj)) return false; const tt = objType(obj); if (tt > 10) { // Invalid type tag -- this is garbage, not a GC object. // Log and skip to prevent crash from following garbage pointers. var buf: [160]u8 = undefined; log.print(fmt(&buf, "BUG markObject(0x{x}) tt={d} marked=0x{x} caller=0x{x}\n", .{ obj, tt, readU8(obj + offsets.OBJ_marked), @returnAddress(), })); return false; } switch (tt) { offsets.LUA_TSTRING => { // Strings: just clear white bits, don't go to gray (no children) stringmark(obj); return true; }, offsets.LUA_TUSERDATA => { // Userdata: go black immediately, mark metatable inline (5.1 line 77-81) white2gray(obj); gray2black(obj); const mt = readU32(obj + offsets.UDATA_metatable); if (mt != 0) _ = markObject(mt); return true; }, offsets.LUA_TUPVAL => { // Upvalue: mark value, if closed go black, if open stay gray (5.1 line 83-88) // uv->v at +0x08. Closed if v == &uv->u.value (points to +0x10). white2gray(obj); const v_ptr = readU32(obj + 0x08); if (v_ptr != 0) { const v_tt = readU8(v_ptr + offsets.TVALUE_tt); if (v_tt >= offsets.LUA_TSTRING) { const v_gc = readU32(v_ptr + offsets.TVALUE_gcptr); if (v_gc != 0) _ = markObject(v_gc); } } // Closed upvalue: v points to inline value at obj+0x10 if (v_ptr == obj + 0x10) { gray2black(obj); } // Open upvalues stay gray (never black) return true; }, else => { // Tables, closures, threads, protos: go to gray stack white2gray(obj); grayStackPush(obj); return true; }, } } /// For strings: mark by clearing white bits (stringmark). Strings don't /// go to gray (no children to traverse). inline fn stringmark(s: u32) void { writeU8(s + offsets.OBJ_marked, readU8(s + offsets.OBJ_marked) & ~offsets.WHITEBITS); } 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; /// Pop a gray object, turn it black, and traverse its children. /// Matches Lua 5.1 propagatemark (lgc.c:277-320). /// THREADS: never go black. They go to grayagain and stay gray. /// WEAK TABLES: go back to gray after traversal (black2gray). fn propagatemark(obj: u32) void { const tt = objType(obj); switch (tt) { offsets.LUA_TTABLE => { dbg_table_count += 1; gray2black(obj); const is_weak = traverseTable(obj); if (is_weak) black2gray(obj); // weak tables stay gray }, offsets.LUA_TFUNCTION => { // Closures go to grayagain (like threads/protos). Upvalues can // be modified between mark steps via lua_replace, OP_SETUPVAL, // lua_setupvalue, and other paths. Re-traversal in atomic catches // all mutations regardless of which path modified the upvalue. dbg_closure_count += 1; traverseClosure(obj); grayagainAdd(obj); }, offsets.LUA_TTHREAD => { // Lua 5.1: threads NEVER go black. After traversal, add to // grayagain so the atomic phase re-traverses the stack. dbg_thread_count += 1; traverseThread(obj); grayagainAdd(obj); // stays gray, re-traversed in atomic }, offsets.LUA_TPROTO => { // Protos go to grayagain (like threads). The Lua compiler adds // constants, nested protos, locvars, and upvalue names between // mark steps. 5.1 uses 4 explicit barriers (lcode.c:244, // lparser.c:151,199,319). We use grayagain re-traversal instead // because the barrier sites are internal compiler functions with // complex calling conventions that vary per call site. dbg_proto_count += 1; traverseProto(obj); grayagainAdd(obj); }, offsets.LUA_TUSERDATA => { // Should not reach here -- userdata goes black in markObject. // But handle gracefully. dbg_udata_count += 1; gray2black(obj); }, else => { 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 &= ~(offsets.KEYWEAK | offsets.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 |= offsets.KEYWEAK; if (weakvalue) marked |= offsets.VALUEWEAK; } writeU8(table + offsets.OBJ_marked, marked); } /// Traverse a table's children. Returns true if the table is weak. /// Matches Lua 5.1 traversetable (lgc.c:158-196). fn traverseTable(table: u32) bool { const mt = readU32(table + offsets.TABLE_metatable); if (mt != 0) { _ = markObject(mt); detectAndSetWeakFlags(table, mt); } else { // No metatable: clear any stale weak flags from previous cycles. // Without this, a table that lost its metatable (setmetatable(t, nil)) // would keep old KEYWEAK/VALUEWEAK bits and be treated as weak. const m = readU8(table + offsets.OBJ_marked); if ((m & (offsets.KEYWEAK | offsets.VALUEWEAK)) != 0) { writeU8(table + offsets.OBJ_marked, m & ~(offsets.KEYWEAK | offsets.VALUEWEAK)); } } const marked = readU8(table + offsets.OBJ_marked); const weakkey = (marked & offsets.KEYWEAK) != 0; const weakvalue = (marked & offsets.VALUEWEAK) != 0; const is_weak = weakkey or weakvalue; // 5.1: if both-weak, return early (only metatable marked) if (weakkey and weakvalue) { if (is_weak) weakTableListAdd(table); return true; } // Array: skip if value-weak 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) _ = markObject(gc); } } } } // Hash: condmarkobject(key, !weakkey); condmarkobject(value, !weakvalue) const node_ptr = readU32(table + offsets.TABLE_node); if (node_ptr == 0) { if (is_weak) weakTableListAdd(table); return is_weak; } 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, skip (5.0 leaves key as-is) 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) _ = markObject(key_gc); } } if (!weakvalue) { if (val_tt >= offsets.LUA_TSTRING) { const val_gc = readU32(node + offsets.NODE_value_gcptr); if (val_gc != 0) _ = markObject(val_gc); } } } if (is_weak) weakTableListAdd(table); return is_weak; } /// Traverse closure children. Matches Lua 5.1 traverseclosure (lgc.c:224-238). 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) _ = markObject(gc); } } } else { // Lua closure: mark env table + proto const m1 = readU32(cl + offsets.CLOSURE_lua_mark1); if (m1 != 0) _ = markObject(m1); const m2 = readU32(cl + offsets.CLOSURE_lua_mark2); if (m2 != 0) _ = markObject(m2); // Upvalues: 5.1 reallymarkobject for LUA_TUPVAL (lgc.c:83-88): // white2gray(o), markvalue(g, uv->v), if closed: gray2black(o) // We must make the upvalue itself non-white so sweep doesn't free it. // Don't push to gray stack (upvals have no children to traverse). var i: u32 = 0; while (i < nups) : (i += 1) { const upv = readU32(cl + offsets.CLOSURE_lua_upval_ptrs + i * 4); if (upv == 0) continue; // Make upvalue non-white (gray) so it survives sweep. if (isWhite(upv)) { white2gray(upv); } // Mark the upvalue's held value. Must dereference upval->v (+0x08) // which points to the ACTUAL value location: // - Open upvalues: v points into the Lua stack // - Closed upvalues: v points to the inline copy at upval+0x10 // Reading upval+0x10 directly is WRONG for open upvalues (stale data). const v_ptr = readU32(upv + 0x08); // upval->v if (v_ptr != 0) { const val_tt = readU8(v_ptr + offsets.TVALUE_tt); if (val_tt >= offsets.LUA_TSTRING) { const val_gc = readU32(v_ptr + offsets.TVALUE_gcptr); if (val_gc != 0) _ = markObject(val_gc); } } } } } /// 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) _ = markObject(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) 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) _ = markObject(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 { const source = readU32(proto + offsets.PROTO_source); if (source != 0) stringmark(source); // Constants: mark strings, mark non-string collectables 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; const tt = readU8(tv + offsets.TVALUE_tt); if (tt == offsets.LUA_TSTRING) { const s = readU32(tv + offsets.TVALUE_gcptr); if (s != 0) stringmark(s); } else if (tt >= offsets.LUA_TSTRING) { const gc = readU32(tv + offsets.TVALUE_gcptr); if (gc != 0) _ = markObject(gc); } } } 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) stringmark(s); } } 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) _ = markObject(sub); } } 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) stringmark(s); } } } // ============================================================================= // Root Mark // ============================================================================= /// Mark root set. Matches Lua 5.1 markroot (lgc.c:501-512). fn markroot(g: u32) void { gray_count = 0; grayagain_count = 0; weak_table_count = 0; // defaultmeta if (readU8(g + 0x40) >= offsets.LUA_TTABLE) { const dm = readU32(g + offsets.GS_defaultmeta); if (dm != 0) _ = markObject(dm); } // registry if (readU8(g + 0x30) >= offsets.LUA_TTABLE) { const reg = readU32(g + offsets.GS_registry); if (reg != 0) _ = markObject(reg); } // mainthread -- not created via luaC_link (lua_open assigns directly). // Ensure it has currentwhite so markObject can find it. const mt = readU32(g + offsets.GS_mainthread); if (!isWhite(mt)) makewhite(mt); _ = markObject(mt); // Make global table be traversed before main stack (5.1 line 508) const gt_tt = readU8(mt + offsets.THREAD_gt_tt); if (gt_tt >= offsets.LUA_TSTRING) { const gt_gc = readU32(mt + offsets.THREAD_gt_gcptr); if (gt_gc != 0) _ = markObject(gt_gc); } // current L if different (also may bypass luaC_link) if (saved_L != 0 and saved_L != mt) { if (!isWhite(saved_L)) makewhite(saved_L); _ = markObject(saved_L); } } // ============================================================================= // Single Step (matches 5.1 singlestep lgc.c:556-607) // ============================================================================= // // Process one unit of GC work. Returns estimated memory cost. // Called in a budget loop by collectGarbageDetour. const GCSWEEPCOST: u32 = 10; const GCFINALIZECOST: u32 = 100; fn singlestep(L: u32, g: u32) u32 { switch (phase) { .idle => { // Start new cycle cur_mark_steps = 0; cur_sweep_steps = 0; cur_dead_freed = 0; cur_strings_freed = 0; cur_max_step_cycles = 0; cur_atomic_cycles = 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; markroot(g); phase = .marking; return 0; }, .marking => { if (gray_count > 0) { cur_mark_steps += 1; const obj = grayStackPop(); if (obj != 0) propagatemark(obj); // Return estimated cost (rough, like 5.1 propagatemark) return 100; } else { // Gray stack drained -- run atomic phase phase = .atomic; runAtomicAndStartSweep(L, g); return 0; } }, .atomic => { // Should not be called (atomic runs to completion in runAtomicAndStartSweep) runAtomicAndStartSweep(L, g); return 0; }, .sweep_strings => { 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 or sweep_string_bucket >= bucket_count) { // Strings done, start rootgc sweep sweep_prev_next = g + offsets.GS_rootgc; phase = .sweeping; return GCSWEEPCOST; } // Sweep one bucket var prev_next: u32 = hash_array + sweep_string_bucket * 4; while (true) { const obj = readU32(prev_next); if (obj == 0) break; if (!isDead(obj)) { makewhite(obj); prev_next = obj + offsets.OBJ_next; } else { const next = readU32(obj + offsets.OBJ_next); writeU32(prev_next, next); 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; return GCSWEEPCOST; }, .sweeping => { // Sweep up to GCSWEEPMAX objects from rootgc cur_sweep_steps += 1; var count: u32 = 0; while (count < GCSWEEPMAX) { const obj = readU32(sweep_prev_next); if (obj == 0) { // rootgc sweep done phase = .finalize; finalizeCycle(L, g); return 0; } if (!isDead(obj)) { makewhite(obj); sweep_prev_next = obj + offsets.OBJ_next; } else { const next = readU32(obj + offsets.OBJ_next); writeU32(sweep_prev_next, next); native_free_object(L, obj); cur_dead_freed += 1; } count += 1; } return GCSWEEPMAX * GCSWEEPCOST; }, .finalize => { finalizeCycle(L, g); return GCFINALIZECOST; }, } } /// Drain the entire gray stack (used in atomic phase). fn propagateall() void { while (gray_count > 0) { const obj = grayStackPop(); if (obj == 0) break; propagatemark(obj); } } // ============================================================================= // 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 /// Atomic phase. Matches Lua 5.1 atomic() (lgc.c:525-553). /// This runs to completion in a single call -- no yielding. fn atomicPhase(L: u32, g: u32) void { const t0 = rdtsc(); // (1) Propagate any remaining gray objects propagateall(); // (2) Re-traverse weak tables (push to gray, propagate) // In 5.1 this uses g->weak. We use our weak_table_list. // Weak tables need re-traversal because they were kept gray. var wi: u32 = 0; while (wi < weak_table_count) : (wi += 1) { const tbl = weak_table_list[wi]; grayStackPush(tbl); } propagateall(); // (3) Mark running thread + metatables (5.1 lines 536-537) _ = markObject(saved_L); // Mark defaultmeta again if (readU8(g + 0x40) >= offsets.LUA_TTABLE) { const dm = readU32(g + offsets.GS_defaultmeta); if (dm != 0) _ = markObject(dm); } propagateall(); // (4) Re-traverse grayagain: barrier-back tables + threads // 5.1 lines 539-542: g->gray = g->grayagain; propagateall var gi: u32 = 0; while (gi < grayagain_count) : (gi += 1) { const obj = grayagain_list[gi]; // Push back to gray for re-traversal grayStackPush(obj); } grayagain_count = 0; propagateall(); // (5) Separate dead userdata with __gc into tmudata native_separateudata(L); // (6) Re-mark tmudata so their refs survive sweep marktmuImpl(g); propagateall(); // (7) Clear weak tables atomicClearWeakTables(); // (8) Flip current white. New objects born after this point get the // NEW currentwhite. Sweep will free objects with the OLD white. currentwhite = otherwhite(); syncBirthMarks(); // update string creation to use new white weak_table_count = 0; grayagain_count = 0; cur_atomic_cycles = rdtsc() - t0; } /// 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) { // Make white then re-mark (5.1 marktmu: makewhite + reallymarkobject) makewhite(obj); _ = markObject(obj); // white2gray + push to gray obj = readU32(obj + offsets.OBJ_next); } } /// Clear dead entries from weak tables. Matches 5.1 cleartable (lgc.c:351-375). /// Single pass: for hash nodes, if EITHER key or value is cleared, remove entry. 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); const has_weakvalue = (marked & offsets.VALUEWEAK) != 0; const has_weakkey = (marked & offsets.KEYWEAK) != 0; // Array part: only clear if value-weak if (has_weakvalue) { const array_ptr = readU32(table + offsets.TABLE_array); const sizearray = readU32(table + offsets.TABLE_sizearray); if (array_ptr != 0) { var ai: u32 = 0; while (ai < sizearray) : (ai += 1) { const tv = array_ptr + ai * offsets.TVALUE_size; if (isCleared(readU8(tv + offsets.TVALUE_tt), readU32(tv + offsets.TVALUE_gcptr))) { writeU8(tv + offsets.TVALUE_tt, offsets.LUA_TNIL); writeU32(tv + offsets.TVALUE_gcptr, 0); } } } } // Hash part: clear if key OR value is cleared (5.1 line 367-368) const node_ptr = readU32(table + offsets.TABLE_node); if (node_ptr == 0) continue; 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; // already nil, skip const key_tt = readU8(node + offsets.NODE_key_tt); const key_gc = readU32(node + offsets.NODE_key_gcptr); const val_gc = readU32(node + offsets.NODE_value_gcptr); const key_dead = has_weakkey and isCleared(key_tt, key_gc); const val_dead = has_weakvalue and isCleared(val_tt, val_gc); if (key_dead or val_dead) { // removekey: nil value + mark collectable key as dead writeU8(node + offsets.NODE_value_tt, offsets.LUA_TNIL); writeU32(node + offsets.NODE_value_gcptr, 0); if (key_tt >= offsets.LUA_TSTRING) { writeU8(node + offsets.NODE_key_tt, offsets.LUA_TNONE); } } } } } /// Clear dead entries from weak-value tables. Matches native cleartablevalues. /// For hash nodes, uses removekey semantics: nil value + set dead key to TNONE. /// 5.1 iscleared equivalent: check if a TValue should be cleared from a weak /// table. Strings are NEVER cleared (they're "values", always kept). Non-string /// collectables are cleared if white. Non-collectables are never cleared. fn isCleared(tv_tt: u8, tv_gc: u32) bool { if (tv_tt < offsets.LUA_TSTRING) return false; // not collectable if (tv_tt == offsets.LUA_TSTRING) { // Strings are values, never weak. Mark them to ensure survival. if (tv_gc != 0) stringmark(tv_gc); return false; } if (tv_gc == 0) return false; return isWhite(tv_gc); } // ============================================================================= // 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). /// Legacy chunked sweep for DIAG_MODE only. fn sweepRootgcStep(L: u32) bool { const t0 = rdtsc(); cur_sweep_steps += 1; var processed: u32 = 0; while (processed < 1000000) { const obj = readU32(sweep_prev_next); if (obj == 0) { const elapsed = rdtsc() - t0; if (elapsed > cur_max_step_cycles) cur_max_step_cycles = elapsed; return true; } if (!isDead(obj)) { // Alive: set to current white for next cycle. // Preserves non-color flags (KEYWEAK, VALUEWEAK, FIXED). makewhite(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; } const elapsed = rdtsc() - t0; if (elapsed > cur_max_step_cycles) cur_max_step_cycles = elapsed; // threshold managed by budget loop return false; } // ============================================================================= // 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 (!isDead(obj)) { makewhite(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. /// Legacy chunked string sweep for DIAG_MODE only. fn sweepStringsStep(L: u32, g: u32) bool { const t0 = rdtsc(); cur_sweep_steps += 1; const CHUNK_SIZE = 1000000; 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 (!isDead(obj)) { makewhite(obj); prev_next = obj + offsets.OBJ_next; } else { const next = readU32(obj + offsets.OBJ_next); writeU32(prev_next, next); 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; } const elapsed = rdtsc() - t0; if (elapsed > cur_max_step_cycles) cur_max_step_cycles = elapsed; if (sweep_string_bucket >= bucket_count) return true; // threshold managed by budget loop return false; } // ============================================================================= // Threshold Management // ============================================================================= /// After sweep: threshold = (totalbytes / 100) * gcpause. /// Matches 5.1 setthreshold. Default gcpause=200 means 2x. fn setFinalThreshold(g: u32) void { const totalbytes: u64 = readU32(g + offsets.GS_totalbytes); const threshold = (totalbytes / 100) * gcpause; writeU32(g + offsets.GS_gcthreshold, @intCast(@min(threshold, 0xFFFFFFFF))); } // bumpThresholdHeadroom removed -- threshold managed by 5.1-style budget loop. /// 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); // (birth mark removed -- two-white handles this) const mt = readU32(g + offsets.GS_mainthread); makewhite(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; markroot(g); while (gray_count > 0) { const obj = grayStackPop(); if (obj == 0) break; propagatemark(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); makewhite(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; markroot(g); while (gray_count > 0) { const obj = grayStackPop(); if (obj == 0) break; propagatemark(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); // (birth mark removed -- two-white handles this) const mt = readU32(g + offsets.GS_mainthread); makewhite(mt); if (saved_L != 0 and saved_L != mt) makewhite(saved_L); maybeShrinkStringTable(L, g); setFinalThreshold(g); native_callGCTM(L); return; } // 5.1-style budget loop (luaC_step, lgc.c:610-632). // Each trigger processes (stepsize/100)*stepmul bytes of work. const t0 = rdtsc(); var lim: i32 = @intCast((gcstepsize / 100) * gcstepmul); if (lim == 0) lim = @as(i32, @intCast((@as(u32, 0x7FFFFFFF)))); // no limit while (lim > 0) { const cost = singlestep(L, g); lim -= @as(i32, @intCast(cost)); if (phase == .idle) break; } const elapsed = rdtsc() - t0; if (elapsed > cur_max_step_cycles) cur_max_step_cycles = elapsed; if (phase != .idle) { // Mid-cycle: set threshold for next trigger const totalbytes = readU32(g + offsets.GS_totalbytes); writeU32(g + offsets.GS_gcthreshold, totalbytes + gcstepsize); } } /// 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. /// Start sweep after atomic phase. /// Two-white eliminates birth mark: new objects born with currentwhite /// (already flipped in atomic), which is NOT otherwhite, so they survive. fn runAtomicAndStartSweep(L: u32, g: u32) void { atomicPhase(L, g); // Sweep rootudata atomically (small list, fast) sweepRootudata(L, g); // Initialize sweep cursors, transition to string sweep. // Actual sweep work is done by singlestep in the budget loop. sweep_string_bucket = 0; phase = .sweep_strings; } /// Final cleanup: shrink string table, set threshold, run finalizers. fn finalizeCycle(L: u32, g: u32) void { // Mainthread is not in rootgc (WoW-specific). Make it white for next cycle. const mt = readU32(g + offsets.GS_mainthread); makewhite(mt); if (saved_L != 0 and saved_L != mt) { makewhite(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; stats.max_step_us = cyclesToUs(cur_max_step_cycles); stats.atomic_us = cyclesToUs(cur_atomic_cycles); cur_max_step_cycles = 0; cur_atomic_cycles = 0; 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 { // Barrier back (5.1 luaC_barrierback): if table is BLACK during propagate, // make it gray again and add to grayagain list. if (phase == .marking and isBlack(table)) { black2gray(table); grayagainAdd(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 isBlack(table)) { black2gray(table); grayagainAdd(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 { if (readU32(L + offsets.L_active_check) == 0) return; // 5.1 luaC_fullgc pattern: if a forced GC arrives mid-cycle, // abandon the current cycle. Must reset ALL objects to white first // (matching 5.1's sweep-everything-back-to-white before restart). // Without this, BLACK objects from the abandoned mark are invisible // to the new cycle and their children may be incorrectly freed. if (phase != .idle) { const g = getGlobalState(L); // Walk rootgc and makewhite everything (clear BLACK/GRAY, set currentwhite) var obj = readU32(g + offsets.GS_rootgc); while (obj != 0) { makewhite(obj); obj = readU32(obj + offsets.OBJ_next); } // Walk rootudata obj = readU32(g + offsets.GS_rootudata); while (obj != 0) { makewhite(obj); obj = readU32(obj + offsets.OBJ_next); } // Walk string hash table const hash_array = readU32(g + offsets.GS_strt_hash); const bucket_count = readU32(g + offsets.GS_strt_size); if (hash_array != 0) { var bi: u32 = 0; while (bi < bucket_count) : (bi += 1) { var s = readU32(hash_array + bi * 4); while (s != 0) { const old = readU8(s + offsets.OBJ_marked); writeU8(s + offsets.OBJ_marked, (old & offsets.MASKMARKS) | currentwhite); s = readU32(s + offsets.OBJ_next); } } } // Mainthread const mt = readU32(g + offsets.GS_mainthread); if (mt != 0) makewhite(mt); phase = .idle; gray_count = 0; grayagain_count = 0; weak_table_count = 0; } const g = getGlobalState(L); const tb = readU32(g + offsets.GS_totalbytes); const requested = @as(u64, newthreshold) << 10; if (requested < tb) { // Caller wants to force GC (e.g. collectgarbage() passes 0) collectGarbageDetour(L); } } // ============================================================================= // lua_close hook // ============================================================================= // // lua_close (0x6F6EF0) calls luaC_sweep(L,1) directly, bypassing our // collectgarbage detour. If we're mid-cycle, our gray stack and sweep // cursors hold pointers to objects that luaC_sweep is about to free. // Reset our state to idle before the native close proceeds. const LuaCloseFn = fn (u32) callconv(hook.cc.fastcall) void; var lua_close_hook: hook.Detour(LuaCloseFn) = .{}; fn luaCloseDetour(L: u32) callconv(hook.cc.fastcall) void { // Abandon any in-progress cycle phase = .idle; gray_count = 0; grayagain_count = 0; weak_table_count = 0; in_gc = false; currentwhite = 0x01; needs_bootstrap = true; syncBirthMarks(); lua_close_hook.callOriginal(.{L}); } // ============================================================================= // luaC_link hook (two-white birth system) // ============================================================================= // // New objects must be born with currentwhite so they're alive in the current // cycle. The native luaC_link sets marked=0 which in our two-white system // means "gray" (invisible to mark). We hook it to set marked=currentwhite. const LuaCLinkFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) void; var luac_link_hook: hook.Detour(LuaCLinkFn) = .{}; fn luaCLinkDetour(L: u32, obj: u32, tt: u32) callconv(hook.cc.fastcall) void { const g = getGlobalState(L); writeU32(obj + offsets.OBJ_next, readU32(g + offsets.GS_rootgc)); writeU32(g + offsets.GS_rootgc, obj); writeU8(obj + offsets.OBJ_marked, currentwhite); writeU8(obj + offsets.OBJ_tt, @intCast(tt)); } // ============================================================================= // Pre-allocation GC check (5.1 luaC_checkGC pattern) // ============================================================================= // // 5.1 calls luaC_checkGC at the START of API functions that allocate objects, // BEFORE the allocation. This ensures pending GC work completes before any // half-initialized object is linked to rootgc. WoW's 5.0 triggers checkGC // DURING allocation (inside luaM_realloc), after luaC_link but before the // caller finishes initialization. We hook the API functions to do the // pre-check, matching 5.1's order. // ============================================================================= // Forward barriers (luaC_barrierf equivalent) // ============================================================================= // // Lua 5.1 has luaC_barrierf at every write site that creates a cross-object // reference (besides table writes which use barrierback). WoW's Lua 5.0 has // no barriers at all. We hook each function that needs one. // // The barrier: if we're in marking phase, and the written value is a WHITE // collectable GC object, mark it immediately so it survives sweep. // This prevents BLACK→WHITE edges from going unnoticed. /// Forward barrier: if a white collectable was written somewhere during /// marking, mark it now. Reads a TValue at the given address. fn barrierForward(tv_addr: u32) void { if (phase != .marking) return; const tt = readU8(tv_addr + offsets.TVALUE_tt); if (tt < offsets.LUA_TSTRING) return; // not collectable const gc = readU32(tv_addr + offsets.TVALUE_gcptr); if (gc == 0) return; if (isWhite(gc)) _ = markObject(gc); } // --- lua_setmetatable (0x6F4020) --- // __fastcall(ECX=L, EDX=objindex). Sets metatable on stack object. // 5.1 barriers: luaC_objbarriert for tables, luaC_objbarrier for userdata. const SetMetaFn = fn (u32, u32) callconv(hook.cc.fastcall) u32; var setmeta_hook: hook.Detour(SetMetaFn) = .{}; fn setmetaDetour(L: u32, objindex: u32) callconv(hook.cc.fastcall) u32 { const result = setmeta_hook.callOriginal(.{ L, objindex }); // After the native sets the metatable, barrier the new metatable. // The metatable was just pushed on the stack (L->top - 1) before the call, // then popped by the native. We can't easily get the metatable pointer // after the call. Instead, just re-mark the object's metatable field. // The object is at the given stack index. Read it, get its metatable. if (phase == .marking) { // Resolve stack index to object pointer const obj_tv = resolveStackIndex(L, objindex); if (obj_tv != 0) { const obj_tt = readU8(obj_tv + offsets.TVALUE_tt); if (obj_tt == offsets.LUA_TTABLE or obj_tt == offsets.LUA_TUSERDATA) { const obj_gc = readU32(obj_tv + offsets.TVALUE_gcptr); if (obj_gc != 0) { const mt = readU32(obj_gc + offsets.TABLE_metatable); if (mt != 0 and isWhite(mt)) _ = markObject(mt); } } } } return result; } // --- lua_setupvalue (0x6F47B0) --- // __fastcall(ECX=L, EDX=funcindex, stack=n). Sets upvalue by index. const SetUpvalFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) u32; var setupval_hook: hook.Detour(SetUpvalFn) = .{}; fn setupvalDetour(L: u32, funcindex: u32, n: u32) callconv(hook.cc.fastcall) u32 { const result = setupval_hook.callOriginal(.{ L, funcindex, n }); // After the native sets the upvalue, the value was popped from stack. // The upvalue now holds the new value. We can't easily get which upvalue // was written without re-deriving it. But the native already did the write. // For safety: if we're marking, re-traverse the closure. if (phase == .marking and result != 0) { const obj_tv = resolveStackIndex(L, funcindex); if (obj_tv != 0 and readU8(obj_tv + offsets.TVALUE_tt) == offsets.LUA_TFUNCTION) { const cl = readU32(obj_tv + offsets.TVALUE_gcptr); if (cl != 0 and isBlack(cl)) { black2gray(cl); grayagainAdd(cl); } } } return result; } // ============================================================================= // Pre-allocation GC hooks (5.1 luaC_checkGC at API entry) // ============================================================================= // // 5.1 calls luaC_checkGC at the start of every API function that allocates, // BEFORE any allocation. This ensures pending GC work completes before // half-initialized objects exist in rootgc. WoW's 5.0 lacks these checks. // We hook all 8 allocating API functions. /// Run pending GC work if threshold is exceeded. Called at API entry. fn preAllocCheck(L: u32) void { if (phase == .idle) return; if (in_gc) return; const g = getGlobalState(L); const tb = readU32(g + offsets.GS_totalbytes); const thr = readU32(g + offsets.GS_gcthreshold); if (tb >= thr) { collectGarbageDetour(L); } } // Generic detour types for different API signatures const ApiFn1 = fn (u32, u32, u32) callconv(hook.cc.fastcall) void; // pushcclosure, pushlstring const ApiFn0 = fn (u32) callconv(hook.cc.fastcall) void; // concat-like (1 arg) const ApiFn0r = fn (u32) callconv(hook.cc.fastcall) u32; // newuserdata-like (returns ptr) const ApiFn2 = fn (u32, u32) callconv(hook.cc.fastcall) void; // newtable-like const ApiFn2r = fn (u32, u32) callconv(hook.cc.fastcall) u32; // pushstring-like (returns ptr) var prealloc_pushcclosure: hook.Detour(ApiFn1) = .{}; var prealloc_pushlstring: hook.Detour(ApiFn1) = .{}; var prealloc_createtable: hook.Detour(ApiFn1) = .{}; var prealloc_pushvfstring: hook.Detour(ApiFn1) = .{}; // pushfstring is cdecl (variadic), not fastcall -- cannot use standard Detour. // var prealloc_pushfstring: hook.Detour(ApiFn1) = .{}; var prealloc_pushstring: hook.Detour(ApiFn2) = .{}; var prealloc_concat: hook.Detour(ApiFn2) = .{}; var prealloc_newuserdata: hook.Detour(ApiFn2r) = .{}; var prealloc_newthread: hook.Detour(ApiFn0r) = .{}; fn prealloc_pushcclosure_fn(L: u32, a: u32, b: u32) callconv(hook.cc.fastcall) void { preAllocCheck(L); prealloc_pushcclosure.callOriginal(.{ L, a, b }); } fn prealloc_pushlstring_fn(L: u32, a: u32, b: u32) callconv(hook.cc.fastcall) void { preAllocCheck(L); prealloc_pushlstring.callOriginal(.{ L, a, b }); } fn prealloc_createtable_fn(L: u32, a: u32, b: u32) callconv(hook.cc.fastcall) void { preAllocCheck(L); prealloc_createtable.callOriginal(.{ L, a, b }); } fn prealloc_pushvfstring_fn(L: u32, a: u32, b: u32) callconv(hook.cc.fastcall) void { preAllocCheck(L); prealloc_pushvfstring.callOriginal(.{ L, a, b }); } // pushfstring detour removed -- cdecl variadic, not fastcall fn prealloc_pushstring_fn(L: u32, a: u32) callconv(hook.cc.fastcall) void { preAllocCheck(L); prealloc_pushstring.callOriginal(.{ L, a }); } fn prealloc_concat_fn(L: u32, a: u32) callconv(hook.cc.fastcall) void { preAllocCheck(L); prealloc_concat.callOriginal(.{ L, a }); } fn prealloc_newuserdata_fn(L: u32, a: u32) callconv(hook.cc.fastcall) u32 { preAllocCheck(L); return prealloc_newuserdata.callOriginal(.{ L, a }); } fn prealloc_newthread_fn(L: u32) callconv(hook.cc.fastcall) u32 { preAllocCheck(L); return prealloc_newthread.callOriginal(.{L}); } fn installPreAllocHooks() void { // Disabled for diagnosis -- re-enable one at a time _ = prealloc_pushcclosure.attach(0x6F3920, &prealloc_pushcclosure_fn); //_ = prealloc_pushlstring.attach(0x6F3840, &prealloc_pushlstring_fn); //_ = prealloc_createtable.attach(0x6F3C90, &prealloc_createtable_fn); //_ = prealloc_pushvfstring.attach(0x6F38C0, &prealloc_pushvfstring_fn); //_ = prealloc_concat.attach(0x6F44E0, &prealloc_concat_fn); //_ = prealloc_newuserdata.attach(0x6F4560, &prealloc_newuserdata_fn); //_ = prealloc_newthread.attach(0x6F6B10, &prealloc_newthread_fn); } fn removePreAllocHooks() void { prealloc_pushcclosure.detach(); prealloc_pushlstring.detach(); prealloc_createtable.detach(); prealloc_pushvfstring.detach(); // prealloc_pushfstring not installed prealloc_pushstring.detach(); prealloc_concat.detach(); prealloc_newuserdata.detach(); prealloc_newthread.detach(); } // --- lua_setfenv (0x6F40D0) --- // __fastcall(ECX=L, EDX=idx). Sets environment table on stack object. const SetFenvFn = fn (u32, u32) callconv(hook.cc.fastcall) u32; var setfenv_hook: hook.Detour(SetFenvFn) = .{}; fn setfenvDetour(L: u32, idx: u32) callconv(hook.cc.fastcall) u32 { const result = setfenv_hook.callOriginal(.{ L, idx }); if (phase == .marking and result != 0) { const obj_tv = resolveStackIndex(L, idx); if (obj_tv != 0) { const obj_gc = readU32(obj_tv + offsets.TVALUE_gcptr); if (obj_gc != 0 and isBlack(obj_gc)) { black2gray(obj_gc); grayagainAdd(obj_gc); } } } return result; } /// Resolve a Lua stack index to the TValue address. /// Positive = from base, negative = from top, pseudo-indices = special. fn resolveStackIndex(L: u32, idx: u32) u32 { const idx_i: i32 = @bitCast(idx); if (idx_i > 0) { // Positive: base + (idx-1) * 16 const base = readU32(L + 0x0C); // L->base (ci->base typically at L+0x0C) if (base == 0) return 0; return base + @as(u32, @intCast(idx_i - 1)) * offsets.TVALUE_size; } else if (idx_i < -10000) { // Pseudo-index (globals, registry, upvalues) -- skip return 0; } else { // Negative: top + idx * 16 const top = readU32(L + offsets.THREAD_top); if (top == 0) return 0; return @as(u32, @intCast(@as(i32, @intCast(top)) + idx_i * @as(i32, offsets.TVALUE_size))); } } // ============================================================================= // lua_replace barrier (5.1 lapi.c:220-221) // ============================================================================= // // lua_replace writes to stack indices, upvalue pseudo-indices, and // LUA_ENVIRONINDEX/LUA_GLOBALSINDEX. For upvalue writes (idx < LUA_GLOBALSINDEX), // 5.1 fires luaC_barrier(L, curr_func(L), value). WoW's 5.0 has no barrier. // The native lua_replace does a direct 16-byte copy with no CALL to any // hooked function. const LuaReplaceFn = fn (u32, u32) callconv(hook.cc.fastcall) void; var lua_replace_hook: hook.Detour(LuaReplaceFn) = .{}; fn luaReplaceDetour(L: u32, idx: u32) callconv(hook.cc.fastcall) void { lua_replace_hook.callOriginal(.{ L, idx }); // Barrier for upvalue writes: idx < LUA_GLOBALSINDEX (0xffffd8f0) // LUA_GLOBALSINDEX in Lua 5.0 = -10000 (0xffffd8f0 as u32) if (phase == .marking) { const idx_i: i32 = @bitCast(idx); if (idx_i < -10000) { // Upvalue or environment write. Barrier the running closure. const ci_base = readU32(L + 0x0C); // L->base if (ci_base != 0) { // curr_func is at base[-1] (the closure TValue below the base) const func_tv = ci_base - offsets.TVALUE_size; const func_tt = readU8(func_tv + offsets.TVALUE_tt); if (func_tt == offsets.LUA_TFUNCTION) { const func_gc = readU32(func_tv + offsets.TVALUE_gcptr); if (func_gc != 0 and isBlack(func_gc)) { black2gray(func_gc); grayagainAdd(func_gc); } } } } } } // ============================================================================= // String resurrection (5.1 lstring.c:88) // ============================================================================= // // During incremental sweep, the string intern table can return a dead string // (one with otherwhite, not yet swept). Lua 5.1 handles this in luaS_newlstr: // if (isdead(G(L), o)) changewhite(o); /* resurrect */ // WoW's Lua 5.0 has no such check. We hook lua_create_string_object and // resurrect dead strings after the native returns them. const StringCreateFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) u32; var string_create_hook: hook.Detour(StringCreateFn) = .{}; fn stringCreateDetour(L: u32, str_ptr: u32, len: u32) callconv(hook.cc.fastcall) u32 { const result = string_create_hook.callOriginal(.{ L, str_ptr, len }); // If the returned string is dead (has otherwhite), resurrect it if (result != 0) { const marked = readU8(result + offsets.OBJ_marked); if ((marked & otherwhite() & offsets.WHITEBITS) != 0) { // changewhite: flip white bits (dead white → alive white) writeU8(result + offsets.OBJ_marked, marked ^ offsets.WHITEBITS); } } return result; } // ============================================================================= // Upvalue resurrection (5.1 lfunc.c:61-62) // ============================================================================= // // Same pattern as strings: luaF_findupval can return a dead open upvalue // during incremental sweep. Resurrect it. const UpvalCreateFn = fn (u32, u32) callconv(hook.cc.fastcall) u32; var upval_create_hook: hook.Detour(UpvalCreateFn) = .{}; fn upvalCreateDetour(L: u32, level: u32) callconv(hook.cc.fastcall) u32 { const result = upval_create_hook.callOriginal(.{ L, level }); if (result != 0) { const marked = readU8(result + offsets.OBJ_marked); if ((marked & otherwhite() & offsets.WHITEBITS) != 0) { writeU8(result + offsets.OBJ_marked, marked ^ offsets.WHITEBITS); } } return result; } // ============================================================================= // Compiler barriers (5.1 lcode.c:244, lparser.c:151,199,319) // ============================================================================= // // The Lua compiler adds constants, nested protos, locvars, and upvalue names // to proto objects during compilation. If GC runs mid-compilation (multi-step // mark), a proto can be BLACK when the compiler adds new WHITE children. // 5.1 has luaC_barrier/luaC_objbarrier after each such write. // We hook the compiler functions and apply barrier-back on the proto. const CreateConstantFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) void; var create_constant_hook: hook.Detour(CreateConstantFn) = .{}; fn createConstantDetour(lexstate: u32, value: u32, typ: u32) callconv(hook.cc.fastcall) void { create_constant_hook.callOriginal(.{ lexstate, value, typ }); // All callers pass LexState in ECX (verified from Ghidra: parse_string_field, // parseFunctionCall, parse_primary_expression all pass the same struct). // LexState+0x30 = FuncState, FuncState+0x00 = Proto. if (phase == .marking and lexstate != 0) { const fs = readU32(lexstate + 0x30); if (fs != 0) { const proto = readU32(fs); if (proto != 0 and isBlack(proto)) { black2gray(proto); grayagainAdd(proto); } } } } const FinishFunctionFn = fn (u32) callconv(hook.cc.fastcall) void; var finish_function_hook: hook.Detour(FinishFunctionFn) = .{}; fn finishFunctionDetour(lexstate: u32) callconv(hook.cc.fastcall) void { // Barrier BEFORE native: proto arrays are about to be resized. // LexState+0x30 = FuncState, FuncState+0x00 = Proto (verified Ghidra). if (phase == .marking and lexstate != 0) { const fs = readU32(lexstate + 0x30); if (fs != 0) { const proto = readU32(fs); if (proto != 0 and isBlack(proto)) { black2gray(proto); grayagainAdd(proto); } } } finish_function_hook.callOriginal(.{lexstate}); } // ============================================================================= // OP_SETUPVAL VM patch (forward barrier) // ============================================================================= // // At 0x6F8A97, right after the 16-byte TValue copy in OP_SETUPVAL handler, // EDX holds uv->v (the destination pointer -- the TValue just written). // We patch 5 bytes at 0x6F8A97 with CALL to our barrier trampoline. // The trampoline checks if the written value is WHITE and marks it. // // Original bytes at 0x6F8A97: 8B 7F 04 85 FF (MOV EDI,[EDI+4]; TEST EDI,EDI) // These are debug hook code, not critical to SETUPVAL functionality. // We overwrite with: E8 XX XX XX XX (CALL rel32 to our function) // Our function does the barrier check then executes the overwritten instructions. var setupval_patch_installed: bool = false; var setupval_original_bytes: [5]u8 = undefined; fn installSetupvalPatch() void { // Read original bytes const src: [*]const u8 = @ptrFromInt(0x6F8A97); @memcpy(&setupval_original_bytes, src[0..5]); // Write CALL rel32 to our barrier const target = @intFromPtr(&setupvalBarrierTrampoline); const rel: i32 = @intCast(@as(i64, target) - (0x6F8A97 + 5)); var patch: [5]u8 = undefined; patch[0] = 0xE8; // CALL rel32 patch[1..5].* = @bitCast(rel); hook.writeProtected(0x6F8A97, &patch); setupval_patch_installed = true; } fn removeSetupvalPatch() void { if (setupval_patch_installed) { hook.writeProtected(0x6F8A97, &setupval_original_bytes); setupval_patch_installed = false; } } /// Called from the VM after OP_SETUPVAL's TValue copy. /// EDX = uv->v (the TValue that was just written to). /// Must preserve all registers and flags, execute the overwritten instructions, /// then return. fn setupvalBarrierTrampoline() callconv(.naked) void { // Called from VM after OP_SETUPVAL TValue copy. EDX = uv->v. // Check if written value is a white collectable during mark, mark it if so. // Must preserve all registers and execute overwritten instructions before ret. asm volatile ( \\ pushf \\ push %%eax \\ push %%ecx // Check phase == .marking (1) \\ movl %[phase_ptr], %%eax \\ cmpb $1, (%%eax) \\ jne 1f // EDX = uv->v. Check tt >= LUA_TSTRING (4) \\ movzbl (%%edx), %%eax \\ cmpl $4, %%eax \\ jb 1f // Read gcptr at [EDX+8] \\ movl 8(%%edx), %%eax \\ testl %%eax, %%eax \\ jz 1f // Check if white (marked & 0x03) \\ testb $0x03, 5(%%eax) \\ jz 1f // White collectable during mark -> call markObject via indirect \\ push %%edx \\ push %%eax \\ movl %[mark_fn], %%ecx \\ call *%%ecx \\ addl $4, %%esp \\ pop %%edx \\1: \\ pop %%ecx \\ pop %%eax \\ popf // Execute overwritten: MOV EDI,[EDI+4]; TEST EDI,EDI \\ movl 4(%%edi), %%edi \\ testl %%edi, %%edi \\ ret : : [phase_ptr] "i" (&phase), [mark_fn] "i" (&markObjectCdecl), ); } fn markObjectCdecl(obj: u32) callconv(.c) void { _ = markObject(obj); } // ============================================================================= // 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))); lua.pushnumber(L, @floatFromInt(stats.max_step_us)); lua.pushnumber(L, @floatFromInt(stats.atomic_us)); return 9; } /// ZGCTune(stepsize, stepmul, pause) -- set GC tuning parameters. /// stepsize: bytes between incremental steps (default 1024, 5.1 GCSTEPSIZE) /// stepmul: work multiplier per step (default 200, 5.1 gcstepmul) /// pause: cycle threshold factor (default 200 = 2x, 5.1 gcpause) /// Returns previous values. pub fn luaZGCTune(L: lua.State) callconv(hook.cc.fastcall) i32 { // Return old values lua.pushnumber(L, @floatFromInt(gcstepsize)); lua.pushnumber(L, @floatFromInt(gcstepmul)); lua.pushnumber(L, @floatFromInt(gcpause)); // Set new values if provided const top = lua.gettop(L); if (top >= 1) { const v = lua.tonumber(L, 1); if (v > 0) gcstepsize = @intFromFloat(v); } if (top >= 2) { const v = lua.tonumber(L, 2); if (v > 0) gcstepmul = @intFromFloat(v); } if (top >= 3) { const v = lua.tonumber(L, 3); if (v > 0) gcpause = @intFromFloat(v); } return 3; } // ============================================================================= // 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"); } if (lua_close_hook.attach(offsets.lua_close, &luaCloseDetour) == .ok) { installed += 1; log.print("hooked lua_close\n"); } if (luac_link_hook.attach(offsets.luaC_link, &luaCLinkDetour) == .ok) { installed += 1; log.print("hooked luaC_link\n"); } // lua_replace barrier (5.1 lapi.c:220-221) if (lua_replace_hook.attach(offsets.lua_replace, &luaReplaceDetour) == .ok) { installed += 1; } // Resurrection hooks (5.1 lstring.c:88, lfunc.c:61-62) if (string_create_hook.attach(offsets.luaS_newlstr, &stringCreateDetour) == .ok) { installed += 1; } if (upval_create_hook.attach(offsets.lua_create_open_upvalue, &upvalCreateDetour) == .ok) { installed += 1; } // Forward barriers (5.1 luaC_barrierf equivalents) if (setmeta_hook.attach(offsets.lua_setmetatable, &setmetaDetour) == .ok) { installed += 1; } if (setupval_hook.attach(offsets.lua_setupvalue, &setupvalDetour) == .ok) { installed += 1; } if (setfenv_hook.attach(offsets.lua_setfenv, &setfenvDetour) == .ok) { installed += 1; } // Note: lua_pushcclosure doesn't need a barrier (new closures are WHITE) // Pre-allocation GC check (5.1 pattern: luaC_checkGC before allocation). // Ensures pending GC work completes before half-initialized objects exist. installPreAllocHooks(); // Compiler barriers: covered by proto grayagain in propagatemark. // Direct hooks removed -- proto is re-traversed in atomic phase instead. installSetupvalPatch(); log.print("installed forward barriers\n"); syncBirthMarks(); 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 // Reset birth marks to native defaults var patch0 = [1]u8{0x00}; hook.writeProtected(offsets.birth_mark_string, &patch0); var patch1 = [1]u8{0x01}; hook.writeProtected(offsets.birth_mark_upvalue, &patch1); collect_hook.detach(); table_set_hook.detach(); table_set_int_hook.detach(); setgcthreshold_hook.detach(); lua_close_hook.detach(); luac_link_hook.detach(); lua_replace_hook.detach(); removePreAllocHooks(); string_create_hook.detach(); upval_create_hook.detach(); setmeta_hook.detach(); setupval_hook.detach(); setfenv_hook.detach(); // create_constant_hook.detach(); // not installed // finish_function_hook.detach(); // not installed removeSetupvalPatch(); luaalloc.remove(); phase = .idle; in_gc = false; gray_count = 0; log.close(); mod_mutex.release(&g_mutex); }