Files
WeirdUtils/src/luagc/luagc.zig
T
MarcelineVQ 039cec1806 luagc: 5.1 incremental GC with timing, stress tests, pre-alloc hooks (BROKEN)
Current state CRASHES on load due to pre-alloc hooks with wrong calling
conventions. pushcclosure hook is still enabled with partially disabled
others. Needs all pre-alloc hooks disabled or CCs fixed before use.

Implemented:
- Full 5.1 tri-color incremental GC
- 5.1-style singlestep budget loop (replaces chunk-based approach)
- Per-step rdtsc timing (ZGCStats returns max_step_us, atomic_us)
- Configurable gcstepsize/gcstepmul/gcpause via ZGCTune()
- Pre-alloc GC hooks for 7 API functions (5.1 luaC_checkGC pattern)
- Stress test addon: /gclife, /gcsweep, /gccompare

Broken:
- Pre-alloc hooks have CC mismatches causing immediate crash on load
- pushfstring is cdecl variadic, not fastcall (removed)
- pushstring has ambiguous CC (removed, covered by pushlstring)
- pushcclosure hook still active -- may be causing the crash
- Half-initialized objects cause garbage gcptr under heavy stress

weirdperformance: re-enabled luagc.install() + luastr + luavm
2026-04-11 13:03:37 -07:00

2200 lines
84 KiB
Zig

//! 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);
}