Refactor addon system: derive module list from build.zig, prune inactive prefixes at runtime

- Eliminate hardcoded module_names in addons.zig — now derived from
  build.zig via all_module_names build option
- Add module_active.zig runtime registry: main.zig registers isActive
  pointers during install(), addons.zig queries them without importing
  each module directly
- Prune embedded file prefixes at startup: after all modules claim
  mutexes, build a runtime active_prefixes table excluding modules we
  don't own. findEmbeddedFile searches only active entries — no
  per-lookup isActive check on the hot path
- Tag both addon and asset prefixes with module_name so pruning applies
  to all embedded files for a module
- Rename src/markers/ → src/worldmarkers/, src/outline/api.zig →
  src/outline/outline.zig to follow {name}/{name}.zig convention
This commit is contained in:
MarcelineVQ
2026-03-13 11:40:49 -07:00
parent c770e053aa
commit 79d9521953
29 changed files with 138 additions and 55 deletions
+18 -1
View File
@@ -18,7 +18,7 @@ const module_list = [_]ModuleDesc{
.{ .name = "pngscreenshots", .desc = "Enable screenshot module", .src_dir = "screenshot" },
.{ .name = "interact", .desc = "Enable interact module", .addon_name = "Interact" },
.{ .name = "outline", .desc = "Enable outline module", .default = false, .addon_name = "Outline" },
.{ .name = "worldmarkers", .desc = "Enable world markers module", .src_dir = "markers", .addon_name = "WorldMarkers", .addon_hidden = true },
.{ .name = "worldmarkers", .desc = "Enable world markers module", .addon_name = "WorldMarkers", .addon_hidden = true },
.{ .name = "framecrash", .desc = "Enable framecrash fix", .default = false },
.{ .name = "logsessions", .desc = "Enable log session rotation", .addon_name = "LogSessions" },
.{ .name = "minimapicons", .desc = "Enable custom minimap icons", .addon_name = "MinimapIcons" },
@@ -30,6 +30,7 @@ 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 {
@@ -86,6 +87,14 @@ pub fn build(b: *std.Build) void {
opts.addOption(bool, "enable_" ++ m.name, std.mem.eql(u8, m.name, variant_mod.name));
}
addFileListOptions(b, opts);
// Variant builds also need the module name list for addons.zig
const names: []const []const u8 = comptime blk: {
var n: [module_list.len][]const u8 = undefined;
for (module_list, 0..) |m2, mi| n[mi] = m2.name;
const final = n;
break :blk &final;
};
opts.addOption([]const []const u8, "all_module_names", names);
const variant_lib = b.addLibrary(.{
.name = variant_mod.name,
@@ -116,6 +125,14 @@ fn addModuleOptions(b: *std.Build, opts: *std.Build.Step.Options) void {
inline for (module_list) |mod| {
opts.addOption(bool, "enable_" ++ mod.name, b.option(bool, mod.name, mod.desc) orelse mod.default);
}
// Pass full module name list so addons.zig doesn't need a hardcoded copy
const names: []const []const u8 = comptime blk: {
var n: [module_list.len][]const u8 = undefined;
for (module_list, 0..) |mod, i| n[i] = mod.name;
const final = n;
break :blk &final;
};
opts.addOption([]const []const u8, "all_module_names", names);
addFileListOptions(b, opts);
}
+51 -31
View File
@@ -22,35 +22,18 @@ var log: logging.Logger = .{};
const build_options = @import("build_options");
// =============================================================================
// Module list — must match build.zig module_list names.
// This is the only place module names appear; everything else is derived.
// 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 = [_][]const u8{
"interact",
"outline",
"worldmarkers",
"logsessions",
"minimapicons",
"dpslog",
};
const module_names = build_options.all_module_names;
// =============================================================================
// is_active wiring — convention-based imports
// 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.
// =============================================================================
fn moduleIsActive(comptime name: []const u8) ?*const fn () bool {
if (!@field(build_options, "enable_" ++ name)) return null;
const mod = if (eqlSlice(name, "interact")) @import("interact/interact.zig")
else if (eqlSlice(name, "outline")) @import("outline/api.zig")
else if (eqlSlice(name, "worldmarkers")) @import("markers/markers.zig")
else if (eqlSlice(name, "logsessions")) @import("logsessions/logsessions.zig")
else if (eqlSlice(name, "minimapicons")) @import("minimapicons/minimapicons.zig")
else if (eqlSlice(name, "dpslog")) @import("dpslog/dpslog.zig")
else struct {};
if (@hasDecl(mod, "isActive")) return &mod.isActive;
return null;
}
const module_active = @import("module_active.zig");
// =============================================================================
// Embedded file types
@@ -64,6 +47,8 @@ pub const FileEntry = struct {
const AddonPrefix = struct {
prefix: []const u8,
files: []const FileEntry,
/// Module name for runtime isActive gating. Null = always serve.
module_name: ?[*:0]const u8 = null,
};
// =============================================================================
@@ -156,7 +141,7 @@ fn countUniqueAssetDirs(comptime paths: []const []const u8, comptime assets_pref
return n;
}
fn appendAssetPrefixes(result: anytype, start_idx: usize, comptime paths: []const []const u8, comptime assets_prefix: []const u8) usize {
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;
@@ -191,7 +176,7 @@ fn appendAssetPrefixes(result: anytype, start_idx: usize, comptime paths: []cons
}
}
const final_files = files;
result[idx] = .{ .prefix = dir, .files = &final_files };
result[idx] = .{ .prefix = dir, .files = &final_files, .module_name = mod_name };
idx += 1;
}
return idx;
@@ -250,18 +235,20 @@ fn buildAllPrefixes() []const AddonPrefix {
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);
idx = appendAssetPrefixes(&result, idx, asset_files, pfx, name_z);
}
}
const final = result;
@@ -269,16 +256,37 @@ fn buildAllPrefixes() []const AddonPrefix {
}
}
const addon_prefixes = buildAllPrefixes();
const all_prefixes = buildAllPrefixes();
// =============================================================================
// Embedded file lookup
// 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;
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 (addon_prefixes) |*addon| {
for (active_prefixes[0..active_count]) |addon| {
if (path_span.len <= addon.prefix.len) continue;
// Case-insensitive prefix check
@@ -323,13 +331,24 @@ 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 active = if (comptime moduleIsActive(name)) |f| f() else true;
if (active) {
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});
@@ -379,6 +398,7 @@ const has_addons = blk: {
pub fn install() void {
if (!has_addons) return;
log = logging.Logger.open("addons", .console);
pruneInactivePrefixes();
_ = setup_addons_hook.attach(0x51C740, &setupAddonsDetour);
}
+42 -22
View File
@@ -20,13 +20,14 @@ 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
const screenshot = if (build_opts.screenshot) @import("screenshot/screenshot.zig") else struct {};
const interact = if (build_opts.interact) @import("interact/interact.zig") else struct {};
const outline = if (build_opts.outline) @import("outline/api.zig") else struct {};
const markers = if (build_opts.worldmarkers) @import("markers/markers.zig") else struct {};
const outline = if (build_opts.outline) @import("outline/outline.zig") else struct {};
const markers = if (build_opts.worldmarkers) @import("worldmarkers/worldmarkers.zig") else struct {};
const framecrash = if (build_opts.framecrash) @import("framecrash/framecrash.zig") else struct {};
const logsessions = if (build_opts.logsessions) @import("logsessions/logsessions.zig") else struct {};
const minimapicons = if (build_opts.minimapicons) @import("minimapicons/minimapicons.zig") else struct {};
@@ -38,20 +39,12 @@ 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 module_active = @import("module_active.zig");
const WINAPI = std.builtin.CallingConvention.winapi;
const SYSTEMTIME = extern struct {
wYear: u16,
wMonth: u16,
wDayOfWeek: u16,
wDay: u16,
wHour: u16,
wMinute: u16,
wSecond: u16,
wMilliseconds: u16,
};
// =============================================================================
// Lua Protection Bypass
// =============================================================================
@@ -120,6 +113,9 @@ 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));
@@ -211,7 +207,6 @@ const LoadModelFn = fn (u32, u32, u32) callconv(hook.cc.thiscall) u32;
var model_load_hook: hook.Detour(LoadModelFn) = .{};
// Windows API imports for async handling
extern "kernel32" fn GetLocalTime(lpSystemTime: *SYSTEMTIME) callconv(WINAPI) void;
extern "kernel32" fn EnterCriticalSection(lpCriticalSection: *anyopaque) callconv(WINAPI) void;
extern "kernel32" fn LeaveCriticalSection(lpCriticalSection: *anyopaque) callconv(WINAPI) void;
extern "kernel32" fn SetEvent(hEvent: *anyopaque) callconv(WINAPI) i32;
@@ -249,11 +244,6 @@ fn openFileDetour(
flags: u32,
handle_out: *u32,
) callconv(hook.cc.stdcall) u32 {
var st: SYSTEMTIME = undefined;
GetLocalTime(&st);
log.to(.file).fmt("{d}/{d} {d:0>2}:{d:0>2}:{d:0>2}.{d:0>3} {s}\n", .{
st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, std.mem.span(path),
});
if (findEmbeddedFile(path)) |entry| {
// Allocate and zero-fill 0x60-byte file context
const ctx = allocateGameBuffer(0x60) orelse {
@@ -283,7 +273,10 @@ fn openFileDetour(
return 2; // success (non-zero type code)
}
return open_file_hook.callOriginal(.{ archive_ptr, path, flags, handle_out });
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;
}
// --- Hook 2: GetFileSizeFromHandle (0x6487f0) ---
@@ -299,7 +292,17 @@ fn getFileSizeDetour(
return size;
}
return get_file_size_hook.callOriginal(.{ file_ctx, high_size_out });
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;
}
// --- Hook 3: ReadFileFromMultipleSources (0x648460) ---
@@ -335,7 +338,19 @@ fn readFileDetour(
return 1; // success
}
return read_file_hook.callOriginal(.{ ctx, buffer, size, bytes_read_out, async_ptr, param6 });
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;
}
// --- Hook 4: processAsyncFileOperation (0x647350) ---
@@ -647,6 +662,7 @@ 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 .{},
@@ -675,6 +691,10 @@ fn install() void {
inline for (modules) |m| {
if (m.install) |inst| inst();
// Register isActive for addons.zig runtime lookup
if (m.name) |name| {
if (m.is_active) |f| module_active.register(name, f);
}
}
addons.install();
+26
View File
@@ -0,0 +1,26 @@
// Runtime registry for module isActive() function pointers.
// Populated by main.zig during install(), queried by addons.zig during login.
// Exists solely to avoid duplicating module imports in addons.zig.
const std = @import("std");
const MAX = 32;
var names: [MAX][*:0]const u8 = undefined;
var fns: [MAX]*const fn () bool = undefined;
var count: usize = 0;
/// Register a module's isActive function. Called from main.zig install().
pub fn register(name: [*:0]const u8, f: *const fn () bool) void {
if (count >= MAX) return;
names[count] = name;
fns[count] = f;
count += 1;
}
/// Check if a module is active by name. Returns true if unknown (no isActive).
pub fn isActive(name: [*:0]const u8) bool {
for (0..count) |i| {
if (std.mem.orderZ(u8, names[i], name) == .eq) return fns[i]();
}
return true;
}
+1 -1
View File
@@ -14,7 +14,7 @@
const std = @import("std");
const hook = @import("zhook");
const api = @import("api.zig");
const api = @import("outline.zig");
const o = @import("offsets.zig");
const types = @import("types.zig");
const tracker = @import("tracker.zig");