Move file cache to standalone module, add timer fix, fix refcount crashes

File cache (filecache module):
- Moved from transform44 sub-module to standalone src/filecache/
- 2-way set-associative cache (32768 sets x 2 ways) with FNV-1a hash + finalizer
- Fixed negative cache hit crash: zero output params before returning 0
  (FindFileInArchive reuses filename slot for out_outer_archive)
- Fixed path 2 crash: set out_outer_archive on all cache hit paths
- Fixed stale block_entry crash: cache block index instead of raw pointer,
  recompute from archive+0x290 on each hit
- Fixed archive-freed crash: use game's FindAndIncrementResourceReference
  (0x650780) instead of manual +0x38 increment -- validates archive is alive
- Periodic stats dump with projected time savings (hit=~1000cy vs miss=~30000cy)

Timer fix (transform44 sub-module, ported from VanillaFixes):
- TSC calibration via QPC reference over 500ms
- Enables TSC mode if game was using GetTickCount fallback
- NtSetTimerResolution for 0.5ms OS timer granularity
- SetProcessInformation to disable Windows 11 power throttling
- Always-on (no A/B toggle -- no measurable impact on Wine/Linux)
This commit is contained in:
MarcelineVQ
2026-03-14 14:57:58 -07:00
parent 0833d23e28
commit 692a02ed2c
6 changed files with 571 additions and 172 deletions
+10
View File
@@ -30,6 +30,7 @@ const module_list = [_]ModuleDesc{
.{ .name = "dpslog", .desc = "Enable structured combat log events for addons", .default = false },
.{ .name = "transform44", .desc = "Enable transformMatrix4x4 hook", .default = false },
.{ .name = "addonperf", .desc = "Enable addon memory/CPU profiling API" },
.{ .name = "filecache", .desc = "Enable MPQ archive file cache", .default = false },
};
pub fn build(b: *std.Build) void {
@@ -68,6 +69,14 @@ pub fn build(b: *std.Build) void {
.optimize = .ReleaseFast,
}),
});
const math_sse_obj = b.addObject(.{
.name = "math_sse",
.root_module = b.createModule(.{
.root_source_file = b.path("src/transform44/math_sse.zig"),
.target = target,
.optimize = .ReleaseFast,
}),
});
const lib = b.addLibrary(.{
.name = "weirdutils",
@@ -84,6 +93,7 @@ pub fn build(b: *std.Build) void {
});
lib.root_module.addObject(clip_sse_obj);
lib.root_module.addObject(bone_sse_obj);
lib.root_module.addObject(math_sse_obj);
b.installArtifact(lib);
// Convenience step to build all single-module variants
+254
View File
@@ -0,0 +1,254 @@
//! filecache -- MPQ archive pointer cache for File_FindInArchive (0x6549a0)
//!
//! 2-way set-associative cache: hash(filename) picks a set of 2 entries.
//! Filename stored and compared for collision safety. On eviction, the
//! least recently used way is replaced.
const std = @import("std");
const hook = @import("zhook");
const logging = @import("../logging.zig");
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "filecache";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
var log_state: logging.Logger = .{};
pub inline fn rdtsc() u64 {
var lo: u32 = undefined;
var hi: u32 = undefined;
asm volatile ("rdtsc"
: [lo] "={eax}" (lo),
[hi] "={edx}" (hi),
);
return @as(u64, hi) << 32 | lo;
}
// FNV-1a 32-bit, raw bytes (no normalization). Different casings get different slots.
// Finalizer mixes high bits into low bits for better slot distribution.
pub fn hashPath(path: [*:0]const u8) u32 {
var h: u32 = 0x811c9dc5;
var i: usize = 0;
while (path[i] != 0) : (i += 1) {
h ^= path[i];
h *%= 0x01000193;
}
h ^= h >> 16;
return h;
}
// Longest filename across all MPQ archives is 122 chars. 128 covers all.
const CACHE_NAME_LEN = 128;
const NUM_SETS = 32768; // power of 2
const WAYS = 2; // 2-way set-associative
pub const ArchiveCacheEntry = struct {
outer_archive: u32 = 0,
inner_archive: u32 = 0,
block_index: u32 = 0, // index into archive's block table, not raw pointer
is_negative: bool = false,
name: [CACHE_NAME_LEN]u8 = .{0} ** CACHE_NAME_LEN,
name_len: u8 = 0,
};
const CacheSet = struct {
entries: [WAYS]ArchiveCacheEntry = .{ArchiveCacheEntry{}} ** WAYS,
lru: u8 = 0,
};
var cache: [NUM_SETS]CacheSet = @splat(CacheSet{});
var cache_entries: u32 = 0;
var cache_hits: u64 = 0;
var cache_negative_hits: u64 = 0;
var cache_misses: u64 = 0;
var cache_stale: u64 = 0;
var hit_cycles: u64 = 0;
var miss_cycles: u64 = 0;
var cache_miss_p1: u64 = 0;
var cache_miss_p2: u64 = 0;
var cache_miss_p2_archive: u64 = 0;
/// Lookup: hash picks set, check both ways for name match.
pub fn archiveCacheLookup(h: u32, path: [*:0]const u8) ?ArchiveCacheEntry {
const set_idx = h & (NUM_SETS - 1);
const set = &cache[set_idx];
const span = std.mem.span(path);
for (0..WAYS) |w| {
const entry = &set.entries[w];
if (entry.name_len == 0) continue;
if (span.len != entry.name_len) continue;
const len: usize = entry.name_len;
if (std.mem.eql(u8, entry.name[0..len], span[0..len])) {
set.lru = @intCast(w ^ 1);
return entry.*;
}
}
return null;
}
/// Insert: hash picks set, store in empty way or evict LRU.
/// Recompute block_entry pointer from archive's current block table + cached index.
/// block_entry = archive->block_table_data + index * 0x2C
pub fn computeBlockEntry(archive: u32, index: u32) u32 {
if (archive == 0) return 0;
const block_table_base = hook.readMem(u32, archive + 0x290);
if (block_table_base == 0) return 0;
return block_table_base + index * 0x2C;
}
pub fn archiveCacheInsert(h: u32, path: [*:0]const u8, outer: u32, inner: u32, block: u32, negative: bool) void {
const span = std.mem.span(path);
if (span.len > CACHE_NAME_LEN) return;
const set_idx = h & (NUM_SETS - 1);
const set = &cache[set_idx];
var target: u8 = set.lru;
for (0..WAYS) |w| {
if (set.entries[w].name_len == 0) {
target = @intCast(w);
cache_entries += 1;
break;
}
}
const entry = &set.entries[target];
entry.outer_archive = outer;
entry.inner_archive = inner;
// Convert block_entry pointer to index: (ptr - base) / 0x2C
if (block != 0 and inner != 0) {
const base = hook.readMem(u32, inner + 0x290);
entry.block_index = if (base != 0) (block - base) / 0x2C else 0;
} else {
entry.block_index = 0;
}
entry.is_negative = negative;
const len: u8 = @intCast(span.len);
@memcpy(entry.name[0..len], span[0..len]);
entry.name_len = len;
set.lru = target ^ 1;
}
pub fn recordCacheHit() void { cache_hits +|= 1; }
pub fn addHitCycles(c: u64) void { hit_cycles +|= c; }
pub fn addMissCycles(c: u64) void { miss_cycles +|= c; }
pub fn recordNegativeHit() void { cache_negative_hits +|= 1; }
pub fn recordCacheMiss() void { cache_misses +|= 1; }
pub fn recordCacheStale() void { cache_stale +|= 1; }
pub fn recordMissP1() void { cache_miss_p1 +|= 1; }
pub fn recordMissP2() void { cache_miss_p2 +|= 1; }
pub fn recordMissP2Archive() void { cache_miss_p2_archive +|= 1; }
pub fn getSlotOccupant(h: u32) ?[]const u8 {
const set_idx = h & (NUM_SETS - 1);
const set = &cache[set_idx];
for (0..WAYS) |w| {
if (set.entries[w].name_len != 0)
return set.entries[w].name[0..set.entries[w].name_len];
}
return null;
}
pub const CacheStats = struct {
hits: u64, neg_hits: u64, misses: u64, stale: u64,
miss_p1: u64, miss_p2: u64, miss_p2_archive: u64,
entries: u32, total: u64,
};
pub fn getCacheStats() CacheStats {
return .{
.hits = cache_hits,
.neg_hits = cache_negative_hits,
.misses = cache_misses,
.stale = cache_stale,
.miss_p1 = cache_miss_p1,
.miss_p2 = cache_miss_p2,
.miss_p2_archive = cache_miss_p2_archive,
.entries = cache_entries,
.total = cache_hits + cache_negative_hits + cache_misses + cache_stale,
};
}
pub fn resetStats() void {
cache_hits = 0;
cache_negative_hits = 0;
cache_misses = 0;
cache_stale = 0;
cache_miss_p1 = 0;
cache_miss_p2 = 0;
cache_miss_p2_archive = 0;
hit_cycles = 0;
miss_cycles = 0;
}
fn pct(part: u64, total: u64) u64 {
if (total == 0) return 0;
return part *| 1000 / total;
}
pub fn dumpStats() void {
const fc = getCacheStats();
if (fc.total == 0) return;
const hit_pct = pct(fc.hits + fc.neg_hits, fc.total);
// @3GHz: cycles/3000 = us, cycles/3000000 = ms
const total_hit_calls = fc.hits + fc.neg_hits;
const avg_hit = if (total_hit_calls > 0) hit_cycles / total_hit_calls else 0;
const avg_miss = if (fc.misses > 0) miss_cycles / fc.misses else 0;
// Without cache, hits would have cost avg_miss each
const saved_us = if (avg_miss > avg_hit) total_hit_calls * (avg_miss - avg_hit) / 3000 else 0;
if (saved_us >= 2000) {
log_state.fmt(" file_cache: {d}.{d}% hit ({d}hit/{d}neg/{d}miss) {d} entries | saved {d}ms (avg hit={d}cy miss={d}cy)\n", .{
hit_pct / 10, hit_pct % 10,
fc.hits, fc.neg_hits, fc.misses, fc.entries,
saved_us / 1000, avg_hit, avg_miss,
});
} else {
log_state.fmt(" file_cache: {d}.{d}% hit ({d}hit/{d}neg/{d}miss) {d} entries | saved {d}us (avg hit={d}cy miss={d}cy)\n", .{
hit_pct / 10, hit_pct % 10,
fc.hits, fc.neg_hits, fc.misses, fc.entries,
saved_us, avg_hit, avg_miss,
});
}
resetStats();
}
// Frame counting for periodic stats dump
const DUMP_FRAMES: u64 = 900; // ~15s at 60fps
var frame_count: u64 = 0;
const WorldUpdateFn = fn (u32) callconv(hook.cc.fastcall) void;
var world_update_hook: hook.Detour(WorldUpdateFn) = .{};
fn worldUpdateDetour(fc: u32) callconv(hook.cc.fastcall) void {
world_update_hook.callOriginal(.{fc});
frame_count +|= 1;
if (frame_count >= DUMP_FRAMES) {
dumpStats();
frame_count = 0;
}
}
// Module lifecycle
pub fn isActive() bool { return g_is_hook_owner; }
pub fn installHooks() void {
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
log_state = logging.Logger.open(module_name, .console);
_ = world_update_hook.attach(0x482EA0, &worldUpdateDetour);
log_state.print("filecache: active\n");
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
world_update_hook.detach();
dumpStats();
log_state.close();
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}
+51 -48
View File
@@ -20,6 +20,7 @@ const build_opts = struct {
const dpslog = @import("build_options").enable_dpslog;
const transform44 = @import("build_options").enable_transform44;
const addonperf = @import("build_options").enable_addonperf;
const filecache = @import("build_options").enable_filecache;
};
// Conditional module imports
@@ -38,7 +39,7 @@ const clickthrough = if (build_opts.clickthrough) @import("clickthrough/clickthr
const dpslog = if (build_opts.dpslog) @import("dpslog/dpslog.zig") else struct {};
const transform44 = if (build_opts.transform44) @import("transform44/transform44.zig") else struct {};
const addonperf = if (build_opts.addonperf) @import("addonperf/addonperf.zig") else struct {};
const file_cache = if (build_opts.transform44) @import("transform44/file_cache.zig") else struct {};
const file_cache = if (build_opts.filecache) @import("filecache/filecache.zig") else struct {};
const module_active = @import("module_active.zig");
@@ -534,91 +535,92 @@ fn fileFindDetour(
out_block_entry: u32, // ptr to ptr: block table entry data
out_disk_path: u32, // ptr to buf: disk path output
) callconv(hook.cc.fastcall) u32 {
if (!build_opts.transform44 or filename_ptr == 0)
if (!build_opts.filecache or filename_ptr == 0)
return file_find_hook.callOriginal(.{ archive_or_group, filename_ptr, flags, out_inner_archive, out_outer_archive, out_block_entry, out_disk_path });
// --- Time the original (baseline) path first ---
const baseline_start = transform44.rdtscPub();
const orig_ret = file_find_hook.callOriginal(.{ archive_or_group, filename_ptr, flags, out_inner_archive, out_outer_archive, out_block_entry, out_disk_path });
const baseline_elapsed = transform44.rdtscPub() - baseline_start;
// --- Now time the cached path (overwrites output ptrs with same or cached values) ---
const cache_start = transform44.rdtscPub();
const tsc_start = file_cache.rdtsc();
const path: [*:0]const u8 = @ptrFromInt(filename_ptr);
const h = file_cache.hashPath(path);
if (file_cache.archiveCacheLookup(h, path)) |cached| {
cache_check: {
const cached = file_cache.archiveCacheLookup(h, path) orelse break :cache_check;
if (cached.is_negative and archive_or_group == 0) {
file_cache.recordNegativeHit();
const cache_elapsed = transform44.rdtscPub() - cache_start;
transform44.addFilefindCycles(cache_elapsed, baseline_elapsed);
return orig_ret;
if (out_outer_archive != 0) @as(*align(1) u32, @ptrFromInt(out_outer_archive)).* = 0;
if (out_inner_archive != 0) @as(*align(1) u32, @ptrFromInt(out_inner_archive)).* = 0;
if (out_block_entry != 0) @as(*align(1) u32, @ptrFromInt(out_block_entry)).* = 0;
hook.call(fn (u32) callconv(hook.cc.stdcall) void, 0x64e850, .{2});
file_cache.addHitCycles(file_cache.rdtsc() - tsc_start);
return 0;
}
if (!cached.is_negative) {
// Path 1: search-all
if (archive_or_group == 0 and out_outer_archive != 0) {
@as(*align(1) u32, @ptrFromInt(out_outer_archive)).* = cached.outer_archive;
if (cached.outer_archive != 0) {
@as(*align(1) i32, @ptrFromInt(cached.outer_archive + 0x38)).* += 1;
}
if (out_inner_archive != 0) {
@as(*align(1) u32, @ptrFromInt(out_inner_archive)).* = cached.inner_archive;
if (cached.inner_archive != 0) {
@as(*align(1) i32, @ptrFromInt(cached.inner_archive + 0x38)).* += 1;
}
const valid_outer = if (cached.outer_archive != 0)
hook.call(fn (u32, u32) callconv(hook.cc.fastcall) u32, 0x650780, .{ cached.outer_archive, 0 })
else
0;
if (valid_outer == 0 and cached.outer_archive != 0) break :cache_check;
@as(*align(1) u32, @ptrFromInt(out_outer_archive)).* = valid_outer;
if (out_inner_archive != 0 and cached.inner_archive != 0) {
const valid_inner = hook.call(fn (u32, u32) callconv(hook.cc.fastcall) u32, 0x650780, .{ cached.inner_archive, 0 });
@as(*align(1) u32, @ptrFromInt(out_inner_archive)).* = valid_inner;
} else if (out_inner_archive != 0) {
@as(*align(1) u32, @ptrFromInt(out_inner_archive)).* = 0;
}
if (out_block_entry != 0) {
@as(*align(1) u32, @ptrFromInt(out_block_entry)).* = cached.block_entry;
@as(*align(1) u32, @ptrFromInt(out_block_entry)).* = file_cache.computeBlockEntry(cached.inner_archive, cached.block_index);
}
file_cache.recordCacheHit();
const cache_elapsed = transform44.rdtscPub() - cache_start;
transform44.addFilefindCycles(cache_elapsed, baseline_elapsed);
return orig_ret;
file_cache.addHitCycles(file_cache.rdtsc() - tsc_start);
return 1;
}
// Path 2: specific archive
if (archive_or_group != 0 and (archive_or_group == cached.outer_archive or archive_or_group == cached.inner_archive)) {
if (out_outer_archive != 0) {
@as(*align(1) u32, @ptrFromInt(out_outer_archive)).* = cached.outer_archive;
if (cached.outer_archive != 0) {
@as(*align(1) i32, @ptrFromInt(cached.outer_archive + 0x38)).* += 1;
}
if (out_outer_archive != 0 and cached.outer_archive != 0) {
const valid = hook.call(fn (u32, u32) callconv(hook.cc.fastcall) u32, 0x650780, .{ cached.outer_archive, 0 });
if (valid == 0) break :cache_check;
@as(*align(1) u32, @ptrFromInt(out_outer_archive)).* = valid;
} else if (out_outer_archive != 0) {
@as(*align(1) u32, @ptrFromInt(out_outer_archive)).* = 0;
}
if (out_inner_archive != 0) {
@as(*align(1) u32, @ptrFromInt(out_inner_archive)).* = cached.inner_archive;
if (cached.inner_archive != 0) {
@as(*align(1) i32, @ptrFromInt(cached.inner_archive + 0x38)).* += 1;
}
if (out_inner_archive != 0 and cached.inner_archive != 0) {
const valid = hook.call(fn (u32, u32) callconv(hook.cc.fastcall) u32, 0x650780, .{ cached.inner_archive, 0 });
if (valid == 0) break :cache_check;
@as(*align(1) u32, @ptrFromInt(out_inner_archive)).* = valid;
} else if (out_inner_archive != 0) {
@as(*align(1) u32, @ptrFromInt(out_inner_archive)).* = 0;
}
if (out_block_entry != 0) {
@as(*align(1) u32, @ptrFromInt(out_block_entry)).* = cached.block_entry;
@as(*align(1) u32, @ptrFromInt(out_block_entry)).* = file_cache.computeBlockEntry(cached.inner_archive, cached.block_index);
}
file_cache.recordCacheHit();
const cache_elapsed = transform44.rdtscPub() - cache_start;
transform44.addFilefindCycles(cache_elapsed, baseline_elapsed);
return orig_ret;
file_cache.addHitCycles(file_cache.rdtsc() - tsc_start);
return 1;
}
}
}
// Cache miss — populate cache from the original's results
// Cache miss — call original and populate cache
file_cache.recordCacheMiss();
const ret = file_find_hook.callOriginal(.{ archive_or_group, filename_ptr, flags, out_inner_archive, out_outer_archive, out_block_entry, out_disk_path });
file_cache.addMissCycles(file_cache.rdtsc() - tsc_start);
if (archive_or_group != 0 and out_inner_archive != 0 and out_block_entry != 0) {
if (orig_ret == 1) {
if (ret == 1) {
const inner = hook.readMem(u32, out_inner_archive);
const block = hook.readMem(u32, out_block_entry);
file_cache.archiveCacheInsert(h, path, archive_or_group, inner, block, false);
}
}
if (archive_or_group == 0 and orig_ret == 0) {
if (archive_or_group == 0 and ret == 0) {
file_cache.archiveCacheInsert(h, path, 0, 0, 0, true);
}
const cache_elapsed = transform44.rdtscPub() - cache_start;
transform44.addFilefindCycles(cache_elapsed, baseline_elapsed);
return orig_ret;
return ret;
}
// --- Install/remove in-memory file hooks ---
@@ -630,7 +632,7 @@ fn installFileHooks() void {
_ = cleanup_file_handle_hook.attach(0x648730, &cleanupFileHandleDetour);
_ = model_load_hook.attach(0x71d4e0, &loadModelAsyncDetour);
_ = cfe_hook.attach(0x654DD0, &checkFileExistenceDetour);
if (build_opts.transform44) {
if (build_opts.filecache) {
_ = file_find_hook.attach(0x6549a0, &fileFindDetour);
log.print("archive cache hook installed\n");
}
@@ -751,6 +753,7 @@ const modules = [_]ModuleHooks{
if (build_opts.dpslog) .{ .name = dpslog.module_name, .install = dpslog.installHooks, .remove = dpslog.removeHooks, .is_active = dpslog.isActive } else .{},
if (build_opts.transform44) .{ .name = transform44.module_name, .install = transform44.installHooks, .remove = transform44.removeHooks, .is_active = transform44.isActive } else .{},
if (build_opts.addonperf) .{ .name = addonperf.module_name, .install = addonperf.installHooks, .remove = addonperf.removeHooks, .is_active = addonperf.isActive } else .{},
if (build_opts.filecache) .{ .name = file_cache.module_name, .install = file_cache.installHooks, .remove = file_cache.removeHooks, .is_active = file_cache.isActive } else .{},
if (build_opts.worldmarkers) .{ .name = markers.module_name, .install = markers.installHooks, .remove = markers.removeHooks, .is_active = markers.isActive } else .{},
if (build_opts.interact) .{ .name = interact.module_name, .install = interact.installHooks, .remove = interact.removeHooks, .is_active = interact.isActive } else .{},
if (build_opts.outline) .{ .name = outline.module_name, .remove = outline.cleanup, .is_active = outline.isActive } else .{},
-107
View File
@@ -1,107 +0,0 @@
//! file_cache — MPQ archive pointer cache for File_FindInArchive (0x6549a0)
//!
//! Direct-mapped cache: hash(filename) → {outer_archive, inner_archive, block_entry}.
//! Populated on successful File_FindInStorage calls (where all three values are
//! available). Used to skip both the archive chain walk and per-archive hash lookup.
//!
//! MPQ archives are loaded at startup and never modified, so cached pointers
//! (archive structs, block table entries) remain valid for the entire session.
//!
//! Cache verification uses stored filename comparison (not hash alone) to
//! guarantee no wrong data is ever returned on hash collisions.
const std = @import("std");
// FNV-1a 32-bit, case-insensitive, backslash-normalized (for cache slot indexing).
pub fn hashPath(path: [*:0]const u8) u32 {
var h: u32 = 0x811c9dc5;
var i: usize = 0;
while (path[i] != 0) : (i += 1) {
var c = path[i];
if (c >= 'A' and c <= 'Z') c += 32;
if (c == '\\') c = '/';
h ^= c;
h *%= 0x01000193;
}
return h;
}
// Longest filename across all MPQ archives is 122 chars (macOS .nib path in base.MPQ).
// Longest game-relevant path is 114 chars (WMO models). 128 covers all with margin.
// Paths exceeding this are not cached (safe fallback to original function).
const CACHE_NAME_LEN = 128;
const CACHE_SIZE = 16384; // power of 2, direct-mapped
pub const ArchiveCacheEntry = struct {
outer_archive: u32 = 0,
inner_archive: u32 = 0,
block_entry: u32 = 0,
is_negative: bool = false,
name: [CACHE_NAME_LEN]u8 = .{0} ** CACHE_NAME_LEN,
name_len: u8 = 0,
};
var archive_cache: [CACHE_SIZE]ArchiveCacheEntry = @splat(ArchiveCacheEntry{});
var cache_entries: u32 = 0;
var cache_hits: u64 = 0;
var cache_negative_hits: u64 = 0;
var cache_misses: u64 = 0;
/// Lookup by hash (slot index) + filename verification. Returns null on miss or name mismatch.
pub fn archiveCacheLookup(h: u32, path: [*:0]const u8) ?ArchiveCacheEntry {
const idx = h & (CACHE_SIZE - 1);
const entry = &archive_cache[idx];
if (entry.name_len == 0) return null;
const span = std.mem.span(path);
if (span.len != entry.name_len) return null;
const len: usize = entry.name_len;
if (!std.mem.eql(u8, entry.name[0..len], span[0..len])) return null;
return entry.*;
}
/// Insert: hash picks slot, filename stored for verification on future lookups.
pub fn archiveCacheInsert(h: u32, path: [*:0]const u8, outer: u32, inner: u32, block: u32, negative: bool) void {
const span = std.mem.span(path);
if (span.len > CACHE_NAME_LEN) return; // too long to cache, skip
const idx = h & (CACHE_SIZE - 1);
if (archive_cache[idx].name_len == 0) cache_entries += 1;
const entry = &archive_cache[idx];
entry.outer_archive = outer;
entry.inner_archive = inner;
entry.block_entry = block;
entry.is_negative = negative;
const len: u8 = @intCast(span.len);
@memcpy(entry.name[0..len], span[0..len]);
entry.name_len = len;
}
pub fn recordCacheHit() void {
cache_hits +|= 1;
}
pub fn recordNegativeHit() void {
cache_negative_hits +|= 1;
}
pub fn recordCacheMiss() void {
cache_misses +|= 1;
}
pub const CacheStats = struct { hits: u64, neg_hits: u64, misses: u64, entries: u32, total: u64 };
pub fn getCacheStats() CacheStats {
return .{
.hits = cache_hits,
.neg_hits = cache_negative_hits,
.misses = cache_misses,
.entries = cache_entries,
.total = cache_hits + cache_negative_hits + cache_misses,
};
}
/// Reset cache stats but preserve cached data (archive pointers remain valid).
pub fn resetStats() void {
cache_hits = 0;
cache_negative_hits = 0;
cache_misses = 0;
}
+249
View File
@@ -0,0 +1,249 @@
//! timer_fix -- TSC calibration + OS timer tweaks (ported from VanillaFixes)
//!
//! The vanilla client poorly calibrates its RDTSC frequency, causing animation
//! stutter. We recalibrate using QueryPerformanceFrequency as reference and
//! patch the game's timer globals. A/B tested: BASELINE restores original
//! values, CUSTOM applies calibrated values.
//!
//! Timer globals (1.12.1 build 5875):
//! pUseTSC: 0x00884c80 (BOOL)
//! pTimerTicksPerSecond: 0x008332c0 (u64)
//! pTimerToMilliseconds: 0x008332c8 (f64)
//! pTimerOffset: 0x00884c88 (f64)
const hook = @import("zhook");
const ADDR_USE_TSC: usize = 0x00884c80;
const ADDR_TICKS_PER_SEC: usize = 0x008332c0;
const ADDR_TO_MS: usize = 0x008332c8;
const ADDR_OFFSET: usize = 0x00884c88;
const WINAPI = @import("std").builtin.CallingConvention.winapi;
const LARGE_INTEGER = extern struct { QuadPart: i64 };
extern "kernel32" fn QueryPerformanceCounter(lpPerformanceCount: *LARGE_INTEGER) callconv(WINAPI) i32;
extern "kernel32" fn QueryPerformanceFrequency(lpFrequency: *LARGE_INTEGER) callconv(WINAPI) i32;
extern "kernel32" fn GetTickCount() callconv(WINAPI) u32;
extern "kernel32" fn GetCurrentThread() callconv(WINAPI) usize;
extern "kernel32" fn SetThreadPriority(hThread: usize, nPriority: i32) callconv(WINAPI) i32;
extern "kernel32" fn GetCurrentProcessorNumber() callconv(WINAPI) u32;
extern "kernel32" fn SetThreadAffinityMask(hThread: usize, dwThreadAffinityMask: usize) callconv(WINAPI) usize;
extern "kernel32" fn Sleep(dwMilliseconds: u32) callconv(WINAPI) void;
// Windows API for OS timer tweaks
extern "kernel32" fn GetModuleHandleA(lpModuleName: [*:0]const u8) callconv(WINAPI) ?*anyopaque;
extern "kernel32" fn GetProcAddress(hModule: *anyopaque, lpProcName: [*:0]const u8) callconv(WINAPI) ?*anyopaque;
extern "kernel32" fn GetCurrentProcess() callconv(WINAPI) usize;
fn increaseTimerResolution() void {
const ntdll = GetModuleHandleA("ntdll") orelse return;
const NtQueryFn = *const fn (*u32, *u32, *u32) callconv(WINAPI) i32;
const NtSetFn = *const fn (u32, i32, *u32) callconv(WINAPI) i32;
const query_ptr = GetProcAddress(ntdll, "NtQueryTimerResolution");
const set_ptr = GetProcAddress(ntdll, "NtSetTimerResolution");
if (query_ptr != null and set_ptr != null) {
const query: NtQueryFn = @ptrCast(query_ptr.?);
const set: NtSetFn = @ptrCast(set_ptr.?);
var min_res: u32 = 0;
var max_res: u32 = 0;
var cur_res: u32 = 0;
_ = query(&min_res, &max_res, &cur_res);
// Request 0.5ms (5000 * 100ns) or hardware max, whichever is larger
const desired = @max(max_res, 5000);
var actual: u32 = 0;
_ = set(desired, 1, &actual); // 1 = TRUE (enable)
return;
}
// Fallback: timeBeginPeriod(1) via winmm
const winmm = GetModuleHandleA("winmm") orelse return;
const TbpFn = *const fn (u32) callconv(WINAPI) u32;
const tbp_ptr = GetProcAddress(winmm, "timeBeginPeriod") orelse return;
const tbp: TbpFn = @ptrCast(tbp_ptr);
_ = tbp(1);
}
fn disablePowerThrottling() void {
const kernel32 = GetModuleHandleA("kernel32") orelse return;
const SpiPtr = GetProcAddress(kernel32, "SetProcessInformation") orelse return;
// ProcessPowerThrottling = 4
const PowerThrottlingState = extern struct {
Version: u32,
ControlMask: u32,
StateMask: u32,
};
const SetProcessInfoFn = *const fn (usize, u32, *PowerThrottlingState, u32) callconv(WINAPI) i32;
const setProcessInfo: SetProcessInfoFn = @ptrCast(SpiPtr);
const process = GetCurrentProcess();
// PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION = 2
var state1 = PowerThrottlingState{ .Version = 1, .ControlMask = 2, .StateMask = 0 };
_ = setProcessInfo(process, 4, &state1, @sizeOf(PowerThrottlingState));
// PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 1
var state2 = PowerThrottlingState{ .Version = 1, .ControlMask = 1, .StateMask = 0 };
_ = setProcessInfo(process, 4, &state2, @sizeOf(PowerThrottlingState));
}
// Saved original values (before calibration) for A/B testing
var orig_ticks_per_sec: u64 = 0;
var orig_to_ms: f64 = 0;
var orig_offset: f64 = 0;
// Calibrated values
var cal_ticks_per_sec: u64 = 0;
var cal_to_ms: f64 = 0;
var cal_offset: f64 = 0;
var is_calibrated: bool = false;
inline fn rdtsc() u64 {
var lo: u32 = undefined;
var hi: u32 = undefined;
asm volatile ("rdtsc"
: [lo] "={eax}" (lo),
[hi] "={edx}" (hi),
);
return @as(u64, hi) << 32 | lo;
}
fn timeSample() struct { qpc: i64, tsc: u64 } {
Sleep(0); // yield to get fresh timeslice
var qpc: LARGE_INTEGER = .{ .QuadPart = 0 };
_ = QueryPerformanceCounter(&qpc);
const tsc = rdtsc();
asm volatile ("lfence" ::: "memory");
return .{ .qpc = qpc.QuadPart, .tsc = tsc };
}
fn calibrateTSC() u64 {
const thread = GetCurrentThread();
_ = SetThreadPriority(thread, 2); // THREAD_PRIORITY_HIGHEST
const core: u5 = @truncate(GetCurrentProcessorNumber());
_ = SetThreadAffinityMask(thread, @as(usize, 1) << core);
const baseline = timeSample();
Sleep(500);
const after = timeSample();
_ = SetThreadPriority(thread, 0); // THREAD_PRIORITY_NORMAL
_ = SetThreadAffinityMask(thread, 0); // all cores
var freq: LARGE_INTEGER = .{ .QuadPart = 0 };
_ = QueryPerformanceFrequency(&freq);
const qpc_delta: f64 = @floatFromInt(after.qpc - baseline.qpc);
const tsc_delta: f64 = @floatFromInt(after.tsc - baseline.tsc);
const elapsed_sec = qpc_delta / @as(f64, @floatFromInt(freq.QuadPart));
return @intFromFloat(@round(tsc_delta / elapsed_sec));
}
fn writeTimerValues(ticks: u64, to_ms: f64, offset: f64) void {
const ticks_bytes = @as([8]u8, @bitCast(ticks));
hook.writeProtected(ADDR_TICKS_PER_SEC, &ticks_bytes);
const ms_bytes = @as([8]u8, @bitCast(to_ms));
hook.writeProtected(ADDR_TO_MS, &ms_bytes);
const off_bytes = @as([8]u8, @bitCast(offset));
hook.writeProtected(ADDR_OFFSET, &off_bytes);
}
/// Calibrate and save both original and calibrated values.
/// Called once from transform44.installHooks().
pub fn init() void {
// OS-level tweaks (independent of TSC calibration)
increaseTimerResolution();
disablePowerThrottling();
const tsc_was_enabled = hook.readMem(u32, ADDR_USE_TSC) != 0;
// Save original values (if TSC was already enabled)
if (tsc_was_enabled) {
orig_ticks_per_sec = hook.readMem(u64, ADDR_TICKS_PER_SEC);
const orig_ms_bytes = hook.readMem(u64, ADDR_TO_MS);
orig_to_ms = @bitCast(orig_ms_bytes);
const orig_off_bytes = hook.readMem(u64, ADDR_OFFSET);
orig_offset = @bitCast(orig_off_bytes);
} else {
// TSC not in use -- game is using GetTickCount (1000 ticks/sec).
// Save those as "original" for A/B comparison.
orig_ticks_per_sec = 1000;
orig_to_ms = 1.0;
orig_offset = 0;
}
// Calibrate
cal_ticks_per_sec = calibrateTSC();
cal_to_ms = 1000.0 / @as(f64, @floatFromInt(cal_ticks_per_sec));
// Align TSC epoch with GetTickCount
const gtc: f64 = @floatFromInt(GetTickCount());
const tsc_ms: f64 = @as(f64, @floatFromInt(rdtsc())) * cal_to_ms;
cal_offset = gtc - tsc_ms;
// Check if already well-calibrated (within 0.1%)
if (tsc_was_enabled) {
const orig_f: f64 = @floatFromInt(orig_ticks_per_sec);
const cal_f: f64 = @floatFromInt(cal_ticks_per_sec);
const diff_pct = @abs(cal_f - orig_f) / cal_f * 100.0;
if (diff_pct < 0.1) {
// Already calibrated (VanillaFixes or similar), keep original
is_calibrated = false;
return;
}
}
is_calibrated = true;
// Enable TSC mode if it wasn't already
if (!tsc_was_enabled) {
const one = @as([4]u8, @bitCast(@as(u32, 1)));
hook.writeProtected(ADDR_USE_TSC, &one);
}
// Apply calibrated values
writeTimerValues(cal_ticks_per_sec, cal_to_ms, cal_offset);
}
/// Switch to calibrated (CUSTOM) timer values.
pub fn applyCalibrated() void {
if (!is_calibrated) return;
const one = @as([4]u8, @bitCast(@as(u32, 1)));
hook.writeProtected(ADDR_USE_TSC, &one);
writeTimerValues(cal_ticks_per_sec, cal_to_ms, cal_offset);
}
/// Restore original (BASELINE) timer values.
pub fn applyOriginal() void {
if (!is_calibrated) return;
// If TSC was originally disabled, restore that state
if (orig_ticks_per_sec == 1000) {
const zero = @as([4]u8, @bitCast(@as(u32, 0)));
hook.writeProtected(ADDR_USE_TSC, &zero);
}
writeTimerValues(orig_ticks_per_sec, orig_to_ms, orig_offset);
}
pub const TimerInfo = struct {
calibrated: bool,
orig_freq: u64,
cal_freq: u64,
diff_pct_x10: u64, // tenths of a percent
};
pub fn getInfo() TimerInfo {
const orig_f: f64 = @floatFromInt(orig_ticks_per_sec);
const cal_f: f64 = @floatFromInt(cal_ticks_per_sec);
const diff = if (cal_f > 0) @abs(cal_f - orig_f) / cal_f * 1000.0 else 0;
return .{
.calibrated = is_calibrated,
.orig_freq = orig_ticks_per_sec,
.cal_freq = cal_ticks_per_sec,
.diff_pct_x10 = @intFromFloat(diff),
};
}
+7 -17
View File
@@ -15,7 +15,6 @@ const std = @import("std");
const hook = @import("zhook");
const logging = @import("../logging.zig");
const mod_mutex = @import("../mutex.zig");
pub const file_cache = @import("file_cache.zig");
const timer_fix = @import("timer_fix.zig");
extern fn clipPolygonToSinglePlane(u32, u32, u32) void;
extern fn buildTrianglePlanes(u32, u32, u32, u32, u32) u32;
@@ -38,7 +37,7 @@ pub fn isActive() bool {
// Profiling state — unified dump every DUMP_FRAMES render passes
// =============================================================================
const DUMP_FRAMES: u64 = 1800; // ~30s at 60fps
const DUMP_FRAMES: u64 = 900; // ~15s at 60fps
var prof = ProfState{};
var t44_depth: u64 = 0; // recursion depth — survives resets
@@ -237,9 +236,11 @@ fn transformDetour(this: u32, edx: u32, mat1: u32, mat2: u32, mat3: u32, mat4: u
t44_depth +|= 1;
if (t44_depth > prof.t44_max_depth) prof.t44_max_depth = t44_depth;
// bone_sse disabled — investigating crash in post-bone-loop sections
_ = transformMatrix4x4_SSE;
transform_hook.callOriginal(.{ this, edx, mat1, mat2, mat3, mat4 });
if (ab_use_custom) {
transformMatrix4x4_SSE(this, mat1, mat2, mat3, mat4);
} else {
transform_hook.callOriginal(.{ this, edx, mat1, mat2, mat3, mat4 });
}
t44_depth -|= 1;
const elapsed = rdtsc() - start;
@@ -1306,18 +1307,7 @@ fn dumpStats() void {
});
}
// File cache stats
const fc = file_cache.getCacheStats();
if (fc.total > 0) {
const fc_hit_pct = pct(fc.hits + fc.neg_hits, fc.total);
log.fmt(" file_cache: {d}.{d}% hit ({d}hit/{d}neg/{d}miss) {d} entries\n", .{
fc_hit_pct / 10, fc_hit_pct % 10,
fc.hits, fc.neg_hits, fc.misses, fc.entries,
});
}
// Reset per-period cache stats, flip A/B mode
file_cache.resetStats();
// Flip A/B mode
ab_use_custom = !ab_use_custom;
prof = ProfState{};
}