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
This commit is contained in:
MarcelineVQ
2026-04-11 13:03:37 -07:00
parent 8d3460cf1e
commit 039cec1806
7 changed files with 1255 additions and 136 deletions
+376 -1
View File
@@ -185,7 +185,382 @@ SlashCmdList["GCCOMPARE"] = function(args)
end
-- =========================================================================
-- zluagen stats frame — visible by default, center screen, movable
-- /gclife [duration]: mixed-age residency stress test
-- Simulates realistic addon memory patterns:
-- - Long-lived config tables (persist entire test)
-- - Medium-lived frame data (replaced every few seconds)
-- - Short-lived closures and strings (created/discarded each frame)
-- - Weak table caches (entries die and get cleared)
-- - Nested tables with upvalue captures
-- Runs per-frame for [duration] seconds (default 20), reports timing.
-- =========================================================================
local gclife_frame = CreateFrame("Frame")
local gclife_running = false
local gclife_end_time = 0
local gclife_frame_times = {}
local gclife_frame_count = 0
local gclife_last_time = 0
-- Long-lived: addon config tables (never replaced during test)
local gclife_config = {}
-- Medium-lived: frame data (replaced every ~2 seconds)
local gclife_frames = {}
local gclife_frames_age = 0
-- Weak cache: entries die when not referenced elsewhere
local gclife_weak_cache = setmetatable({}, { __mode = "v" })
-- Closure registry: closures with upvalue captures
local gclife_closures = {}
local function gclife_build_config()
-- 2000 addon configs with 20 callbacks each = 40k closures + 2k tables
for i = 1, 2000 do
gclife_config["addon_" .. i] = {
enabled = true,
settings = { scale = 1.0, alpha = 0.8, x = i, y = i * 2 },
history = {},
callbacks = {},
data = {},
}
local cfg = gclife_config["addon_" .. i]
-- Deep nested data (3 levels)
for j = 1, 10 do
cfg.data[j] = {
entries = {},
meta = { created = i * 1000 + j, tag = "d" .. j },
}
for k = 1, 5 do
cfg.data[j].entries[k] = { id = k, val = "item_" .. i .. "_" .. j .. "_" .. k }
end
end
-- 20 callback closures capturing different upvalues
for j = 1, 20 do
local slot = j
local data = cfg.data[math.mod(j, 10) + 1]
cfg.callbacks[j] = function() return cfg.settings.scale * slot + data.meta.created end
end
end
end
local function gclife_rebuild_frames()
-- 800 frames with 20 children each = 16k tables + 1600 closures
gclife_frames = {}
for i = 1, 800 do
gclife_frames[i] = {
name = "Frame" .. i,
children = {},
scripts = {},
visible = math.mod(i, 3) ~= 0,
textures = {},
}
for j = 1, 20 do
gclife_frames[i].children[j] = {
parent = gclife_frames[i],
id = i * 100 + j,
text = string.format("child_%d_%d", i, j),
tooltip = "Tooltip for " .. i .. ":" .. j,
}
end
-- Texture references (string heavy)
for j = 1, 5 do
gclife_frames[i].textures[j] = "Interface\\Icons\\Icon_" .. (i * 5 + j)
end
local f = gclife_frames[i]
f.scripts.OnUpdate = function() return f.name end
f.scripts.OnClick = function() f.visible = not f.visible end
end
gclife_frames_age = 0
end
local function gclife_per_frame()
-- Heavy short-lived: 500 event tables + strings each frame
local temps = {}
for i = 1, 500 do
local msg = "event_" .. math.random(10000) .. "_" .. GetTime()
temps[i] = {
type = "CHAT",
msg = msg,
time = GetTime(),
source = "Player" .. math.random(40),
args = { math.random(100), "spell_" .. math.random(500), math.random() > 0.5 },
}
end
-- 200 short-lived closures (simulates addon callbacks, iterators)
for i = 1, 200 do
local val = math.random(1000)
local name = "cb_" .. i
local fn = function() return val * 2 + string.len(name) end
fn()
end
-- Heavy weak cache churn: 100 entries, some pinned
for i = 1, 100 do
local key = "cache_" .. math.random(2000)
local entry = {
data = math.random(),
ts = GetTime(),
payload = { math.random(), "str_" .. math.random(1000) },
}
gclife_weak_cache[key] = entry
if math.mod(i, 10) == 0 then
local cfg_key = "addon_" .. math.random(2000)
if gclife_config[cfg_key] then
table.insert(gclife_config[cfg_key].history, entry)
if table.getn(gclife_config[cfg_key].history) > 20 then
table.remove(gclife_config[cfg_key].history, 1)
end
end
end
end
-- 50 upvalue mutations on existing closures
for i = 1, 50 do
local idx = math.random(2000)
local cfg = gclife_config["addon_" .. idx]
if cfg then
cfg.settings.scale = math.random() * 2
cfg.settings.alpha = math.random()
end
end
-- String interning pressure: lookup existing + create new
for i = 1, 200 do
local _ = "addon_" .. math.random(2000) -- hits intern table
local _ = string.format("fmt_%d_%d_%s", i, math.random(100), GetTime())
end
-- Medium-lived replacement every ~2 seconds
gclife_frames_age = gclife_frames_age + 1
if gclife_frames_age > 120 then
gclife_rebuild_frames()
end
temps = nil
end
local function gclife_start(args)
local duration = tonumber(args) or 20
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ff00GC Life|r: building mixed-age heap, running %d seconds...", duration))
-- Build long-lived structures
gclife_config = {}
gclife_build_config()
gclife_rebuild_frames()
gclife_weak_cache = setmetatable({}, { __mode = "v" })
-- Reset counters
gclife_frame_times = {}
gclife_frame_count = 0
gclife_last_time = debugprofilestop and 0 or GetTime()
gclife_end_time = GetTime() + duration
gclife_running = true
debugprofilestart()
gclife_frame:SetScript("OnUpdate", function()
if not gclife_running then return end
if GetTime() >= gclife_end_time then
gclife_running = false
gclife_frame:SetScript("OnUpdate", nil)
-- Report results
local n = gclife_frame_count
if n == 0 then return end
table.sort(gclife_frame_times)
local p50 = gclife_frame_times[math.floor(n * 0.5)] or 0
local p95 = gclife_frame_times[math.floor(n * 0.95)] or 0
local p99 = gclife_frame_times[math.floor(n * 0.99)] or 0
local max_t = gclife_frame_times[n] or 0
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ff00GC Life|r: %d frames over %ds", n, duration))
DEFAULT_CHAT_FRAME:AddMessage(string.format(
" p50=|cffffd700%.1f ms|r p95=|cffffd700%.1f ms|r p99=|cffffd700%.1f ms|r max=|cffff0000%.1f ms|r",
p50, p95, p99, max_t))
if type(ZGCStats) == "function" then
local total, mark, sweep, gray, freed, fstr, ph, stepus, atomicus = ZGCStats()
DEFAULT_CHAT_FRAME:AddMessage(string.format(
" GC: step_max=%dus atomic=%dus last_mark=%d last_sweep=%d",
stepus, atomicus, mark, sweep))
end
-- Cleanup
gclife_config = {}
gclife_frames = {}
gclife_closures = {}
gclife_weak_cache = nil
collectgarbage()
return
end
debugprofilestart()
gclife_per_frame()
local elapsed = debugprofilestop()
gclife_frame_count = gclife_frame_count + 1
table.insert(gclife_frame_times, elapsed)
end)
end
SLASH_GCLIFE1 = "/gclife"
SlashCmdList["GCLIFE"] = gclife_start
-- =========================================================================
-- /gcsweep [seconds_per_combo]: auto-tune GC parameters
-- Runs /gclife workload with many (stepsize, stepmul, pause) combinations,
-- reports p99 frame time for each. Finds the empirical best defaults.
-- =========================================================================
local gcsweep_combos = {
-- { stepsize, stepmul, pause, label }
-- Vary stepsize
{ 256, 200, 200, "step=256" },
{ 512, 200, 200, "step=512" },
{ 1024, 200, 200, "step=1024 (5.1 default)" },
{ 2048, 200, 200, "step=2048" },
{ 4096, 200, 200, "step=4096" },
{ 8192, 200, 200, "step=8192" },
-- Vary stepmul
{ 1024, 100, 200, "mul=100" },
{ 1024, 200, 200, "mul=200 (5.1 default)" },
{ 1024, 400, 200, "mul=400" },
{ 1024, 800, 200, "mul=800" },
-- Vary pause
{ 1024, 200, 150, "pause=150 (1.5x)" },
{ 1024, 200, 200, "pause=200 (2x, default)" },
{ 1024, 200, 300, "pause=300 (3x)" },
{ 1024, 200, 400, "pause=400 (4x)" },
-- Promising combos
{ 2048, 400, 200, "step=2k mul=400" },
{ 4096, 400, 200, "step=4k mul=400" },
{ 2048, 200, 150, "step=2k pause=1.5x" },
{ 512, 100, 150, "small+aggressive" },
{ 4096, 800, 300, "big+lazy" },
}
local gcsweep_running = false
local gcsweep_combo_idx = 0
local gcsweep_seconds = 0
local gcsweep_results = {}
local gcsweep_frame = CreateFrame("Frame")
-- Reuse gclife's per-frame workload
local gcsweep_end_time = 0
local gcsweep_frame_times = {}
local gcsweep_frame_count = 0
local function gcsweep_run_next()
gcsweep_combo_idx = gcsweep_combo_idx + 1
if gcsweep_combo_idx > table.getn(gcsweep_combos) then
-- All done, report results
gcsweep_running = false
gcsweep_frame:SetScript("OnUpdate", nil)
-- Restore defaults
ZGCTune(1024, 200, 200)
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00GC Sweep|r: results (sorted by p99):")
table.sort(gcsweep_results, function(a, b) return a.p99 < b.p99 end)
for _, r in ipairs(gcsweep_results) do
local color = "|cff00ff00"
if r.p99 > 2.0 then color = "|cffff0000"
elseif r.p99 > 1.0 then color = "|cffffff00"
end
DEFAULT_CHAT_FRAME:AddMessage(string.format(
" %sp50=%.1f p95=%.1f p99=%.1f max=%.1f|r %s",
color, r.p50, r.p95, r.p99, r.max, r.label))
end
local best = gcsweep_results[1]
if best then
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ff00BEST|r: %s (p99=%.1f ms)", best.label, best.p99))
end
-- Cleanup
gclife_config = {}
gclife_frames = {}
gclife_closures = {}
gclife_weak_cache = nil
collectgarbage()
return
end
local combo = gcsweep_combos[gcsweep_combo_idx]
ZGCTune(combo[1], combo[2], combo[3])
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ff00GC Sweep|r [%d/%d]: %s ...",
gcsweep_combo_idx, table.getn(gcsweep_combos), combo[4]))
-- Rebuild heap fresh for each combo
gclife_config = {}
gclife_build_config()
gclife_rebuild_frames()
gclife_weak_cache = setmetatable({}, { __mode = "v" })
collectgarbage()
gcsweep_frame_times = {}
gcsweep_frame_count = 0
gcsweep_end_time = GetTime() + gcsweep_seconds
gcsweep_frame:SetScript("OnUpdate", function()
if not gcsweep_running then return end
if GetTime() >= gcsweep_end_time then
-- Collect results for this combo
local n = gcsweep_frame_count
if n > 0 then
table.sort(gcsweep_frame_times)
local combo = gcsweep_combos[gcsweep_combo_idx]
table.insert(gcsweep_results, {
label = combo[4],
p50 = gcsweep_frame_times[math.floor(n * 0.5)] or 0,
p95 = gcsweep_frame_times[math.floor(n * 0.95)] or 0,
p99 = gcsweep_frame_times[math.floor(n * 0.99)] or 0,
max = gcsweep_frame_times[n] or 0,
})
end
-- Next combo
gcsweep_run_next()
return
end
debugprofilestart()
gclife_per_frame()
local elapsed = debugprofilestop()
gcsweep_frame_count = gcsweep_frame_count + 1
table.insert(gcsweep_frame_times, elapsed)
end)
end
SLASH_GCSWEEP1 = "/gcsweep"
SlashCmdList["GCSWEEP"] = function(args)
if gcsweep_running then
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000GC Sweep already running|r")
return
end
if type(ZGCTune) ~= "function" then
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000ZGCTune not available|r")
return
end
gcsweep_seconds = tonumber(args) or 5
gcsweep_combo_idx = 0
gcsweep_results = {}
gcsweep_running = true
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ff00GC Sweep|r: testing %d combos, %ds each (%ds total)...",
table.getn(gcsweep_combos), gcsweep_seconds,
table.getn(gcsweep_combos) * gcsweep_seconds))
gcsweep_run_next()
end
-- =========================================================================
-- zluagen stats frame -- visible by default, center screen, movable
-- ZGCStats() is registered by zluagen on its first GC tick.
-- Returns: cycles_total, cycles_major, cycles_minor,
-- mark_steps_last, sweep_steps_last,
+67
View File
@@ -0,0 +1,67 @@
# Lua 5.0 GC Write Barrier in lua_vm_execute
## Location
`lua_vm_execute` (0x6F8720), the main VM interpreter loop.
The barrier appears after every TValue copy (~20 sites in the function).
## Globals
- `0xCEEAC0` -- GC barrier value (current white marker / gc object pointer)
- `0xCEEAC4` -- GC barrier flag (non-zero = barrier is active)
## Pattern (from disassembly)
After copying a 16-byte TValue (type_tag, gc_ptr, value_lo, value_hi):
```asm
mov eax, [dst + 4] ; eax = copied gc_ptr field
test eax, eax
jz skip ; NULL gc_ptr -> no barrier needed
cmp dword ptr [0xCEEAC4], 0
jz skip ; barrier disabled -> skip
mov [0xCEEAC0], eax ; mark: write gc_ptr into barrier global
skip:
```
## What it does
The gc_ptr field at TValue+0x04 holds a pointer to a GC-managed object
(string, table, closure, userdata) or NULL for non-collectable types
(number, boolean, nil, lightuserdata).
When a TValue is copied (MOVE, GETGLOBAL, GETTABLE, LOADK, etc.), the
barrier checks:
1. Is the copied value a GC object? (gc_ptr != NULL)
2. Is the write barrier active? (flag at 0xCEEAC4 != 0)
3. If both true, write the gc_ptr to the barrier global at 0xCEEAC0
This is Lua 5.0's incremental GC write barrier. It tracks which GC objects
have been moved/copied so the collector knows which objects are reachable
from newly-written locations. The barrier global accumulates the "last written"
gc object -- the actual GC uses this to avoid rescanning the full root set.
## Opcodes that trigger the barrier
Every opcode that writes a TValue to a register or table slot:
- MOVE (op 0)
- LOADK (op 1) -- loads constant, has gc_ptr for string constants
- GETUPVAL (op 4)
- GETGLOBAL (op 5)
- GETTABLE (op 6)
- SETGLOBAL (op 7) -- barrier on the table side
- SETTABLE (op 9)
- NEWTABLE (op 10)
- SELF (op 11)
- CONCAT (op 21)
- CLOSURE (op 30)
- FORLOOP (op 23) -- number only, but barrier still present
## Relevance to luagc module
Our luagc module (in weirdperformance) hooks `lua_gc_step` (0x6FAE00).
Understanding the barrier globals is useful for:
- Knowing when/how often the barrier fires during heavy addon activity
- Potentially batching barrier writes if we ever replace the GC step
- The flag at 0xCEEAC4 could be used to temporarily disable the barrier
during bulk operations (dangerous -- must re-enable before GC runs)
+393
View File
@@ -0,0 +1,393 @@
//! luastr -- Lua string pattern optimizations.
//!
//! Two production wins, both measured in real gameplay (see git history for
//! before/after numbers):
//!
//! 1. pattern_match_class (0x7FC930): replace Wine CRT's locale dispatch
//! (~300+ cyc/call) with a direct 256-entry table lookup (~30 cyc/call).
//! The table is built once at install time by calling the original
//! matchclass for every (byte, class_letter) pair, capturing the exact
//! Wine/locale classification. Zero correctness risk.
//!
//! 2. string.find / string.gfind-iterator / string.gsub literal pre-filter:
//! extract the longest mandatory literal run from each pattern and short-
//! circuit with memmem if absent from the subject. Kills the O(n^2)
//! backtracking that addon combat log parsers (MSBT, WIM, BigWigs, etc.)
//! inflict on every chat message. Measured impact:
//! - eliminates ~22-40% of real pattern-engine cycles per frame window
//! - ~5-17% overhead of its own (inside the real matcher's budget)
//! - net_if_live consistently positive across all measurement windows
//!
//! Correctness: the prefilter is correct by construction -- we only short-
//! circuit when a REQUIRED literal substring is PROVABLY absent from the
//! subject. Any parse ambiguity, complex construct (%b/%f), or run too short
//! to beat MIN_LITERAL_LEN falls through to the real matcher.
const std = @import("std");
const hook = @import("zhook");
const lua = @import("../lua.zig");
const logging = @import("../logging.zig");
var log: logging.Logger = .{};
// =============================================================================
// pattern_match_class (0x7FC930) -- 256-entry table lookup
// __fastcall(ECX=character, EDX=class_letter) -> u32 (1=match, 0=no match)
//
// Wine sets DAT_00831710 >= 2, forcing every %d/%a/%s/etc. check through
// CheckCharacterProperties() instead of a direct table lookup. A single
// %d+ match against a 100-char string calls this 100+ times per frame.
// =============================================================================
// Runtime 256-entry classification table. Each entry is a u16 bitfield:
// bit 0 ( 1): %a -- alpha
// bit 1 ( 2): %c -- control
// bit 2 ( 4): %d -- digit
// bit 3 ( 8): %l -- lowercase
// bit 4 ( 16): %p -- punct
// bit 5 ( 32): %s -- space
// bit 6 ( 64): %u -- uppercase
// bit 7 (128): %w -- alnum
// bit 8 (256): %x -- xdigit
//
// Populated at installHooks time by calling the original pattern_match_class
// for every (c=0..255, class_letter) pair. Captures the exact Wine/locale
// classification -- correct for ASCII, UTF-8 multibyte bytes, Windows codepage,
// and any locale. 256 * 9 = 2304 calls at load, zero overhead thereafter.
var runtime_table: [256]u16 = [_]u16{0} ** 256;
fn buildRuntimeTable() void {
const OrigFn = fn (u32, u32) callconv(hook.cc.fastcall) u32;
const orig: *const OrigFn = @ptrFromInt(0x7FC930);
const Entry = struct { cl: u32, bit: u16 };
const entries = [_]Entry{
.{ .cl = 'a', .bit = 1 },
.{ .cl = 'c', .bit = 2 },
.{ .cl = 'd', .bit = 4 },
.{ .cl = 'l', .bit = 8 },
.{ .cl = 'p', .bit = 16 },
.{ .cl = 's', .bit = 32 },
.{ .cl = 'u', .bit = 64 },
.{ .cl = 'w', .bit = 128 },
.{ .cl = 'x', .bit = 256 },
};
for (entries) |e| {
for (0..256) |c| {
if (orig(@intCast(c), e.cl) != 0) {
runtime_table[c] |= e.bit;
}
}
}
}
// Reimplementation of matchclass() using the runtime-populated table.
// %z/%Z are special-cased (zero-byte semantics, not locale-dependent).
// Unknown class letters fall through to literal match, same as the original.
fn matchClassImpl(c: u32, cl: u32) u32 {
const lc: u32 = cl | 0x20; // ASCII tolower (safe for A-Z only)
const is_upper_cl: bool = cl >= 'A' and cl <= 'Z';
// %z / %Z: zero-byte check -- not a locale concept
if (lc == 'z') {
const m = c == 0;
return if (if (is_upper_cl) !m else m) 1 else 0;
}
const bit: u16 = switch (lc) {
'a' => 1,
'c' => 2,
'd' => 4,
'l' => 8,
'p' => 16,
's' => 32,
'u' => 64,
'w' => 128,
'x' => 256,
else => return if (cl == c) 1 else 0,
};
const entry: u16 = if (c < 256) runtime_table[c] else 0;
const m = (entry & bit) != 0;
return if (if (is_upper_cl) !m else m) 1 else 0;
}
const MatchClassFn = fn (u32, u32) callconv(hook.cc.fastcall) u32;
var matchclass_hook: hook.Detour(MatchClassFn) = .{};
fn matchclassDetour(c: u32, cl: u32) callconv(hook.cc.fastcall) u32 {
// Preserve ESI/EDI/EBX across our pure-Zig body -- Wine's pattern matcher
// assumes these are callee-saved across the call.
asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true });
return matchClassImpl(c, cl);
}
// =============================================================================
// String function literal pre-filter
// =============================================================================
const LUA_TSTRING: i32 = 4;
// Minimum literal run length before we bother running memmem. Shorter runs
// produce too many false-positive passes on typical chat/tooltip subjects to
// be worth the scan cost. 4 is empirically a good tradeoff.
const MIN_LITERAL_LEN: usize = 4;
const FnTag = enum { find, match, gsub };
// Pseudo-index constants for the gfind iterator closure body (0x7FCFF0).
// Subject/pattern/position live in upvalues, not arg slots.
// LUA_GLOBALSINDEX = -10001, upvalue(i) = LUA_GLOBALSINDEX - i
const GFIND_SUBJ_IDX: i32 = -10002; // upvalue 1
const GFIND_PAT_IDX: i32 = -10003; // upvalue 2
/// lua_strlen(L, idx) -> size_t. Address 0x6F36E0. Ghidra and earlier project
/// notes mislabeled this as `lua_tolstring`; disassembly shows it returns
/// `*(TString + 0xC)` which is the `len` field of Lua 5.0's TString header,
/// NOT a data pointer. 2-arg fastcall, ECX=L EDX=idx, `RET` (no stack cleanup).
fn luaStrLen(L: lua.State, idx: i32) usize {
const f: *const fn (lua.State, i32) callconv(hook.cc.fastcall) usize = @ptrFromInt(0x6F36E0);
return f(L, idx);
}
/// Return the longest run of mandatory literal bytes in a Lua 5.0 pattern,
/// as a slice into `pat` itself. Zero-copy: no scratch buffer, no escape
/// translation, no out-parameter.
///
/// Tradeoff: runs break at any `%X` escape item (even escaped punctuation
/// like `%.`). The literal `foo%.bar` yields best run "foo" or "bar" rather
/// than "foo.bar". For prefilter correctness this is fine -- any mandatory
/// literal substring being provably absent from the subject disproves the
/// pattern. A shorter run means slightly weaker filtering, never a wrong
/// answer.
///
/// Parser notes:
/// - `+`, `-`, `*`, `?` at a fresh item-parse position are plain literal
/// bytes, matching Lua 5.0's own matcher. Example: `%d+-%d+` parses as
/// `%d+` class+quantifier, then `-` as literal dash, then `%d+`.
/// - `(` and `)` are zero-width capture delimiters; they break runs without
/// consuming a literal byte.
/// - `[set]` scans past the matching `]` as a non-literal item.
/// - `%b...` and `%f[...]` are complex; we bail.
fn longestLiteral(pat: []const u8) []const u8 {
var best_start: usize = 0;
var best_len: usize = 0;
var cur_start: usize = 0;
var cur_len: usize = 0;
var i: usize = 0;
// Leading `^` is an anchor, skip it -- the rest still defines the literal.
if (pat.len > 0 and pat[0] == '^') i = 1;
while (i < pat.len) {
const c = pat[i];
var item_bytes: usize = 1;
var is_literal: bool = true;
switch (c) {
'(', ')' => {
// Zero-width capture delim: break run, advance 1, no quantifier.
if (cur_len > best_len) {
best_start = cur_start;
best_len = cur_len;
}
cur_len = 0;
i += 1;
continue;
},
'.', '$' => {
is_literal = false;
},
'[' => {
// Scan past matching ].
var j = i + 1;
if (j < pat.len and pat[j] == '^') j += 1;
if (j < pat.len and pat[j] == ']') j += 1; // first ] inside set is literal
while (j < pat.len and pat[j] != ']') : (j += 1) {
if (pat[j] == '%' and j + 1 < pat.len) j += 1;
}
if (j >= pat.len) break; // unbalanced -- bail
is_literal = false;
item_bytes = j + 1 - i;
},
'%' => {
if (i + 1 >= pat.len) break; // trailing % -- bail
const n = pat[i + 1];
if (n == 'b' or n == 'B' or n == 'f' or n == 'F') break; // complex
is_literal = false;
item_bytes = 2;
},
']' => break, // unbalanced close-bracket -- bail
else => {}, // plain literal byte (including +/-/*/? at item position)
}
// Peek for a quantifier modifying this item.
const next_i = i + item_bytes;
var quant: u8 = 0;
if (next_i < pat.len) {
const q = pat[next_i];
if (q == '+' or q == '*' or q == '-' or q == '?') quant = q;
}
if (is_literal) {
if (quant == '*' or quant == '-' or quant == '?') {
// Optional -- doesn't contribute, break run.
if (cur_len > best_len) {
best_start = cur_start;
best_len = cur_len;
}
cur_len = 0;
} else {
// No quant or `+`: byte required at least once.
if (cur_len == 0) cur_start = i;
cur_len += 1;
if (quant == '+') {
// Required once, but run breaks: subject may contain extra
// repeats of this byte, so the next pattern byte isn't
// guaranteed contiguous in the subject.
if (cur_len > best_len) {
best_start = cur_start;
best_len = cur_len;
}
cur_len = 0;
}
}
} else {
// Non-literal item: break run.
if (cur_len > best_len) {
best_start = cur_start;
best_len = cur_len;
}
cur_len = 0;
}
i = next_i + @intFromBool(quant != 0);
}
// Finalize trailing run.
if (cur_len > best_len) {
best_start = cur_start;
best_len = cur_len;
}
return pat[best_start..][0..best_len];
}
/// Returns nresults if the call was short-circuited (pattern provably can't
/// match), or null if the caller should proceed to the real matcher.
///
/// subj_idx/pat_idx choose where to read subject and pattern from. For
/// string.find/gsub these are arg slots 1 and 2. For the string.gfind
/// iterator closure (0x7FCFF0) they are upvalue pseudo-indices -10002/-10003.
fn tryPrefilter(L: lua.State, tag: FnTag, subj_idx: i32, pat_idx: i32) ?u32 {
// Require string type (not number) to avoid triggering number -> string
// coercion side effects inside a detour. typeOf returns LUA_TNONE on
// invalid indices, which cleanly falls through.
if (lua.typeOf(L, subj_idx) != LUA_TSTRING) return null;
if (lua.typeOf(L, pat_idx) != LUA_TSTRING) return null;
const subj_ptr_sent = lua.tostring(L, subj_idx) orelse return null;
const pat_ptr_sent = lua.tostring(L, pat_idx) orelse return null;
const subj_len = luaStrLen(L, subj_idx);
const pat_len = luaStrLen(L, pat_idx);
if (pat_len == 0 or subj_len == 0) return null;
const subj_ptr: [*]const u8 = @ptrCast(subj_ptr_sent);
const pat_ptr: [*]const u8 = @ptrCast(pat_ptr_sent);
const pat_slice = pat_ptr[0..pat_len];
const lit = longestLiteral(pat_slice);
if (lit.len < MIN_LITERAL_LEN) return null;
const subj_slice = subj_ptr[0..subj_len];
if (std.mem.indexOf(u8, subj_slice, lit) != null) return null;
// Literal provably absent -- pattern cannot match. Short-circuit.
switch (tag) {
.find, .match => {
lua.pushnil(L);
return 1;
},
.gsub => {
// string.gsub on no match returns (subject_unchanged, 0)
lua.pushvalue(L, 1);
lua.pushnumber(L, 0);
return 2;
},
}
}
// =============================================================================
// Hooked functions
//
// 0x7FC3B0 lua_string_find -- reads subject/pattern from stack args 1, 2
// 0x7FCFF0 lua_string_gfind_iter -- the gfind iterator closure body; reads
// subject/pattern/position from upvalues. Ghidra mislabels this as
// "lua_string_match"; Lua 5.0 does NOT have string.match (5.1+).
// 0x7FD0E0 lua_string_gsub -- reads subject/pattern from stack args 1, 2
// =============================================================================
const StringFn = fn (u32, u32) callconv(hook.cc.fastcall) u32;
var strfind_hook: hook.Detour(StringFn) = .{};
var strmatch_hook: hook.Detour(StringFn) = .{};
var strgsub_hook: hook.Detour(StringFn) = .{};
fn strfindDetour(state: u32, edx: u32) callconv(hook.cc.fastcall) u32 {
asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true });
const L: lua.State = @ptrFromInt(state);
if (tryPrefilter(L, .find, 1, 2)) |nres| return nres;
return strfind_hook.callOriginal(.{ state, edx });
}
fn strmatchDetour(state: u32, edx: u32) callconv(hook.cc.fastcall) u32 {
asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true });
const L: lua.State = @ptrFromInt(state);
if (tryPrefilter(L, .match, GFIND_SUBJ_IDX, GFIND_PAT_IDX)) |nres| return nres;
return strmatch_hook.callOriginal(.{ state, edx });
}
fn strgsubDetour(state: u32, edx: u32) callconv(hook.cc.fastcall) u32 {
asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true });
const L: lua.State = @ptrFromInt(state);
if (tryPrefilter(L, .gsub, 1, 2)) |nres| return nres;
return strgsub_hook.callOriginal(.{ state, edx });
}
// =============================================================================
// Install / Remove
// =============================================================================
pub fn install() u32 {
log = logging.Logger.open("luastr", .console);
buildRuntimeTable();
log.print(" matchclass: runtime table built\n");
var installed: u32 = 0;
if (matchclass_hook.attach(0x7FC930, &matchclassDetour) == .ok) {
log.print(" matchclass: table lookup active\n");
installed += 1;
}
if (strfind_hook.attach(0x7FC3B0, &strfindDetour) == .ok) {
log.print(" string.find: literal prefilter active\n");
installed += 1;
}
if (strmatch_hook.attach(0x7FCFF0, &strmatchDetour) == .ok) {
log.print(" string.gfind: literal prefilter active\n");
installed += 1;
}
if (strgsub_hook.attach(0x7FD0E0, &strgsubDetour) == .ok) {
log.print(" string.gsub: literal prefilter active\n");
installed += 1;
}
log.print("luastr: active\n");
return installed;
}
pub fn remove() void {
strgsub_hook.detach();
strmatch_hook.detach();
strfind_hook.detach();
matchclass_hook.detach();
log.close();
}
+103
View File
@@ -0,0 +1,103 @@
//! luavm -- Lua VM hotspot optimizations.
//!
//! luaS_newlstr (0x6F9D00, 1.35% CPU): hash pre-check before memcmp.
//! ~40% win vs original (validated by prior A/B). Production-only, no instrumentation.
const hook = @import("zhook");
const logging = @import("../logging.zig");
var log: logging.Logger = .{};
// =============================================================================
// luaS_newlstr (0x6F9D00)
// __fastcall(ECX=lua_State*, EDX=str_ptr, stack=len) -> TString*
// RET 0x4
// =============================================================================
const NewLStrFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) u32;
var newlstr_hook: hook.Detour(NewLStrFn) = .{};
fn luaCreateStringObject(state: u32, str_ptr: u32, len: u32, hash_val: u32) u32 {
return hook.call(
fn (u32, u32, u32, u32) callconv(hook.cc.fastcall) u32,
0x6F9D90,
.{ state, str_ptr, len, hash_val },
);
}
fn newlstrDetour(state: u32, str_ptr: u32, len: u32) callconv(hook.cc.fastcall) u32 {
asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true });
return newlstrImpl(state, str_ptr, len);
}
fn newlstrImpl(state: u32, str_ptr: u32, len: u32) u32 {
const str: [*]const u8 = @ptrFromInt(str_ptr);
var h: u32 = len;
const step: u32 = (len >> 5) + 1;
var l1: u32 = len;
while (l1 >= step) {
const c: u32 = str[l1 - 1];
h = h ^ (c +% (h << 5) +% (h >> 2));
l1 -= step;
}
const global_state: u32 = hook.readMem(u32, state + 0x10);
const strt_hash: u32 = hook.readMem(u32, global_state + 0x04);
const strt_size: u32 = hook.readMem(u32, global_state + 0x0C);
const bucket: u32 = h & (strt_size - 1);
var ts: u32 = hook.readMem(u32, strt_hash + bucket * 4);
while (ts != 0) {
const ts_len: u32 = hook.readMem(u32, ts + 0x0C);
if (ts_len == len) {
const ts_hash: u32 = hook.readMem(u32, ts + 0x08);
if (ts_hash == h) {
if (len == 0 or strEqual(str_ptr, ts + 0x10, len)) {
return ts;
}
}
}
ts = hook.readMem(u32, ts);
}
return luaCreateStringObject(state, str_ptr, len, h);
}
fn strEqual(a_ptr: u32, b_ptr: u32, len: u32) bool {
const a: [*]const u8 = @ptrFromInt(a_ptr);
const b: [*]const u8 = @ptrFromInt(b_ptr);
var i: u32 = 0;
while (i + 4 <= len) : (i += 4) {
const va = @as(*align(1) const u32, @ptrCast(a + i)).*;
const vb = @as(*align(1) const u32, @ptrCast(b + i)).*;
if (va != vb) return false;
}
while (i < len) : (i += 1) {
if (a[i] != b[i]) return false;
}
return true;
}
// =============================================================================
// Install / Remove
// =============================================================================
pub fn install() u32 {
log = logging.Logger.open("luavm", .console);
var installed: u32 = 0;
if (newlstr_hook.attach(0x6F9D00, &newlstrDetour) == .ok) {
log.print(" newlstr: hash pre-check active\n");
installed += 1;
}
log.print("luavm: active\n");
return installed;
}
pub fn remove() void {
newlstr_hook.detach();
log.close();
}
+12 -3
View File
@@ -44,6 +44,8 @@ const cull_sse = @import("cull_sse.zig");
const silicon_sse = @import("silicon_sse.zig");
const luaalloc = @import("luaalloc.zig");
const luagc = @import("luagc.zig");
const luastr = @import("luastr.zig");
const luavm = @import("luavm.zig");
const renderParticleSprites_SSE = particle_sse.renderParticleSprites_SSE;
const resetParticleCache = particle_sse.resetParticleCache;
@@ -326,9 +328,14 @@ pub fn installHooks() void {
// libdeflate inflate replacement
if (inflate_hook.install()) installed += 1;
// Lua slab allocator + GC: owned by luagc module.
// installed += luaalloc.install();
// installed += luagc.install();
// Lua slab allocator replacement
installed += luaalloc.install();
installed += luagc.install();
installed += luastr.install();
installed += luavm.install();
}
pub fn lateInit() void {
@@ -338,6 +345,8 @@ pub fn lateInit() void {
pub fn removeHooks() void {
if (g_is_hook_owner) {
luavm.remove();
luastr.remove();
luaalloc.dumpStats();
luagc.dumpStats();
filecache.remove();