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
+11 -3
View File
@@ -30,7 +30,6 @@ 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 = "file_perf", .desc = "Enable file access profiling", .default = false, .addon_name = "FilePerf", .addon_hidden = true },
};
pub fn build(b: *std.Build) void {
@@ -40,7 +39,7 @@ pub fn build(b: *std.Build) void {
.abi = .msvc,
.cpu_features_add = std.Target.x86.featureSet(&.{ .sse, .sse2 }),
});
const optimize = b.standardOptimizeOption(.{});
const optimize = b.option(std.builtin.OptimizeMode, "optimize", "Optimization mode (default: ReleaseFast)") orelse .ReleaseFast;
const build_options = b.addOptions();
addModuleOptions(b, build_options);
@@ -52,7 +51,7 @@ pub fn build(b: *std.Build) void {
});
const zhook_mod = zhook_dep.module("zhook");
// Hot math — separate compilation unit, always ReleaseFast
// Hot math — separate compilation units, always ReleaseFast
const clip_sse_obj = b.addObject(.{
.name = "clip_sse",
.root_module = b.createModule(.{
@@ -61,6 +60,14 @@ pub fn build(b: *std.Build) void {
.optimize = .ReleaseFast,
}),
});
const bone_sse_obj = b.addObject(.{
.name = "bone_sse",
.root_module = b.createModule(.{
.root_source_file = b.path("src/transform44/bone_sse.zig"),
.target = target,
.optimize = .ReleaseFast,
}),
});
const lib = b.addLibrary(.{
.name = "weirdutils",
@@ -76,6 +83,7 @@ pub fn build(b: *std.Build) void {
}),
});
lib.root_module.addObject(clip_sse_obj);
lib.root_module.addObject(bone_sse_obj);
b.installArtifact(lib);
// Convenience step to build all single-module variants
+1 -1
View File
@@ -267,7 +267,7 @@ const all_prefixes = buildAllPrefixes();
var active_prefixes: [all_prefixes.len]*const AddonPrefix = undefined;
var active_count: usize = 0;
fn pruneInactivePrefixes() void {
pub fn pruneInactivePrefixes() void {
active_count = 0;
for (all_prefixes) |*prefix| {
const active = if (prefix.module_name) |mod_name| module_active.isActive(mod_name) else true;
+3 -2
View File
@@ -1,4 +1,5 @@
//! Debug logging - compiles out entirely in non-Debug builds.
//! Logging - available in Debug, ReleaseSafe, and ReleaseFast builds.
//! Compiles out entirely in ReleaseSmall.
//!
//! Per-module Logger with optional file output and per-call destination routing.
//! Log files are created lazily on first write -- modules that never log to
@@ -28,7 +29,7 @@
const std = @import("std");
const builtin = @import("builtin");
const debug = builtin.mode == .Debug;
const debug = builtin.mode != .ReleaseSmall;
const WINAPI = std.builtin.CallingConvention.winapi;
+124 -34
View File
@@ -20,7 +20,6 @@ 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 file_perf = @import("build_options").enable_file_perf;
};
// Conditional module imports
@@ -39,7 +38,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_perf = if (build_opts.file_perf) @import("file_perf/file_perf.zig") else struct {};
const file_cache = if (build_opts.transform44) @import("transform44/file_cache.zig") else struct {};
const module_active = @import("module_active.zig");
@@ -113,9 +112,6 @@ fn registerLuaFunctions() void {
registerFunction("ResetAddOnCPUUsage", @intFromPtr(&addonperf.luaResetAddOnCPUUsage));
registerFunction("GetScriptCPUUsage", @intFromPtr(&addonperf.luaGetScriptCPUUsage));
}
if (build_opts.file_perf and file_perf.isActive()) {
registerFunction("ResetFilePerfCounters", @intFromPtr(&file_perf.luaResetCounters));
}
if (build_opts.worldmarkers and markers.isActive()) {
// User-facing functions stay global
registerFunction("WorldMarker", @intFromPtr(&markers.luaWorldMarker));
@@ -206,6 +202,13 @@ var process_async_hook: hook.Detour(ProcessAsyncFn) = .{};
const LoadModelFn = fn (u32, u32, u32) callconv(hook.cc.thiscall) u32;
var model_load_hook: hook.Detour(LoadModelFn) = .{};
// File_FindInArchive (0x6549a0) — Storm internal MPQ file lookup
// __fastcall(ECX=archive_or_group, EDX=filename, stack: flags, out_inner_archive,
// out_outer_archive, out_block_entry, out_disk_path) → int
// Returns: 0=not found, 1=found in MPQ, 2=found on disk, 3=deleted
const FileFindFn = fn (u32, u32, u32, u32, u32, u32, u32) callconv(hook.cc.fastcall) u32;
var file_find_hook: hook.Detour(FileFindFn) = .{};
// Windows API imports for async handling
extern "kernel32" fn EnterCriticalSection(lpCriticalSection: *anyopaque) callconv(WINAPI) void;
extern "kernel32" fn LeaveCriticalSection(lpCriticalSection: *anyopaque) callconv(WINAPI) void;
@@ -273,10 +276,7 @@ fn openFileDetour(
return 2; // success (non-zero type code)
}
const tsc_start = if (build_opts.file_perf) file_perf.beginOpen() else 0;
const ret = open_file_hook.callOriginal(.{ archive_ptr, path, flags, handle_out });
if (build_opts.file_perf) file_perf.endOpen(path, tsc_start);
return ret;
return open_file_hook.callOriginal(.{ archive_ptr, path, flags, handle_out });
}
// --- Hook 2: GetFileSizeFromHandle (0x6487f0) ---
@@ -292,17 +292,7 @@ fn getFileSizeDetour(
return size;
}
const size = get_file_size_hook.callOriginal(.{ file_ctx, high_size_out });
// Track file sizes for cache budget estimation
if (build_opts.file_perf and file_perf.isActive()) {
const path_ptr = hook.readMem(u32, file_ctx + 0x0C);
if (path_ptr != 0) {
file_perf.recordFileSize(@as([*:0]const u8, @ptrFromInt(path_ptr)), size);
}
}
return size;
return get_file_size_hook.callOriginal(.{ file_ctx, high_size_out });
}
// --- Hook 3: ReadFileFromMultipleSources (0x648460) ---
@@ -338,19 +328,7 @@ fn readFileDetour(
return 1; // success
}
const ret = read_file_hook.callOriginal(.{ ctx, buffer, size, bytes_read_out, async_ptr, param6 });
// Track reads for profiling — caller return address tells us who calls ReadFile
if (build_opts.file_perf and file_perf.isActive() and ret != 0) {
const path_ptr = hook.readMem(u32, ctx + 0x0C);
if (path_ptr != 0) {
const actual_read = if (bytes_read_out) |out| out.* else size;
const caller_addr: u32 = @truncate(@returnAddress());
file_perf.recordRead(@as([*:0]const u8, @ptrFromInt(path_ptr)), actual_read, caller_addr);
}
}
return ret;
return read_file_hook.callOriginal(.{ ctx, buffer, size, bytes_read_out, async_ptr, param6 });
}
// --- Hook 4: processAsyncFileOperation (0x647350) ---
@@ -537,6 +515,112 @@ fn checkFileExistenceDetour(filename_ptr: u32, flags: u32, output_buffer_ptr: u3
return cfe_hook.callOriginal(.{ filename_ptr, flags, output_buffer_ptr });
}
// --- Hook 7: File_FindInArchive (0x6549a0) — archive cache short-circuit ---
// Intercepts the core MPQ file lookup to skip both chain walk and hash lookup
// on repeat opens. Caches {outer_archive, inner_archive, block_entry} per file.
//
// Called from two paths during each open:
// 1. FindFileInArchive wrapper (param_1=0): walks all archives, uses param_5 for output
// 2. File_FindInStorage (param_1=specific): single archive, uses param_4 + param_6
//
// We cache on path 2 (has all data), serve both paths from cache on subsequent opens.
fn fileFindDetour(
archive_or_group: u32, // ECX: 0 = search all, else specific archive/group
filename_ptr: u32, // EDX: filename string
flags: u32,
out_inner_archive: u32, // ptr to ptr: inner archive (File_FindInStorage uses this)
out_outer_archive: u32, // ptr to ptr: outer archive (FindFileInArchive wrapper uses this)
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)
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 path: [*:0]const u8 = @ptrFromInt(filename_ptr);
const h = file_cache.hashPath(path);
if (file_cache.archiveCacheLookup(h, path)) |cached| {
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 (!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;
}
}
if (out_block_entry != 0) {
@as(*align(1) u32, @ptrFromInt(out_block_entry)).* = cached.block_entry;
}
file_cache.recordCacheHit();
const cache_elapsed = transform44.rdtscPub() - cache_start;
transform44.addFilefindCycles(cache_elapsed, baseline_elapsed);
return orig_ret;
}
// 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_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_block_entry != 0) {
@as(*align(1) u32, @ptrFromInt(out_block_entry)).* = cached.block_entry;
}
file_cache.recordCacheHit();
const cache_elapsed = transform44.rdtscPub() - cache_start;
transform44.addFilefindCycles(cache_elapsed, baseline_elapsed);
return orig_ret;
}
}
}
// Cache miss — populate cache from the original's results
file_cache.recordCacheMiss();
if (archive_or_group != 0 and out_inner_archive != 0 and out_block_entry != 0) {
if (orig_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) {
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;
}
// --- Install/remove in-memory file hooks ---
fn installFileHooks() void {
@@ -546,10 +630,15 @@ 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) {
_ = file_find_hook.attach(0x6549a0, &fileFindDetour);
log.print("archive cache hook installed\n");
}
log.print("in-memory file hooks installed\n");
}
fn removeFileHooks() void {
file_find_hook.detach();
cfe_hook.detach();
model_load_hook.detach();
process_async_hook.detach();
@@ -662,7 +751,6 @@ 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.file_perf) .{ .name = file_perf.module_name, .install = file_perf.installHooks, .remove = file_perf.removeHooks, .is_active = file_perf.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 .{},
@@ -763,6 +851,7 @@ fn disableModule(name: [*:0]const u8) callconv(.c) i32 {
if (asciiEqlIgnoreCase(name, mod_name)) {
if (m.remove) |rm| {
rm();
addons.pruneInactivePrefixes();
return 1;
}
return 0;
@@ -786,6 +875,7 @@ fn disableAll() callconv(.c) i32 {
count += 1;
}
}
addons.pruneInactivePrefixes();
// Detach core hooks
shutdown_hook.detach();
+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{};
}