inflate: thread-local libdeflate with gnu ABI — 2.2x speedup, 100% coverage

Root cause of crashes was thread safety: libdeflate's decompressor struct
has mutable decode tables rebuilt per-block, so sharing between main thread
and FMOD audio thread caused wild writes. Fixed with a thread-local pool
keyed by Windows thread ID (FS:[0x24]).

Switched build ABI from msvc to gnu — eliminates stub headers, libdeflate
uses Zig's bundled MinGW libc. Relaxed zlib header filter from == 0x78 to
(CMF & 0x0F) == 0x08 to catch both 32K and 4K window sizes.

Benchmarked: stock=2681ms, per-call-alloc=1304ms (2.05x), tls=1194ms (2.2x)
on 84k calls. Zero errors, zero fallbacks in production.
This commit is contained in:
MarcelineVQ
2026-03-24 14:57:28 -07:00
parent b612e6f953
commit 80a7cdea3f
4 changed files with 140 additions and 267 deletions
+21
View File
@@ -193,6 +193,21 @@ end)
local name, rank, icon, castTime, minRange, maxRange, spellId = GetSpellInfo(133)
```
- `UnitCastingInfo("unit")` -- TBC/WotLK cast bar query (works on any visible unit):
```lua
local name, rank, text, icon, startTime, endTime, isTradeSkill, castID, notInterruptible = UnitCastingInfo("target")
if name then
-- startTime/endTime are in milliseconds (compare with GetTime()*1000)
end
```
- `UnitChannelInfo("unit")` -- TBC/WotLK channel bar query:
```lua
local name, rank, text, icon, startTime, endTime, isTradeSkill, notInterruptible = UnitChannelInfo("target")
```
See the [DPSLog wiki page](https://codeberg.org/gwenael/WeirdUtils/wiki/DPSLog) for full event reference and addon developer guide.
**DLL:** `dpslog.dll`
@@ -239,6 +254,12 @@ No configuration needed. Enabled by default when using `weirdutils.dll`.
---
### Performance
Replaces 20+ internal math functions with SIMD (SSE/AVX) equivalents and swaps the game's 2004-era zlib with a modern decompression library (2.2x faster). Covers skeletal animation, particle rendering, frustum culling, collision detection, text glyph caching, and float-to-integer conversion. Most noticeable in cities, raids, and during zone transitions. No visual difference, no configuration needed. Included in `weirdutils.dll`.
---
### Timer Calibration
Improves the game's internal timer precision by recalibrating the TSC (Time Stamp Counter) frequency using the OS performance counter as a reference. The vanilla client's built-in calibration is inaccurate, which can cause animation stutter and timing jitter on some systems.
+8 -14
View File
@@ -41,7 +41,7 @@ pub fn build(b: *std.Build) void {
const target = b.resolveTargetQuery(.{
.cpu_arch = .x86,
.os_tag = .windows,
.abi = .msvc,
.abi = .gnu,
.cpu_features_add = std.Target.x86.featureSet(&.{ .sse, .sse2 }),
});
const optimize = b.option(std.builtin.OptimizeMode, "optimize", "Optimization mode (default: ReleaseFast)") orelse .ReleaseFast;
@@ -69,7 +69,7 @@ pub fn build(b: *std.Build) void {
const bone_sse_target = b.resolveTargetQuery(.{
.cpu_arch = .x86,
.os_tag = .windows,
.abi = .msvc,
.abi = .gnu,
.cpu_features_add = std.Target.x86.featureSet(&.{ .sse, .sse2, .sse3, .sse4_1, .fma, .avx }),
});
const bone_sse_obj = b.addObject(.{
@@ -86,7 +86,7 @@ pub fn build(b: *std.Build) void {
const ref_target = b.resolveTargetQuery(.{
.cpu_arch = .x86,
.os_tag = .windows,
.abi = .msvc,
.abi = .gnu,
.cpu_features_sub = std.Target.x86.featureSet(&.{ .sse, .sse2 }),
});
const bone_sse_ref_obj = b.addObject(.{
@@ -152,21 +152,15 @@ pub fn build(b: *std.Build) void {
lib.root_module.addObject(particle_sse_obj);
lib.root_module.addObject(particle_ref_obj);
// libdeflate — vendored C sources, decompress-only.
// Built as static library targeting x86-windows-gnu (has libc headers).
// libdeflate — compiled as x86-windows-gnu (has libc headers), linked as static lib.
// Disable x86 SIMD dispatch to avoid ABI mismatch between gnu and msvc objects.
// Generic C fallback is still ~2x faster than WoW's embedded zlib.
// libdeflate — compiled as x86-windows-gnu (same ABI as main DLL).
// Uses Zig's bundled MinGW libc for string.h/stdlib.h.
// malloc/free resolved at link time to our exports in performance.zig.
const libdeflate = b.addLibrary(.{
.linkage = .static,
.name = "deflate",
.root_module = b.createModule(.{
.root_source_file = null,
.target = b.resolveTargetQuery(.{
.cpu_arch = .x86,
.os_tag = .windows,
.abi = .gnu,
}),
.target = target,
.optimize = .ReleaseFast,
.link_libc = true,
}),
@@ -179,7 +173,7 @@ pub fn build(b: *std.Build) void {
"src/performance/libdeflate/lib/adler32.c",
"src/performance/libdeflate/lib/x86/cpu_features.c",
},
.flags = &.{ "-DLIBDEFLATE_ASSEMBLER_DOES_NOT_SUPPORT_AVX512VNNI", "-g0" },
.flags = &.{"-DLIBDEFLATE_ASSEMBLER_DOES_NOT_SUPPORT_AVX512VNNI"},
});
libdeflate.root_module.addIncludePath(b.path("src/performance/libdeflate"));
libdeflate.root_module.addIncludePath(b.path("src/performance/libdeflate/lib"));
+107 -239
View File
@@ -1,295 +1,163 @@
//! inflate_hook — libdeflate timing comparison hook for WoW's inflateStateMachine.
//! inflate_hook — libdeflate replacement for WoW's zlib inflate.
//!
//! Hooks BZip2Decompressor_Decompress (0x660740) which receives complete
//! compressed buffers. Runs both original and libdeflate, times both,
//! logs the comparison.
//! Hooks DecompressData_WithOptions (0x661A80). For type 0x02 (zlib) streams,
//! uses libdeflate (~2.2x faster than stock zlib). Falls back to original on failure.
//!
//! Thread-safety: the decompressor struct contains mutable decode tables rebuilt
//! per block, so each thread gets its own cached decompressor via a thread-local
//! pool keyed by Windows thread ID (FS:[0x24]). This avoids both the race condition
//! (shared decompressor → wild writes) and the per-call alloc overhead (~8-9% gain
//! over alloc/free each call).
//!
//! Benchmark results (84k calls, heavy load):
//! stock=2681ms | per-call-alloc=1304ms (2.05x) | tls-cached=1194ms (2.2x)
const hook_lib = @import("zhook");
const logging = @import("../logging.zig");
const FC: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
const SC: std.builtin.CallingConvention = .{ .x86_stdcall = .{} };
const std = @import("std");
// Game memory allocator: ReallocMemory(NULL, size, ...) = malloc, FreeMemory(ptr, ...) = free
const gameRealloc: *const fn (u32, u32, u32, u32, u32) callconv(SC) ?*anyopaque = @ptrFromInt(0x646320);
const gameFreeMemory: *const fn (u32, u32, u32, u32) callconv(SC) u32 = @ptrFromInt(0x646430);
fn gameAlloc(size: u32) ?*anyopaque {
return gameRealloc(0, size, 0, 0, 0);
}
fn gameFree(ptr: *anyopaque) void {
_ = gameFreeMemory(@intFromPtr(ptr), 0, 0, 0);
}
// libdeflate C API (linked from static lib)
// libdeflate C API
extern fn libdeflate_alloc_decompressor() ?*anyopaque;
extern var libdeflate_x86_cpu_features: u32;
extern fn libdeflate_free_decompressor(?*anyopaque) void;
extern fn libdeflate_zlib_decompress(
decompressor: ?*anyopaque,
in_ptr: [*]const u8,
in_len: usize,
out_ptr: [*]u8,
out_len: usize,
actual_out: *usize,
) c_int;
extern fn libdeflate_deflate_decompress(
decompressor: ?*anyopaque,
in_ptr: [*]const u8,
in_len: usize,
out_ptr: [*]u8,
out_len: usize,
actual_out: *usize,
) c_int;
extern fn libdeflate_zlib_decompress(?*anyopaque, [*]const u8, usize, [*]u8, usize, *usize) c_int;
var decompressor: ?*anyopaque = null;
var lib_available: bool = false;
var log: logging.Logger = .{};
// Static decompressor memory — avoids using game allocator which may not be malloc-compatible
var static_decompressor_mem: [12288]u8 align(16) = undefined; // 12KB > 11564 bytes needed
// --- Thread-local decompressor pool ---
// Keyed by Windows thread ID. WoW has ~5-10 threads; only 2-3 call decompress.
const TLS_SLOTS = 8;
const TlsSlot = struct {
thread_id: u32 = 0,
decomp: ?*anyopaque = null,
};
var tls_pool: [TLS_SLOTS]TlsSlot = [_]TlsSlot{.{}} ** TLS_SLOTS;
// Static buffers — no heap allocation needed.
// ld_buf: saves input before original modifies it (256KB)
// ld_out_buf: libdeflate output (256KB)
var ld_buf_backing: [256 * 1024]u8 align(16) = undefined;
var ld_buf: [*]u8 = &ld_buf_backing;
const ld_buf_size: u32 = 256 * 1024;
var ld_out_buf_backing: [256 * 1024]u8 align(16) = undefined;
const ld_out_buf_size: u32 = 256 * 1024;
// Thread safety: only run libdeflate on the main thread (ESP in 0x00Exxxxx range)
var main_thread_id: u32 = 0;
extern "kernel32" fn GetCurrentThreadId() callconv(.{ .x86_stdcall = .{} }) u32;
fn isMainThread() bool {
const tid = GetCurrentThreadId();
if (main_thread_id == 0) {
main_thread_id = tid; // first call captures main thread
return true;
}
return tid == main_thread_id;
fn getCurrentThreadId() u32 {
return asm volatile ("movl %%fs:0x24, %[ret]"
: [ret] "=r" (-> u32),
);
}
// Timing accumulators
var orig_total_cycles: u64 = 0; // ALL calls
var orig_matched_cycles: u64 = 0; // only calls where libdeflate also ran
var fast_total_cycles: u64 = 0; // libdeflate time for matched calls
fn getTlsDecompressor() ?*anyopaque {
const tid = getCurrentThreadId();
for (&tls_pool) |*slot| {
if (slot.thread_id == tid) return slot.decomp;
}
const decomp = libdeflate_alloc_decompressor() orelse return null;
for (&tls_pool) |*slot| {
if (slot.thread_id == 0) {
slot.thread_id = tid;
slot.decomp = decomp;
return decomp;
}
}
libdeflate_free_decompressor(decomp);
return null;
}
fn freeTlsPool() void {
for (&tls_pool) |*slot| {
if (slot.decomp) |d| libdeflate_free_decompressor(d);
slot.* = .{};
}
}
// --- Timing ---
var fast_total_cycles: u64 = 0;
var orig_total_cycles: u64 = 0;
var call_count: u64 = 0;
var mismatch_count: u64 = 0;
var success_count: u64 = 0;
var total_bytes: u64 = 0;
var total_in_bytes: u64 = 0;
// Per-type counters: index by compression mask bit
var type_counts: [8]u64 = .{0} ** 8; // bits 0-7
// Per raw type byte counter (full byte value)
var raw_type_counts: [256]u32 = .{0} ** 256;
// Track first-seen header bytes per type for format identification
var type_headers_logged: [256]bool = .{false} ** 256;
var fallback_count: u64 = 0;
inline fn rdtsc() u64 {
var lo: u32 = undefined;
var hi: u32 = undefined;
asm volatile ("rdtsc"
: [lo] "={eax}" (lo),
[hi] "={edx}" (hi),
);
asm volatile ("rdtsc" : [lo] "={eax}" (lo), [hi] "={edx}" (hi));
return (@as(u64, hi) << 32) | lo;
}
// =============================================================================
// Hook: DecompressData_WithOptions (0x661A80)
// __cdecl(outBuf, &outSize, inBuf, &inSize, flags) → returns ptr (0=fail, 1=ok)
//
// This is the top-level decompression dispatcher. It reads the first byte
// as a compression type bitmask and dispatches to the appropriate decompressor.
// We intercept here to run libdeflate on the same data for comparison.
// =============================================================================
const DecompressFn = fn (u32, u32, u32, u32, u32) callconv(SC) u32;
const DecompressFn = fn (u32, u32, u32, u32, u32) callconv(.{ .x86_stdcall = .{} }) u32;
pub var decompress_hook: hook_lib.Detour(DecompressFn) = .{};
pub fn decompressDetour(out_buf: u32, out_size_ptr: u32, in_buf: u32, in_size: u32, flags: u32) callconv(SC) u32 {
// param1 = outBuf, param2 = &outSize (ptr), param3 = inBuf, param4 = inSize (value), param5 = flags
// Read output buffer capacity BEFORE the original modifies *out_size_ptr
const out_capacity = @as(*const u32, @ptrFromInt(out_size_ptr)).*;
pub fn decompressDetour(out_buf: u32, out_size_ptr: u32, in_buf: u32, in_size: u32, flags: u32) callconv(.{ .x86_stdcall = .{} }) u32 {
const in_ptr: [*]const u8 = @ptrFromInt(in_buf);
const out_capacity = @as(*const u32, @ptrFromInt(out_size_ptr)).*;
// Save input data BEFORE calling original — the original may modify the input buffer.
// Use the static ld_buf_backing (256KB) as the save buffer — it's not used until later.
const save_len = @min(in_size, ld_buf_size);
@memcpy(ld_buf[0..save_len], in_ptr[0..save_len]);
// Accept type 0x02 with valid zlib CMF (deflate method = low nibble 0x08)
if (lib_available and in_size > 3 and out_capacity > 0 and
in_ptr[0] == 0x02 and (in_ptr[1] & 0x0F) == 0x08)
{
if (getTlsDecompressor()) |decomp| {
var ld_out: usize = 0;
const t1 = rdtsc();
const ld_ret = libdeflate_zlib_decompress(
decomp,
in_ptr + 1,
in_size - 1,
@ptrFromInt(out_buf),
out_capacity,
&ld_out,
);
fast_total_cycles +|= rdtsc() - t1;
// Run original and time it
const t0 = rdtsc();
const ret = decompress_hook.callOriginal(.{ out_buf, out_size_ptr, in_buf, in_size, flags });
const orig_cycles = rdtsc() - t0;
orig_total_cycles +|= orig_cycles;
call_count +|= 1;
if (ret != 0 and in_size > 2 and out_capacity > 0) {
const comp_type = in_ptr[0];
const actual_out = @as(*const u32, @ptrFromInt(out_size_ptr)).*;
total_bytes +|= actual_out;
total_in_bytes +|= in_size;
inline for (0..8) |bit| {
if ((comp_type & (@as(u8, 1) << @intCast(bit))) != 0)
type_counts[bit] +|= 1;
}
raw_type_counts[comp_type] +|= 1;
// Log first-seen header per type
if (!type_headers_logged[comp_type]) {
type_headers_logged[comp_type] = true;
log.fmt(" type=0x{x:0>2} in={d} out={d} hdr:", .{ comp_type, in_size, actual_out });
const dump_len = @min(in_size - 1, 16);
for (0..dump_len) |i| log.fmt(" {x:0>2}", .{in_ptr[1 + i]});
log.print("\n");
}
// Time libdeflate on zlib-only streams (type == 0x02, header 78 xx)
if (comp_type == 0x02 and decompressor != null and actual_out > 0) {
// Use actual_out (post-original) as the exact expected size.
// out_capacity (pre-original) may be larger than needed but actual_out
// is what the original produced — libdeflate should produce the same.
const ld_out_size = actual_out;
// Only proceed if: input fits, output fits, valid zlib header,
// AND we're on the main thread (static buffers aren't thread-safe)
if (in_size <= save_len and ld_out_size <= ld_out_buf_size and
save_len > 2 and ld_buf[1] == 0x78 and isMainThread())
{
// Log call number and sizes for first few calls
if (success_count + mismatch_count < 3) {
log.fmt(" ld #{d}: in_size={d} out_size={d} saved_hdr={x:0>2}{x:0>2}\n", .{
success_count + mismatch_count,
save_len - 1, ld_out_size,
ld_buf[1], ld_buf[2],
});
}
var ld_out: usize = 0;
const t1 = rdtsc();
const ld_ret = libdeflate_zlib_decompress(
decompressor,
ld_buf + 1, // skip type byte in saved input
save_len - 1,
&ld_out_buf_backing,
ld_out_size,
&ld_out,
);
const fast_cycles = rdtsc() - t1;
fast_total_cycles +|= fast_cycles;
orig_matched_cycles +|= orig_cycles; // track original time for same calls
if (ld_ret == 0 and ld_out == actual_out)
success_count +|= 1
else
mismatch_count +|= 1;
if (ld_ret == 0) {
call_count +|= 1;
success_count +|= 1;
return 1;
}
}
}
// Fallback to original
call_count +|= 1;
const t0 = rdtsc();
const ret = decompress_hook.callOriginal(.{ out_buf, out_size_ptr, in_buf, in_size, flags });
orig_total_cycles +|= rdtsc() - t0;
fallback_count +|= 1;
return ret;
}
pub fn dumpStats() void {
if (call_count == 0) return;
const MS_DIV: u64 = 3_000_000;
const type_names = [8][]const u8{ "huff", "zlib", "b2", "b3", "bzip", "pkw", "adpcm1", "adpcm2" };
const matched = success_count + mismatch_count;
log.fmt("inflate: {d} calls, in={d}KB out={d}KB, orig_all={d}ms | matched={d}: orig={d}ms fast={d}ms (ok={d} fail={d})\n", .{
call_count,
total_in_bytes / 1024,
total_bytes / 1024,
orig_total_cycles / MS_DIV,
matched,
orig_matched_cycles / MS_DIV,
fast_total_cycles / MS_DIV,
success_count,
mismatch_count,
const MS = 3_000_000;
log.fmt("inflate: {d} calls | fast={d} ({d}ms) fallback={d} ({d}ms)\n", .{
call_count, success_count, fast_total_cycles / MS, fallback_count, orig_total_cycles / MS,
});
// Type bits distribution
log.print(" bits:");
for (type_names, 0..) |name, i| {
if (type_counts[i] > 0) {
log.fmt(" {s}={d}", .{ name, type_counts[i] });
}
}
log.print("\n");
// Raw type byte distribution (shows exact combos used)
log.print(" raw:");
for (raw_type_counts, 0..) |count, i| {
if (count > 0) {
log.fmt(" 0x{x:0>2}={d}", .{ i, count });
}
}
log.print("\n");
// Reset
orig_total_cycles = 0;
orig_matched_cycles = 0;
fast_total_cycles = 0;
orig_total_cycles = 0;
call_count = 0;
success_count = 0;
mismatch_count = 0;
total_bytes = 0;
total_in_bytes = 0;
for (&type_counts) |*c| c.* = 0;
for (&raw_type_counts) |*c| c.* = 0;
fallback_count = 0;
}
fn staticMalloc(size: usize) callconv(.c) ?*anyopaque {
if (size <= static_decompressor_mem.len) {
return @ptrCast(&static_decompressor_mem);
}
return null;
}
fn staticFree(_: ?*anyopaque) callconv(.c) void {}
extern fn libdeflate_alloc_decompressor_with_funcs(?*const fn (usize) callconv(.c) ?*anyopaque, ?*const fn (?*anyopaque) callconv(.c) void) ?*anyopaque;
pub fn install(logger: logging.Logger) bool {
log = logger;
// Use static memory — bypass game allocator entirely
decompressor = libdeflate_alloc_decompressor();
if (decompressor == null) {
log.print("inflate_hook: failed to allocate libdeflate decompressor\n");
// Sanity test
const test_decomp = libdeflate_alloc_decompressor();
if (test_decomp == null) {
log.print("inflate_hook: alloc failed\n");
return false;
}
// Quick sanity test: decompress a trivial zlib stream
{
// zlib-compressed "hello" (pre-computed)
const test_in = [_]u8{ 0x78, 0x9C, 0xCB, 0x48, 0xCD, 0xC9, 0xC9, 0x07, 0x00, 0x06, 0x2C, 0x02, 0x15 };
var test_out: [64]u8 = undefined;
var test_len: usize = 0;
const test_ret = libdeflate_zlib_decompress(
decompressor,
&test_in,
test_in.len,
&test_out,
test_out.len,
&test_len,
);
log.fmt("inflate_hook: sanity test ret={d} len={d} data='{s}'\n", .{
test_ret, test_len, test_out[0..@min(test_len, 32)],
});
}
log.fmt("inflate_hook: cpu_features=0x{x:0>8}\n", .{libdeflate_x86_cpu_features});
const test_in = [_]u8{ 0x78, 0x9C, 0xCB, 0x48, 0xCD, 0xC9, 0xC9, 0x07, 0x00, 0x06, 0x2C, 0x02, 0x15 };
var test_out: [64]u8 = undefined;
var test_len: usize = 0;
const test_ret = libdeflate_zlib_decompress(test_decomp, &test_in, test_in.len, &test_out, 64, &test_len);
log.fmt("inflate_hook: sanity ret={d} len={d} data='{s}'\n", .{ test_ret, test_len, test_out[0..@min(test_len, 32)] });
libdeflate_free_decompressor(test_decomp);
lib_available = true;
if (decompress_hook.attach(0x661A80, &decompressDetour) == .ok) {
log.print("inflate_hook: hooked DecompressData_WithOptions\n");
log.print("inflate_hook: hooked (libdeflate tls-cached, 2.2x speedup)\n");
return true;
}
log.print("inflate_hook: failed to hook\n");
return false;
}
pub fn remove() void {
decompress_hook.detach();
if (decompressor) |d| {
libdeflate_free_decompressor(d);
decompressor = null;
}
lib_available = false;
freeTlsPool();
}
+4 -14
View File
@@ -28,23 +28,11 @@ pub const module_name: [*:0]const u8 = "performance";
const gameRealloc: *const fn (u32, u32, u32, u32, u32) callconv(.{ .x86_stdcall = .{} }) ?*anyopaque = @ptrFromInt(0x646320);
const gameFree: *const fn (u32, u32, u32, u32) callconv(.{ .x86_stdcall = .{} }) u32 = @ptrFromInt(0x646430);
// Static buffer for libdeflate's decompressor struct (~11.5KB).
// Avoids game allocator which may not be fully malloc-compatible.
var static_alloc_buf: [16384]u8 align(16) = undefined;
var static_alloc_used: bool = false;
export fn malloc(size: usize) callconv(.c) ?*anyopaque {
// First allocation goes to static buffer (the decompressor struct)
if (!static_alloc_used and size <= static_alloc_buf.len) {
static_alloc_used = true;
return @ptrCast(&static_alloc_buf);
}
return gameRealloc(0, @intCast(size), 0, 0, 0);
}
export fn free(ptr: ?*anyopaque) callconv(.c) void {
// Don't free static buffer
if (ptr == @as(?*anyopaque, @ptrCast(&static_alloc_buf))) return;
if (ptr) |p| _ = gameFree(@intFromPtr(p), 0, 0, 0);
}
@@ -123,7 +111,8 @@ fn glyphDetour(a: u32, b: u32, c: u32, d: u32) callconv(hook.cc.fastcall) ?*anyo
if (entry.font_ptr == a and entry.char_code == c and entry.param2 == d) {
asm volatile ("flds (%[p])"
:: [p] "r" (&entry.width_bits)
:
: [p] "r" (&entry.width_bits),
);
return null;
}
@@ -132,7 +121,8 @@ fn glyphDetour(a: u32, b: u32, c: u32, d: u32) callconv(hook.cc.fastcall) ?*anyo
var width_bits: u32 = undefined;
asm volatile ("fsts (%[p])"
:: [p] "r" (&width_bits)
:
: [p] "r" (&width_bits),
);
entry.* = .{