Add MPQ archive file cache: skip redundant chain walks on repeat file opens
Hook File_FindInArchive (0x6549a0) with a direct-mapped filename-verified cache. First open does the full MPQ chain walk (~60K cycles), subsequent opens hit the cache (~300 cycles). 80% hit rate in gameplay testing. Cache design: 16384 entries, FNV-1a hash for slot index, raw filename comparison (128 bytes) for collision safety. Stores outer_archive, inner_archive, and block_entry per file. Negative cache for not-found files. Refcount at +0x38 incremented on all output archives to match original File_FindInArchive behavior (verified via Ghidra: FindAndIncrementResourceReference at 0x650780, DecrementResourceReference at 0x6507e0). Also: default build changed to ReleaseFast (works around Zig fastcall inreg bug in Debug mode), logging gate changed to != ReleaseSmall, file cache integrated as transform44 sub-module with A/B comparison timing in readout.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
//! 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;
|
||||
}
|
||||
@@ -15,11 +15,13 @@ 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");
|
||||
extern fn clipPolygonToSinglePlane(u32, u32, u32) void;
|
||||
extern fn buildTrianglePlanes(u32, u32, u32, u32, u32) u32;
|
||||
extern fn rayTriangleIntersection(u32, u32, u32, u32, u32, u32) u32;
|
||||
extern fn rotateMatrixByAxisAngle(u32, u32, u32, u32) void;
|
||||
extern fn multiplyMatrix4x4(u32, u32, u32) u32;
|
||||
extern fn transformMatrix4x4_SSE(u32, u32, u32, u32, u32) void;
|
||||
|
||||
pub const module_name: [*:0]const u8 = "transform44";
|
||||
|
||||
@@ -155,6 +157,9 @@ const ProfState = struct {
|
||||
matmul_cycles: u64 = 0,
|
||||
textline_calls: u64 = 0, // renderTextLine (0x5ce0c0)
|
||||
textline_cycles: u64 = 0,
|
||||
filefind_calls: u64 = 0, // File_FindInArchive cache (0x6549a0)
|
||||
filefind_cycles: u64 = 0, // cached path
|
||||
filefind_baseline_cycles: u64 = 0, // original path (same calls)
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -185,6 +190,17 @@ inline fn rdtsc() u64 {
|
||||
return @as(u64, hi) << 32 | lo;
|
||||
}
|
||||
|
||||
/// Called from main.zig fileFindDetour to accumulate per-call timing.
|
||||
/// cached_cycles = time for cache path, baseline_cycles = time for original path.
|
||||
pub fn addFilefindCycles(cached_cycles: u64, baseline_cycles: u64) void {
|
||||
prof.filefind_cycles +|= cached_cycles;
|
||||
prof.filefind_baseline_cycles +|= baseline_cycles;
|
||||
prof.filefind_calls +|= 1;
|
||||
}
|
||||
|
||||
/// Expose rdtsc for use by main.zig's fileFindDetour.
|
||||
pub const rdtscPub = rdtsc;
|
||||
|
||||
// =============================================================================
|
||||
// Hook: transformMatrix4x4 (0x714260)
|
||||
// __thiscall(ECX=SceneObject*, stack: Matrix4x4* ×4)
|
||||
@@ -229,6 +245,8 @@ 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 });
|
||||
|
||||
t44_depth -|= 1;
|
||||
@@ -1238,16 +1256,29 @@ fn dumpStats() void {
|
||||
.{ .name = "partsetup", .cycles = prof.partsetup_cycles, .calls = prof.partsetup_calls },
|
||||
.{ .name = "matmul", .cycles = prof.matmul_cycles, .calls = prof.matmul_calls },
|
||||
.{ .name = "textline", .cycles = prof.textline_cycles, .calls = prof.textline_calls },
|
||||
.{ .name = "ff_cache", .cycles = prof.filefind_cycles, .calls = prof.filefind_calls },
|
||||
.{ .name = "ff_orig", .cycles = prof.filefind_baseline_cycles, .calls = prof.filefind_calls },
|
||||
};
|
||||
for (hotspots) |h| {
|
||||
if (h.calls > 0) {
|
||||
const hp = pct(h.cycles, wall);
|
||||
log.fmt(" {s}: {d}.{d}% {d}ms {d}c/f\n", .{
|
||||
h.name,
|
||||
hp / 10, hp % 10,
|
||||
h.cycles / MS_DIVISOR,
|
||||
h.calls / f,
|
||||
});
|
||||
const name = std.mem.span(h.name);
|
||||
const is_micro = std.mem.eql(u8, name, "ff_cache") or std.mem.eql(u8, name, "ff_orig") or std.mem.eql(u8, name, "glyph");
|
||||
if (is_micro) {
|
||||
log.fmt(" {s}: {d}.{d}% {d}us {d}c/f\n", .{
|
||||
h.name,
|
||||
hp / 10, hp % 10,
|
||||
h.cycles / 3_000,
|
||||
h.calls / f,
|
||||
});
|
||||
} else {
|
||||
log.fmt(" {s}: {d}.{d}% {d}ms {d}c/f\n", .{
|
||||
h.name,
|
||||
hp / 10, hp % 10,
|
||||
h.cycles / MS_DIVISOR,
|
||||
h.calls / f,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1262,7 +1293,18 @@ fn dumpStats() void {
|
||||
});
|
||||
}
|
||||
|
||||
// Flip A/B mode for next period
|
||||
// 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();
|
||||
ab_use_custom = !ab_use_custom;
|
||||
prof = ProfState{};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user