diff --git a/build.zig b/build.zig index 2078655..daeeb8a 100644 --- a/build.zig +++ b/build.zig @@ -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 diff --git a/src/filecache/filecache.zig b/src/filecache/filecache.zig new file mode 100644 index 0000000..82ed504 --- /dev/null +++ b/src/filecache/filecache.zig @@ -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; +} diff --git a/src/main.zig b/src/main.zig index 429df3c..41d0a77 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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 .{}, diff --git a/src/transform44/file_cache.zig b/src/transform44/file_cache.zig deleted file mode 100644 index b62c0ad..0000000 --- a/src/transform44/file_cache.zig +++ /dev/null @@ -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; -} diff --git a/src/transform44/timer_fix.zig b/src/transform44/timer_fix.zig new file mode 100644 index 0000000..541b8bd --- /dev/null +++ b/src/transform44/timer_fix.zig @@ -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), + }; +} diff --git a/src/transform44/transform44.zig b/src/transform44/transform44.zig index caea17a..972bcec 100644 --- a/src/transform44/transform44.zig +++ b/src/transform44/transform44.zig @@ -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{}; }