Compare commits

...

21 Commits

Author SHA1 Message Date
Dusk-92 83d85f5d5c ci: accept Intel 80386 PE32 label 2026-09-03 11:00:45 +02:00
Dusk-92 5c61928260 safety: keep GC native until player is in world 2026-09-03 10:49:40 +02:00
Dusk-92 722406fef2 ci: pin Zig and enforce x86 GC ABI gate 2026-09-03 10:47:47 +02:00
Dusk-92 c9ba29f60b docs: mark broken B invalid and document B1 2026-09-03 10:47:12 +02:00
Dusk-92 650825d793 build: rename ABI-fixed companion to gc24b1 2026-09-03 10:46:54 +02:00
Dusk-92 ce63965502 ci: patch pinned zhook syntax for Zig 0.17 dev 2026-09-03 10:43:01 +02:00
Dusk-92 dd04a4f480 ci: use Zig 0.17 optimize spelling 2026-09-03 10:38:59 +02:00
Dusk-92 b5662b64ec build: adapt optimize mode to Zig 0.17 dev 2026-09-03 10:38:54 +02:00
Dusk-92 3d821c41c3 ci: build 2.4-B1 with Zig 0.17 dev fastcall fix 2026-09-03 10:36:39 +02:00
Dusk-92 39b0097ed2 build: declare x86 fastcall convention explicitly 2026-09-03 10:35:20 +02:00
Dusk-92 232f37d762 fix: force verified x86 fastcall ABI for 2.4-B1 GC 2026-09-03 10:34:29 +02:00
Dusk-92 762034301a build: use Zig 0.16 winapi calling convention 2026-09-02 23:45:52 +02:00
Dusk-92 9d5d7bd0d6 docs: document strict 2.4-A plus GC companion A/B 2026-09-02 23:44:19 +02:00
Dusk-92 f72686f27a build: add Zig 0.16 package fingerprint 2026-09-02 23:44:16 +02:00
Dusk-92 3e5628f198 test: isolate 2.4-B GC hooks from 2.4-A lifecycle 2026-09-02 23:44:14 +02:00
Dusk-92 1cc0ca7e29 ci: compile 2.4-B before format cleanup 2026-09-02 23:39:51 +02:00
Dusk-92 cfc584ebfa ci: build 2.4-B GC test artifact 2026-09-02 23:39:11 +02:00
Dusk-92 1a0aad3223 docs: document 2.4-B GC A/B test 2026-09-02 23:39:09 +02:00
Dusk-92 368ae01c64 test: pin zhook for 2.4-B GC build 2026-09-02 23:39:07 +02:00
Dusk-92 b4f9cca516 test: add standalone 2.4-B GC build 2026-09-02 23:39:04 +02:00
Dusk-92 bb6e6b9236 test: add 2.4-B GC safe-sweep companion 2026-09-02 23:39:02 +02:00
5 changed files with 577 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
name: Build WeirdPerformance 2.4-B1 GC test
on:
push:
branches:
- test/wp24b-gc-safe-sweep
paths:
- 'experiments/wp24b-gc/**'
- '.github/workflows/wp24b-gc.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mlugg/setup-zig@v2
with:
version: '0.17.0-dev.1970+67f39b551'
- name: Build and ABI-validate x86 DLL
working-directory: experiments/wp24b-gc
run: |
set -euo pipefail
zig build --fetch
python3 - <<'PY'
from pathlib import Path
matches = list(Path("zig-pkg").glob("zhook-*/src/zhook.zig"))
if len(matches) != 1:
raise SystemExit(f"expected one pinned zhook source, found {len(matches)}")
p = matches[0]
text = p.read_text(encoding="utf-8")
old = "var patch: [MAX_STOLEN]u8 = .{0x90} ** MAX_STOLEN;"
new = "var patch: [MAX_STOLEN]u8 = @splat(0x90);"
if old not in text:
raise SystemExit("expected pinned zhook repeat initializer not found")
p.write_text(text.replace(old, new), encoding="utf-8")
PY
zig build -Doptimize=small
DLL=zig-out/bin/weirdperformance_gc24b1.dll
test -s "$DLL"
file "$DLL" | tee BINARY_INFO.txt
file "$DLL" | grep -Eq 'PE32.*Intel (80386|i386)'
if command -v llvm-objdump >/dev/null 2>&1; then
llvm-objdump -d --x86-asm-syntax=intel "$DLL" > ABI_DISASM.txt
else
objdump -d -M intel "$DLL" > ABI_DISASM.txt
fi
python3 - <<'PY'
import re
from pathlib import Path
asm = Path("ABI_DISASM.txt").read_text(encoding="utf-8", errors="replace").lower()
def require(pattern, label):
if not re.search(pattern, asm, re.S):
raise SystemExit(f"ABI validation failed: {label}")
# zhook must jump to callbacks compiled with L arriving in ECX.
m = re.search(r"mov\s+edx,\s*0x6f7340.{0,240}?push\s+0x([0-9a-f]+)", asm, re.S)
if not m:
raise SystemExit("ABI validation failed: collector callback address not found")
cb = m.group(1).lstrip("0") or "0"
pos = asm.find(cb + ":")
if pos < 0 or not re.search(r"mov\s+esi,\s*ecx", asm[pos:pos+500]):
raise SystemExit("ABI validation failed: collector callback does not consume L from ECX")
m = re.search(r"mov\s+edx,\s*0x6f6ef0.{0,240}?push\s+0x([0-9a-f]+)", asm, re.S)
if not m:
raise SystemExit("ABI validation failed: lua_close callback address not found")
cb = m.group(1).lstrip("0") or "0"
pos = asm.find(cb + ":")
if pos < 0 or not re.search(r"mov\s+esi,\s*ecx", asm[pos:pos+400]):
raise SystemExit("ABI validation failed: lua_close callback does not consume L from ECX")
# Native helper ABI observed in WoW 5875:
# ECX=L, EDX=&list, stack arg=all for 0x6F7210.
require(
r"lea\s+edx,\s*\[edi\s*\+\s*0x14\].{0,160}?"
r"mov\s+eax,\s*0x6f7210.{0,120}?"
r"mov\s+ecx,\s*esi.{0,120}?"
r"push\s+0x0.{0,80}?call\s+eax",
"lua_gc_remove_objects register/stack ABI",
)
require(
r"mov\s+eax,\s*0x6f72f0.{0,120}?"
r"mov\s+ecx,\s*esi.{0,100}?"
r"xor\s+edx,\s*edx.{0,80}?call\s+eax",
"lua_gc_sweep_all_lists fastcall ABI",
)
for addr, label in (
("6f73e0", "lua_gc_full_collection"),
("6f7370", "lua_gc_shrink_memory"),
("6f7080", "luaCallUserDataGC"),
):
require(
rf"mov\s+eax,\s*0x{addr}.{{0,120}}?mov\s+ecx,\s*esi.{{0,80}}?call\s+eax",
f"{label} ECX ABI",
)
print("ABI validation: PASS")
PY
sha256sum "$DLL" | tee SHA256SUMS.txt
- uses: actions/upload-artifact@v4
with:
name: WeirdPerformance-2.4-B1-GC-Safe-Sweep
path: |
experiments/wp24b-gc/zig-out/bin/weirdperformance_gc24b1.dll
experiments/wp24b-gc/SHA256SUMS.txt
experiments/wp24b-gc/BINARY_INFO.txt
experiments/wp24b-gc/ABI_DISASM.txt
experiments/wp24b-gc/README.md
if-no-files-found: error
+46
View File
@@ -0,0 +1,46 @@
# WeirdPerformance 2.4-B1 — GC Safe Sweep ABI-fixed test
Strict A/B experiment for WoW 1.12.1 build 5875.
## Baseline
Keep the validated 2.4-A binary **unchanged**:
- `weirdperformance.dll`
- SHA-256: `d35168ae06c19087ef9b7c68918a054640396dfbfcef0664e18e9372284b5eef`
## B1 variant
Add:
- `weirdperformance_gc24b1.dll`
The companion changes **only Lua GC behavior**. It does not replace or rebuild the validated 2.4-A DLL.
The first 2.4-B build is invalid and must not be used. It was built with an i386 Windows fastcall ABI mismatch and crashed at startup in WoW's native `lua_gc_remove_objects`. B1 is built with a pinned Zig 0.17 development toolchain and is binary-disassembly checked for the expected register ABI before distribution.
The implementation is based on the historical pre-generational incremental sweep: WoW keeps its native mark, userdata/string sweep, memory shrink and finalizers. Only the main `rootgc` sweep is split into 50,000-object chunks.
## Safety scope
- hooks only `luaC_collectgarbage` and `lua_close`;
- no overlap with the lifecycle hooks referenced by the validated 2.4-A binary;
- any fragmented sweep is reconnected before native `lua_close` destroys the Lua state;
- GC calls made from inside `lua_close` stay native;
- the birth-mark byte is ownership checked and restored only while still owned;
- a changed Lua `global_State` forces native fallback rather than reconnecting stale pointers;
- hook installation is transactional;
- no generational age bitmap, no write barriers, no GC tuning, no profiling/RDTSC.
## Installation for the test
Keep both DLLs next to WoW and list both in `dlls.txt`:
```text
weirdperformance.dll
weirdperformance_gc24b1.dll
```
Delete the old `weirdperformance_gc24b.dll` if it is still present.
Removing `weirdperformance_gc24b1.dll` returns you exactly to the validated 2.4-A baseline.
+30
View File
@@ -0,0 +1,30 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.resolveTargetQuery(.{
.cpu_arch = .x86,
.os_tag = .windows,
.abi = .msvc,
.cpu_features_add = std.Target.x86.featureSet(&.{ .sse, .sse2 }),
});
const optimize = b.option(std.builtin.OptimizeMode, "optimize", "Optimization mode") orelse .small;
const zhook_dep = b.dependency("zhook", .{
.target = target,
.optimize = optimize,
});
const lib = b.addLibrary(.{
.name = "weirdperformance_gc24b1",
.linkage = .dynamic,
.root_module = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zhook", .module = zhook_dep.module("zhook") },
},
}),
});
b.installArtifact(lib);
}
+16
View File
@@ -0,0 +1,16 @@
.{
.name = .weirdperformance_gc24b,
.version = "2.4.0",
.fingerprint = 0x95e8be635406d50f,
.dependencies = .{
.zhook = .{
.url = "https://codeberg.org/marcelinevq/zhook/archive/f1b252ed61ad839f00310c386761d068f293ad0f.tar.gz",
.hash = "zhook-0.1.0-pFkSYC6FAACAnkqu0k_DJBWdL0gJjrM22IfXeQPJAMov",
},
},
.paths = .{
"build.zig",
"build.zig.zon",
"main.zig",
},
}
+364
View File
@@ -0,0 +1,364 @@
//! WeirdPerformance 2.4-B GC safe-sweep companion.
//!
//! A/B contract:
//! A = validated WeirdPerformance 2.4-A binary, unchanged.
//! B = the exact same 2.4-A binary + this companion DLL.
//!
//! This companion only changes Lua GC behavior: WoW's native mark/udata/string
//! work is kept, while the rootgc sweep is split into bounded chunks.
//!
//! Target: WoW 1.12.1 build 5875, x86 only.
const std = @import("std");
const hook = @import("zhook");
const WINAPI = std.builtin.CallingConvention.winapi;
const X86_FASTCALL: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
const LUA_COLLECT_GARBAGE_ADDR: usize = 0x6F7340;
const LUA_CLOSE_ADDR: usize = 0x6F6EF0;
const BIRTH_MARK_ADDR: usize = 0x6F7B37;
const IS_IN_WORLD_ADDR: u32 = 0x00B4B424;
const GS_ROOTGC: u32 = 0x10;
const GS_ROOTUDATA: u32 = 0x14;
const GS_GCTHRESHOLD: u32 = 0x24;
const GS_TOTALBYTES: u32 = 0x28;
const OBJ_NEXT: u32 = 0;
const CHUNK_SIZE: u32 = 50_000;
const BATCH_HEADROOM: u32 = 128 * 1024;
// Crash logs for the supported client show 0x00400000..0x00D2B000.
const EXPECTED_IMAGE_BASE: usize = 0x00400000;
const EXPECTED_IMAGE_SIZE: u32 = 0x0092B000;
const CollectFn = fn (u32) callconv(X86_FASTCALL) void;
const LuaCloseFn = fn (u32) callconv(X86_FASTCALL) void;
const SweepAllFn = fn (u32, u32) callconv(X86_FASTCALL) void;
const RemoveObjectsFn = fn (u32, u32, u32) callconv(X86_FASTCALL) u32;
const lua_gc_full_collection: *const CollectFn = @ptrFromInt(0x6F73E0);
const lua_gc_shrink_memory: *const CollectFn = @ptrFromInt(0x6F7370);
const luaCallUserDataGC: *const CollectFn = @ptrFromInt(0x6F7080);
const lua_gc_sweep_all_lists: *const SweepAllFn = @ptrFromInt(0x6F72F0);
const lua_gc_remove_objects: *const RemoveObjectsFn = @ptrFromInt(0x6F7210);
var collect_hook: hook.Detour(CollectFn) = .{};
var lua_close_hook: hook.Detour(LuaCloseFn) = .{};
var installed = false;
var in_gc = false;
var closing_lua = false;
var sweeping = false;
var swept_head: u32 = 0;
var swept_tail: u32 = 0;
var unswept_rest: u32 = 0;
var saved_g: u32 = 0;
var birth_mark_owned = false;
var birth_mark_original: u8 = 0;
inline fn readU8(addr: usize) u8 {
return @as(*volatile const u8, @ptrFromInt(addr)).*;
}
inline fn readU16(addr: usize) u16 {
return @as(*volatile const u16, @ptrFromInt(addr)).*;
}
inline fn readU32(addr: u32) u32 {
return @as(*volatile const u32, @ptrFromInt(addr)).*;
}
inline fn readU32usize(addr: usize) u32 {
return @as(*volatile const u32, @ptrFromInt(addr)).*;
}
inline fn writeU32(addr: u32, value: u32) void {
@as(*volatile u32, @ptrFromInt(addr)).* = value;
}
inline fn getGlobalState(L: u32) u32 {
return readU32(L + 0x10);
}
fn validateClient() bool {
if (readU16(EXPECTED_IMAGE_BASE) != 0x5A4D) return false; // MZ
const pe_off = readU32usize(EXPECTED_IMAGE_BASE + 0x3C);
const pe = EXPECTED_IMAGE_BASE + pe_off;
if (readU32usize(pe) != 0x00004550) return false; // PE\0\0
if (readU16(pe + 4) != 0x014C) return false; // IMAGE_FILE_MACHINE_I386
if (readU16(pe + 24) != 0x010B) return false; // PE32 optional header
const image_size = readU32usize(pe + 24 + 56);
return image_size == EXPECTED_IMAGE_SIZE;
}
fn findNth(head: u32, n: u32) struct { obj: u32, count: u32 } {
var obj = head;
var i: u32 = 0;
while (obj != 0 and i < n) : (i += 1) {
const next = readU32(obj + OBJ_NEXT);
if (next == 0) return .{ .obj = 0, .count = i + 1 };
obj = next;
}
return .{ .obj = obj, .count = i };
}
fn findTail(head: u32) u32 {
var obj = head;
if (obj == 0) return 0;
while (true) {
const next = readU32(obj + OBJ_NEXT);
if (next == 0) return obj;
obj = next;
}
}
fn acquireBirthMark() bool {
if (birth_mark_owned) return readU8(BIRTH_MARK_ADDR) == 0x01;
const current = readU8(BIRTH_MARK_ADDR);
if (current != 0x00) return false;
birth_mark_original = current;
const patch = [1]u8{0x01};
hook.writeProtected(BIRTH_MARK_ADDR, &patch);
if (readU8(BIRTH_MARK_ADDR) != 0x01) return false;
birth_mark_owned = true;
return true;
}
fn releaseBirthMark() void {
if (!birth_mark_owned) return;
// Restore only while we still own exactly the byte we installed.
if (readU8(BIRTH_MARK_ADDR) == 0x01) {
const patch = [1]u8{birth_mark_original};
hook.writeProtected(BIRTH_MARK_ADDR, &patch);
}
birth_mark_owned = false;
}
fn resetSweepState() void {
sweeping = false;
swept_head = 0;
swept_tail = 0;
unswept_rest = 0;
saved_g = 0;
releaseBirthMark();
}
fn reconnectSweep() void {
if (!sweeping or saved_g == 0) {
resetSweepState();
return;
}
const g = saved_g;
var tail = findTail(readU32(g + GS_ROOTGC));
if (unswept_rest != 0) {
if (tail != 0) {
writeU32(tail + OBJ_NEXT, unswept_rest);
} else {
writeU32(g + GS_ROOTGC, unswept_rest);
}
tail = findTail(unswept_rest);
}
if (swept_head != 0) {
if (tail != 0) {
writeU32(tail + OBJ_NEXT, swept_head);
} else {
writeU32(g + GS_ROOTGC, swept_head);
}
}
resetSweepState();
}
fn detachSweptAndRestore(g: u32) void {
const current_head = readU32(g + GS_ROOTGC);
if (current_head != 0) {
const tail = findTail(current_head);
if (swept_head == 0) {
swept_head = current_head;
swept_tail = tail;
} else {
writeU32(swept_tail + OBJ_NEXT, current_head);
swept_tail = tail;
}
}
writeU32(g + GS_ROOTGC, unswept_rest);
unswept_rest = 0;
}
fn nativeFallback(L: u32) void {
reconnectSweep();
collect_hook.callOriginal(.{L});
}
fn collectGarbageDetour(L: u32) callconv(X86_FASTCALL) void {
if (closing_lua) {
collect_hook.callOriginal(.{L});
return;
}
// Keep GlueXML/login/character-select on WoW's native GC.
// The experiment is intentionally gameplay-only.
if (readU32(IS_IN_WORLD_ADDR) == 0) {
collect_hook.callOriginal(.{L});
return;
}
if (in_gc) return;
if (L == 0) return;
if (readU32(L + 0x60) == 0) return;
in_gc = true;
defer in_gc = false;
const g = getGlobalState(L);
if (g == 0) {
collect_hook.callOriginal(.{L});
return;
}
// Never carry private list state into another Lua global_State.
if (sweeping and g != saved_g) {
resetSweepState();
collect_hook.callOriginal(.{L});
return;
}
// If another module changed the birth byte during our split cycle,
// reconnect immediately and hand this collection back to WoW.
if (sweeping and (!birth_mark_owned or readU8(BIRTH_MARK_ADDR) != 0x01)) {
nativeFallback(L);
return;
}
if (!sweeping) {
// Keep WoW's native mark, userdata sweep, and string sweep.
lua_gc_full_collection(L);
_ = lua_gc_remove_objects(L, g + GS_ROOTUDATA, 0);
lua_gc_sweep_all_lists(L, 0);
swept_head = 0;
swept_tail = 0;
const rootgc_head = readU32(g + GS_ROOTGC);
const result = findNth(rootgc_head, CHUNK_SIZE);
// Small rootgc lists remain one-shot, matching native behavior closely.
if (result.obj == 0) {
_ = lua_gc_remove_objects(L, g + GS_ROOTGC, 0);
lua_gc_shrink_memory(L);
luaCallUserDataGC(L);
return;
}
// If the birth marker is unavailable, do not split this collection.
if (!acquireBirthMark()) {
_ = lua_gc_remove_objects(L, g + GS_ROOTGC, 0);
lua_gc_shrink_memory(L);
luaCallUserDataGC(L);
return;
}
unswept_rest = readU32(result.obj + OBJ_NEXT);
writeU32(result.obj + OBJ_NEXT, 0);
sweeping = true;
saved_g = g;
_ = lua_gc_remove_objects(L, g + GS_ROOTGC, 0);
detachSweptAndRestore(g);
const totalbytes = readU32(g + GS_TOTALBYTES);
writeU32(g + GS_GCTHRESHOLD, totalbytes + BATCH_HEADROOM);
return;
}
const rootgc_head = readU32(g + GS_ROOTGC);
const result = findNth(rootgc_head, CHUNK_SIZE);
if (result.obj == 0) {
_ = lua_gc_remove_objects(L, g + GS_ROOTGC, 0);
const current_head = readU32(g + GS_ROOTGC);
if (swept_head != 0) {
if (current_head != 0) {
writeU32(swept_tail + OBJ_NEXT, current_head);
}
writeU32(g + GS_ROOTGC, swept_head);
}
resetSweepState();
lua_gc_shrink_memory(L);
luaCallUserDataGC(L);
return;
}
unswept_rest = readU32(result.obj + OBJ_NEXT);
writeU32(result.obj + OBJ_NEXT, 0);
_ = lua_gc_remove_objects(L, g + GS_ROOTGC, 0);
detachSweptAndRestore(g);
const totalbytes = readU32(g + GS_TOTALBYTES);
writeU32(g + GS_GCTHRESHOLD, totalbytes + BATCH_HEADROOM);
}
fn luaCloseDetour(L: u32) callconv(X86_FASTCALL) void {
// lua_close may run native sweep paths that bypass luaC_collectgarbage.
// Reconnect every private fragment while the old Lua state is still valid.
closing_lua = true;
reconnectSweep();
in_gc = false;
lua_close_hook.callOriginal(.{L});
closing_lua = false;
}
fn install() void {
if (installed) return;
if (!validateClient()) return;
// Transactional install: lua_close guard first, collector second.
// If the collector cannot attach, roll the close hook back immediately.
if (lua_close_hook.attach(LUA_CLOSE_ADDR, &luaCloseDetour) != .ok) return;
if (collect_hook.attach(LUA_COLLECT_GARBAGE_ADDR, &collectGarbageDetour) != .ok) {
lua_close_hook.detach();
return;
}
installed = true;
}
const version: [*:0]const u8 = "2.4-B1-gc-safe-sweep-abi";
pub export fn WeirdPerformanceGC24B_GetVersion() callconv(.c) [*:0]const u8 {
return version;
}
pub export fn WeirdPerformanceGC24B_IsActive() callconv(.c) i32 {
return if (installed) 1 else 0;
}
pub export fn DllMain(
_: ?*anyopaque,
reason: u32,
_: ?*anyopaque,
) callconv(WINAPI) std.os.windows.BOOL {
if (reason == 1) install();
// Process-lifetime A/B companion. We intentionally do not hot-unhook
// detours during process teardown.
return @enumFromInt(1);
}