55735d7b9e
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.
409 lines
15 KiB
Zig
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();
|
|
}
|