Files
WeirdUtils/src/addons.zig
T
MarcelineVQ 55735d7b9e 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.
2026-03-13 23:05:38 -07:00

409 lines
15 KiB
Zig

// =============================================================================
// Embedded addon file management
// =============================================================================
//
// Comptime-built table of embedded addon/asset files, plus hooks for addon
// registration (SetupAddonProcessing) and loading (LoadAddonsRecursively).
//
// All addon configuration is driven by build.zig (single source of truth).
// Build options per module: {name}_addon_name, {name}_addon_hidden,
// {name}_addon_files, {name}_asset_files. This file derives everything from
// those options — no hardcoded module list.
//
// The is_active callback is wired by convention: each module that exports
// pub fn isActive() bool gets called at runtime to gate addon loading.
//
// =============================================================================
const std = @import("std");
const hook = @import("zhook");
const logging = @import("logging.zig");
var log: logging.Logger = .{};
const build_options = @import("build_options");
// =============================================================================
// Module list — derived from build.zig via all_module_names build option.
// Convention: each module's main source is at {name}/{name}.zig.
// =============================================================================
const module_names = build_options.all_module_names;
// =============================================================================
// is_active wiring — runtime registry populated by main.zig during install().
// No per-module imports needed here; adding addon_name in build.zig is enough.
// =============================================================================
const module_active = @import("module_active.zig");
// =============================================================================
// Embedded file types
// =============================================================================
pub const FileEntry = struct {
name: []const u8,
data: []const u8,
};
const AddonPrefix = struct {
prefix: []const u8,
files: []const FileEntry,
/// Module name for runtime isActive gating. Null = always serve.
module_name: ?[*:0]const u8 = null,
};
// =============================================================================
// Comptime helpers
// =============================================================================
fn comptimeBasename(comptime path: []const u8) []const u8 {
var i = path.len;
while (i > 0) {
i -= 1;
if (path[i] == '/') return path[i + 1 ..];
}
return path;
}
fn comptimeWowDir(comptime path: []const u8, comptime assets_prefix: []const u8) []const u8 {
const rel = path[assets_prefix.len..];
var last_slash: usize = 0;
var found = false;
for (rel, 0..) |c, i| {
if (c == '/') {
last_slash = i;
found = true;
}
}
if (!found) return "";
const dir = rel[0..last_slash];
return comptime blk: {
var buf: [dir.len + 1]u8 = undefined;
for (dir, 0..) |c, i| {
buf[i] = if (c == '/') '\\' else c;
}
buf[dir.len] = '\\';
const final = buf;
break :blk &final;
};
}
fn eqlSlice(a: []const u8, b: []const u8) bool {
if (a.len != b.len) return false;
for (a, b) |x, y| {
if (x != y) return false;
}
return true;
}
fn embedFiles(comptime paths: []const []const u8) [paths.len]FileEntry {
comptime {
var entries: [paths.len]FileEntry = undefined;
for (paths, 0..) |path, i| {
entries[i] = .{
.name = comptimeBasename(path),
.data = @embedFile(path),
};
}
const final = entries;
return final;
}
}
fn assetsPrefixFromOpt(comptime opt: []const u8) []const u8 {
const paths = @field(build_options, opt);
const first = paths[0];
for (first, 0..) |_, i| {
if (i + 8 <= first.len and eqlSlice(first[i .. i + 8], "/assets/")) {
return first[0 .. i + 8];
}
}
return first;
}
fn countUniqueAssetDirs(comptime paths: []const []const u8, comptime assets_prefix: []const u8) usize {
var dirs: [paths.len][]const u8 = undefined;
var n: usize = 0;
for (paths) |path| {
const dir = comptimeWowDir(path, assets_prefix);
if (dir.len == 0) continue;
var found = false;
for (dirs[0..n]) |d| {
if (eqlSlice(d, dir)) {
found = true;
break;
}
}
if (!found) {
dirs[n] = dir;
n += 1;
}
}
return n;
}
fn appendAssetPrefixes(result: anytype, start_idx: usize, comptime paths: []const []const u8, comptime assets_prefix: []const u8, comptime mod_name: ?[*:0]const u8) usize {
comptime {
var dirs: [paths.len][]const u8 = undefined;
var n_dirs: usize = 0;
for (paths) |path| {
const dir = comptimeWowDir(path, assets_prefix);
if (dir.len == 0) continue;
var found = false;
for (dirs[0..n_dirs]) |d| {
if (eqlSlice(d, dir)) {
found = true;
break;
}
}
if (!found) {
dirs[n_dirs] = dir;
n_dirs += 1;
}
}
var idx = start_idx;
for (dirs[0..n_dirs]) |dir| {
var file_count: usize = 0;
for (paths) |path| {
if (eqlSlice(comptimeWowDir(path, assets_prefix), dir)) file_count += 1;
}
var files: [file_count]FileEntry = undefined;
var fi: usize = 0;
for (paths) |path| {
if (eqlSlice(comptimeWowDir(path, assets_prefix), dir)) {
files[fi] = .{ .name = comptimeBasename(path), .data = @embedFile(path) };
fi += 1;
}
}
const final_files = files;
result[idx] = .{ .prefix = dir, .files = &final_files, .module_name = mod_name };
idx += 1;
}
return idx;
}
}
/// Check if a module has an addon (addon_name build option exists and is non-empty).
fn hasAddon(comptime name: []const u8) bool {
if (!@hasDecl(build_options, name ++ "_addon_name")) return false;
return @field(build_options, name ++ "_addon_name").len > 0;
}
/// Get addon file paths for a module (empty if no addon or no files).
fn getAddonFiles(comptime name: []const u8) []const []const u8 {
if (!@hasDecl(build_options, name ++ "_addon_files")) return &.{};
return @field(build_options, name ++ "_addon_files");
}
/// Get asset file paths for a module (empty if no assets).
fn getAssetFiles(comptime name: []const u8) []const []const u8 {
if (!@hasDecl(build_options, name ++ "_asset_files")) return &.{};
return @field(build_options, name ++ "_asset_files");
}
/// Get hidden flag for a module.
fn isHidden(comptime name: []const u8) bool {
if (!@hasDecl(build_options, name ++ "_addon_hidden")) return false;
return @field(build_options, name ++ "_addon_hidden");
}
// =============================================================================
// Build the addon prefix table from build options
// =============================================================================
fn buildAllPrefixes() []const AddonPrefix {
@setEvalBranchQuota(50000);
comptime {
// Count entries
var count: usize = 0;
for (module_names) |name| {
if (!@field(build_options, "enable_" ++ name)) continue;
if (!hasAddon(name)) continue;
const addon_files = getAddonFiles(name);
if (addon_files.len > 0) count += 1;
const asset_files = getAssetFiles(name);
if (asset_files.len > 0) {
const pfx = assetsPrefixFromOpt(name ++ "_asset_files");
count += countUniqueAssetDirs(asset_files, pfx);
}
}
var result: [count]AddonPrefix = undefined;
var idx: usize = 0;
for (module_names) |name| {
if (!@field(build_options, "enable_" ++ name)) continue;
if (!hasAddon(name)) continue;
const addon_name = @field(build_options, name ++ "_addon_name");
const addon_files = getAddonFiles(name);
const name_z: [*:0]const u8 = (name ++ "\x00").ptr;
if (addon_files.len > 0) {
const files = embedFiles(addon_files);
result[idx] = .{
.prefix = "Interface\\AddOns\\" ++ addon_name ++ "\\",
.files = &files,
.module_name = name_z,
};
idx += 1;
}
const asset_files = getAssetFiles(name);
if (asset_files.len > 0) {
const pfx = assetsPrefixFromOpt(name ++ "_asset_files");
idx = appendAssetPrefixes(&result, idx, asset_files, pfx, name_z);
}
}
const final = result;
return &final;
}
}
const all_prefixes = buildAllPrefixes();
// =============================================================================
// Runtime-pruned prefix table — only includes prefixes for modules that own
// their mutex. Built once in install() after all modules have claimed mutexes.
// findEmbeddedFile searches only this table, no per-lookup isActive checks.
// =============================================================================
var active_prefixes: [all_prefixes.len]*const AddonPrefix = undefined;
var active_count: usize = 0;
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;
if (active) {
active_prefixes[active_count] = prefix;
active_count += 1;
}
}
log.fmt("addon prefixes: {d}/{d} active\n", .{ active_count, all_prefixes.len });
}
// =============================================================================
// Embedded file lookup — searches only active (mutex-owning) prefixes.
// =============================================================================
pub fn findEmbeddedFile(path: [*:0]const u8) ?*const FileEntry {
const path_span = std.mem.span(path);
for (active_prefixes[0..active_count]) |addon| {
if (path_span.len <= addon.prefix.len) continue;
// Case-insensitive prefix check
var prefix_match = true;
for (path_span[0..addon.prefix.len], addon.prefix) |a, b| {
const la = if (a >= 'A' and a <= 'Z') a + 32 else a;
const lb = if (b >= 'A' and b <= 'Z') b + 32 else b;
if (la != lb) {
prefix_match = false;
break;
}
}
if (!prefix_match) continue;
const relative = path_span[addon.prefix.len..];
for (addon.files) |*entry| {
if (relative.len != entry.name.len) continue;
var match = true;
for (relative, entry.name) |a, b| {
const la = if (a >= 'A' and a <= 'Z') a + 32 else a;
const lb = if (b >= 'A' and b <= 'Z') b + 32 else b;
if (la != lb) {
match = false;
break;
}
}
if (match) return entry;
}
}
return null;
}
// =============================================================================
// Hook: SetupAddonProcessing (0x51C740)
// Called during HandleLogin. After the original runs (which scans
// Interface\AddOns\ on the filesystem), we call LoadAddonTOC for each
// DLL-embedded addon to register them in the game's addon hash table.
// =============================================================================
var setup_addons_hook: hook.Detour(fn (u32) callconv(hook.cc.fastcall) void) = .{};
fn setupAddonsDetour(mgr_ptr: u32) callconv(hook.cc.fastcall) void {
setup_addons_hook.callOriginal(.{mgr_ptr});
// Register addons only for active (mutex-owning) modules.
// Use the pruned prefix table — each addon prefix with files corresponds
// to an Interface\AddOns\ entry. We match by module_name to find which
// addon_name to register.
inline for (module_names) |name| {
if (!@field(build_options, "enable_" ++ name)) continue;
if (comptime !hasAddon(name)) continue;
if (comptime getAddonFiles(name).len == 0) continue;
const module_name_z: [*:0]const u8 = comptime (name ++ "\x00").ptr;
// Check if this module survived pruning (i.e., owns its mutex)
const is_active = for (active_prefixes[0..active_count]) |p| {
if (p.module_name) |mn| {
if (std.mem.orderZ(u8, mn, module_name_z) == .eq) break true;
}
} else false;
if (is_active) {
const addon_name = comptime @field(build_options, name ++ "_addon_name");
const name_z: [*:0]const u8 = comptime (addon_name ++ "\x00").ptr;
log.fmt("registering embedded addon: {s}\n", .{name_z});
callLoadAddonTOC(name_z);
if (comptime isHidden(name)) {
hideAddonFromList(name_z);
}
}
}
}
/// Find an addon struct in the linked list by name and set +0x29 to 1,
/// which excludes it from the flat display array built by DeserializeAddonData.
fn hideAddonFromList(name: [*:0]const u8) void {
const list_base = hook.readMem(u32, 0x00be1b64);
var node = hook.readMem(u32, 0x00be1b6c);
while (node != 0 and (node & 1) == 0) {
const node_name = hook.readMem([*:0]const u8, node + 0x14);
if (std.mem.orderZ(u8, node_name, name) == .eq) {
hook.writeMem(node + 0x29, &[_]u8{1});
log.fmt("hidden addon {s} excluded from list (+0x29=1)\n", .{name});
return;
}
node = hook.readMem(u32, list_base + 4 + node);
}
log.fmt("WARNING: could not find {s} in addon list\n", .{name});
}
fn callLoadAddonTOC(addon_name: [*:0]const u8) void {
hook.call(fn ([*:0]const u8) callconv(hook.cc.fastcall) void, 0x0051c9b0, .{addon_name});
}
// =============================================================================
// Install / Remove
// =============================================================================
/// True if any module has embedded addon files compiled in.
const has_addons = blk: {
for (module_names) |name| {
if (@field(build_options, "enable_" ++ name) and hasAddon(name) and getAddonFiles(name).len > 0)
break :blk true;
}
break :blk false;
};
pub fn install() void {
if (!has_addons) return;
log = logging.Logger.open("addons", .console);
pruneInactivePrefixes();
_ = setup_addons_hook.attach(0x51C740, &setupAddonsDetour);
}
pub fn uninstall() void {
if (!has_addons) return;
setup_addons_hook.detach();
}