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:
MarcelineVQ
2026-03-13 23:05:38 -07:00
parent bb8a6ef8a6
commit 55735d7b9e
6 changed files with 295 additions and 47 deletions
+107
View File
@@ -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;
}
+49 -7
View File
@@ -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{};
}