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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user