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