diff --git a/build.zig b/build.zig index fd7b0c3..076aeb3 100644 --- a/build.zig +++ b/build.zig @@ -4,17 +4,21 @@ const ModuleDesc = struct { name: []const u8, desc: []const u8, default: bool = true, + /// Source directory under src/ (defaults to name if null). + src_dir: ?[]const u8 = null, + /// WoW addon folder name. Non-null means this module has an addon/ dir. + addon_name: ?[]const u8 = null, }; /// Single source of truth for all modules. Adding a module here is enough /// to wire up the build option, build_options passthrough, and DLL variant. const module_list = [_]ModuleDesc{ - .{ .name = "screenshot", .desc = "Enable screenshot module" }, - .{ .name = "interact", .desc = "Enable interact module" }, - .{ .name = "outline", .desc = "Enable outline module", .default = false }, - .{ .name = "worldmarkers", .desc = "Enable world markers module" }, + .{ .name = "screenshot", .desc = "Enable screenshot module", .addon_name = "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" }, .{ .name = "framecrash", .desc = "Enable framecrash fix", .default = false }, - .{ .name = "logsessions", .desc = "Enable log session rotation" }, + .{ .name = "logsessions", .desc = "Enable log session rotation", .addon_name = "LogSessions" }, .{ .name = "minimapicons", .desc = "Enable custom minimap icons" }, .{ .name = "transmogfix", .desc = "Enable transmog update coalescing" }, .{ .name = "customassets", .desc = "Enable loose file loading & permissive patch glob" }, @@ -31,11 +35,8 @@ pub fn build(b: *std.Build) void { }); const optimize = b.standardOptimizeOption(.{}); - // Build options for conditional module compilation const build_options = b.addOptions(); - inline for (module_list) |mod| { - build_options.addOption(bool, "enable_" ++ mod.name, b.option(bool, mod.name, mod.desc) orelse mod.default); - } + addModuleOptions(b, build_options); const build_options_module = build_options.createModule(); const zhook_dep = b.dependency("zhook", .{ @@ -67,6 +68,7 @@ pub fn build(b: *std.Build) void { inline for (module_list) |m| { opts.addOption(bool, "enable_" ++ m.name, std.mem.eql(u8, m.name, variant_mod.name)); } + addFileListOptions(b, opts); const variant_lib = b.addLibrary(.{ .name = variant_mod.name, @@ -88,3 +90,87 @@ pub fn build(b: *std.Build) void { build_all_step.dependOn(&install_variant.step); } } + +// ============================================================================= +// Build options: enable flags + scanned file lists for each module +// ============================================================================= + +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); + } + addFileListOptions(b, opts); +} + +fn addFileListOptions(b: *std.Build, opts: *std.Build.Step.Options) void { + const a = b.allocator; + const io = b.graph.io; + const root = b.build_root.handle; + + for (module_list) |mod| { + const src_dir = mod.src_dir orelse mod.name; + + // Addon files: _addon_files = ["screenshot/addon/Screenshot.lua", ...] + if (mod.addon_name != null) { + const addon_rel = std.fmt.allocPrint(a, "src/{s}/addon", .{src_dir}) catch unreachable; + const files = listDir(a, io, root, addon_rel); + var paths: std.ArrayList([]const u8) = .empty; + for (files) |fname| { + paths.append(a, std.fmt.allocPrint(a, "{s}/addon/{s}", .{ src_dir, fname }) catch unreachable) catch unreachable; + } + opts.addOption([]const []const u8, std.fmt.allocPrint(a, "{s}_addon_files", .{mod.name}) catch unreachable, paths.items); + } + + // Asset files: _asset_files = ["markers/assets/Spells/foo.m2", ...] + const assets_rel = std.fmt.allocPrint(a, "src/{s}/assets", .{src_dir}) catch unreachable; + const asset_files = listAssets(a, io, root, assets_rel, src_dir); + if (asset_files.len > 0) { + opts.addOption([]const []const u8, std.fmt.allocPrint(a, "{s}_asset_files", .{mod.name}) catch unreachable, asset_files); + } + } +} + +fn listDir(a: std.mem.Allocator, io: std.Io, root: std.Io.Dir, rel_path: []const u8) []const []const u8 { + var dir = root.openDir(io, rel_path, .{ .iterate = true }) catch return &.{}; + defer dir.close(io); + + var files: std.ArrayList([]const u8) = .empty; + var iter = dir.iterate(); + while (iter.next(io) catch null) |entry| { + if (entry.kind == .file) { + files.append(a, a.dupe(u8, entry.name) catch unreachable) catch unreachable; + } + } + + std.mem.sort([]const u8, files.items, {}, struct { + fn lt(_: void, aa: []const u8, bb: []const u8) bool { + return std.mem.order(u8, aa, bb) == .lt; + } + }.lt); + + return files.items; +} + +/// Walk assets/ recursively, return paths like "markers/assets/Spells/foo.m2" +fn listAssets(a: std.mem.Allocator, io: std.Io, root: std.Io.Dir, rel_path: []const u8, src_dir: []const u8) []const []const u8 { + var dir = root.openDir(io, rel_path, .{ .iterate = true }) catch return &.{}; + defer dir.close(io); + + var paths: std.ArrayList([]const u8) = .empty; + var walker = dir.walk(a) catch return &.{}; + defer walker.deinit(); + while (walker.next(io) catch null) |entry| { + if (entry.kind != .file) continue; + // Only include files in subdirectories (not root of assets/) + if (std.fs.path.dirname(entry.path) == null) continue; + paths.append(a, std.fmt.allocPrint(a, "{s}/assets/{s}", .{ src_dir, entry.path }) catch unreachable) catch unreachable; + } + + std.mem.sort([]const u8, paths.items, {}, struct { + fn lt(_: void, aa: []const u8, bb: []const u8) bool { + return std.mem.order(u8, aa, bb) == .lt; + } + }.lt); + + return paths.items; +} diff --git a/src/main.zig b/src/main.zig index 80da211..92f9b62 100644 --- a/src/main.zig +++ b/src/main.zig @@ -114,9 +114,11 @@ fn registerLuaFunctions() void { } // ============================================================================= -// Embedded addon files +// Embedded addon files — auto-generated from addon/ and assets/ directories // ============================================================================= +const build_options = @import("build_options"); + const AddonPrefix = struct { prefix: []const u8, files: []const FileEntry, @@ -127,90 +129,225 @@ const FileEntry = struct { data: []const u8, }; - -// Conditional module addons -const screenshot_files = if (build_opts.screenshot) [_]FileEntry{ - .{ .name = "Screenshot.toc", .data = @embedFile("screenshot/addon/Screenshot.toc") }, - .{ .name = "Screenshot.lua", .data = @embedFile("screenshot/addon/Screenshot.lua") }, -} else [_]FileEntry{}; - -const interact_files = if (build_opts.interact) [_]FileEntry{ - .{ .name = "Interact.toc", .data = @embedFile("interact/addon/Interact.toc") }, - .{ .name = "Interact.lua", .data = @embedFile("interact/addon/Interact.lua") }, - .{ .name = "Bindings.xml", .data = @embedFile("interact/addon/Bindings.xml") }, -} else [_]FileEntry{}; - -const outline_files = if (build_opts.outline) [_]FileEntry{ - .{ .name = "Outline.toc", .data = @embedFile("outline/addon/Outline.toc") }, - .{ .name = "Outline.lua", .data = @embedFile("outline/addon/Outline.lua") }, - .{ .name = "Bindings.xml", .data = @embedFile("outline/addon/Bindings.xml") }, -} else [_]FileEntry{}; - -const logsessions_files = if (build_opts.logsessions) [_]FileEntry{ - .{ .name = "LogSessions.toc", .data = @embedFile("logsessions/addon/LogSessions.toc") }, - .{ .name = "LogSessions.lua", .data = @embedFile("logsessions/addon/LogSessions.lua") }, -} else [_]FileEntry{}; - -const markers_files = if (build_opts.worldmarkers) [_]FileEntry{ - .{ .name = "WorldMarkers.toc", .data = @embedFile("markers/addon/Markers.toc") }, - .{ .name = "WorldMarkers.lua", .data = @embedFile("markers/addon/Markers.lua") }, - .{ .name = "Bindings.xml", .data = @embedFile("markers/addon/Bindings.xml") }, -} else [_]FileEntry{}; - -// Marker model + skin + textures served under Spells\ prefix -const markers_spells_assets = if (build_opts.worldmarkers) [_]FileEntry{ - // Models (5 colors) - .{ .name = "Raid_UI_FX_Yellow.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Yellow.m2") }, - .{ .name = "Raid_UI_FX_Cyan.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Cyan.m2") }, - .{ .name = "Raid_UI_FX_Green.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Green.m2") }, - .{ .name = "Raid_UI_FX_Purple.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Purple.m2") }, - .{ .name = "Raid_UI_FX_Red.m2", .data = @embedFile("markers/assets/Spells/Raid_UI_FX_Red.m2") }, - // Per-model raid target icon textures - .{ .name = "RaidTarget_Star.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_Star.blp") }, - .{ .name = "RaidTarget_Square.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_Square.blp") }, - .{ .name = "RaidTarget_Triangle.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_Triangle.blp") }, - .{ .name = "RaidTarget_Diamond.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_Diamond.blp") }, - .{ .name = "RaidTarget_X.blp", .data = @embedFile("markers/assets/Spells/RaidTarget_X.blp") }, - // Shared effect textures - .{ .name = "T_VFX_FLARE05_32ALPHA.BLP", .data = @embedFile("markers/assets/Spells/T_VFX_FLARE05_32ALPHA.BLP") }, - .{ .name = "GRAD2D.BLP", .data = @embedFile("markers/assets/Spells/GRAD2D.BLP") }, - .{ .name = "GRAD2C2.BLP", .data = @embedFile("markers/assets/Spells/GRAD2C2.BLP") }, - .{ .name = "NEXUS_FIREBEAM_FAINT_SQUARE_ORA.BLP", .data = @embedFile("markers/assets/Spells/NEXUS_FIREBEAM_FAINT_SQUARE_ORA.BLP") }, -} else [_]FileEntry{}; - -// Shared effect textures served under World\Expansion01\Doodads\Zulaman\Doors\ prefix -const markers_world_assets = if (build_opts.worldmarkers) [_]FileEntry{ - .{ .name = "T_VFX_FIRE03_A.BLP", .data = @embedFile("markers/assets/World/Expansion01/Doodads/Zulaman/Doors/T_VFX_FIRE03_A.BLP") }, - .{ .name = "T_VFX_BORDER6.BLP", .data = @embedFile("markers/assets/World/Expansion01/Doodads/Zulaman/Doors/T_VFX_BORDER6.BLP") }, -} else [_]FileEntry{}; - -// XYZ debug model (renamed to avoid collision with game's built-in xyz.m2) -const markers_xyz_model = if (build_opts.worldmarkers) [_]FileEntry{ - .{ .name = "WU_XYZ.m2", .data = @embedFile("markers/assets/Spells/WU_XYZ.m2") }, -} else [_]FileEntry{}; - -// XYZ texture served under World\ArtTest\Boxtest\ (matches M2 internal reference) -const markers_xyz_texture = if (build_opts.worldmarkers) [_]FileEntry{ - .{ .name = "xyz.blp", .data = @embedFile("markers/assets/Spells/xyz.blp") }, -} else [_]FileEntry{}; - -// All addon prefixes to check -const addon_prefixes = [_]AddonPrefix{ - .{ .prefix = "Interface\\AddOns\\Screenshot\\", .files = &screenshot_files }, - .{ .prefix = "Interface\\AddOns\\Interact\\", .files = &interact_files }, - .{ .prefix = "Interface\\AddOns\\Outline\\", .files = &outline_files }, - .{ .prefix = "Interface\\AddOns\\LogSessions\\", .files = &logsessions_files }, - .{ .prefix = "Interface\\AddOns\\WorldMarkers\\", .files = &markers_files }, - .{ .prefix = "Spells\\", .files = &markers_spells_assets }, - .{ .prefix = "Spells\\", .files = &markers_xyz_model }, - .{ .prefix = "World\\Expansion01\\Doodads\\Zulaman\\Doors\\", .files = &markers_world_assets }, - .{ .prefix = "World\\ArtTest\\Boxtest\\", .files = &markers_xyz_texture }, +/// Module descriptor for compile-time embed generation. +/// addon_name: WoW AddOns folder name (determines prefix + TOC/Bindings paths). +/// option: build_options field name for enable flag. +/// addon_files_opt / asset_files_opt: build_options field names for file lists. +const EmbedModule = struct { + option: []const u8, + addon_name: ?[]const u8 = null, + addon_files_opt: ?[]const u8 = null, + asset_files_opt: ?[]const u8 = null, }; +const embed_modules = [_]EmbedModule{ + .{ .option = "screenshot", .addon_name = "Screenshot", .addon_files_opt = "screenshot_addon_files" }, + .{ .option = "interact", .addon_name = "Interact", .addon_files_opt = "interact_addon_files" }, + .{ .option = "outline", .addon_name = "Outline", .addon_files_opt = "outline_addon_files" }, + .{ .option = "worldmarkers", .addon_name = "WorldMarkers", .addon_files_opt = "worldmarkers_addon_files", .asset_files_opt = "worldmarkers_asset_files" }, + .{ .option = "logsessions", .addon_name = "LogSessions", .addon_files_opt = "logsessions_addon_files" }, +}; + +/// Extract filename from a path like "markers/addon/Foo.lua" → "Foo.lua" +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; +} + +/// Extract WoW-style parent directory: "markers/assets/Spells/foo.m2" with +/// assets_prefix="markers/assets/" → "Spells\\" +fn comptimeWowDir(comptime path: []const u8, comptime assets_prefix: []const u8) []const u8 { + const rel = path[assets_prefix.len..]; // e.g. "Spells/foo.m2" + // Find last '/' to separate dir from filename + 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]; // e.g. "Spells" + // Convert '/' to '\' and add trailing '\' + 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; + }; +} + +/// Embed a list of files, returning an array of FileEntry. +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; + } +} + +/// Build all AddonPrefix entries from embed_modules at comptime. +fn buildAllPrefixes() []const AddonPrefix { + @setEvalBranchQuota(10000); + comptime { + // First pass: count total prefix entries + var count: usize = 0; + for (embed_modules) |mod| { + if (!@field(build_options, "enable_" ++ mod.option)) continue; + + if (mod.addon_files_opt) |opt| { + if (@field(build_options, opt).len > 0) count += 1; + } + if (mod.asset_files_opt) |opt| { + const asset_paths = @field(build_options, opt); + // Determine assets_prefix from first path (e.g. "markers/assets/") + if (asset_paths.len > 0) { + const pfx = assetsPrefixFromOpt(opt); + count += countUniqueAssetDirs(asset_paths, pfx); + } + } + } + + // Second pass: build array + var result: [count]AddonPrefix = undefined; + var idx: usize = 0; + for (embed_modules) |mod| { + if (!@field(build_options, "enable_" ++ mod.option)) continue; + + if (mod.addon_files_opt) |opt| { + const paths = @field(build_options, opt); + if (paths.len > 0) { + const files = embedFiles(paths); + result[idx] = .{ + .prefix = "Interface\\AddOns\\" ++ mod.addon_name.? ++ "\\", + .files = &files, + }; + idx += 1; + } + } + if (mod.asset_files_opt) |opt| { + const asset_paths = @field(build_options, opt); + if (asset_paths.len > 0) { + const pfx = assetsPrefixFromOpt(opt); + idx = appendAssetPrefixes(&result, idx, asset_paths, pfx); + } + } + } + const final = result; + return &final; + } +} + +/// Derive the assets/ path prefix from the option name pattern. +/// "worldmarkers_asset_files" → look at first path to get "markers/assets/" +fn assetsPrefixFromOpt(comptime opt: []const u8) []const u8 { + const paths = @field(build_options, opt); + // First path is like "markers/assets/Spells/foo.m2" — find "/assets/" to get prefix + 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]; // e.g. "markers/assets/" + } + } + return first; // fallback +} + +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; +} + +/// Count unique parent directories in asset paths. +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; +} + +/// Append asset prefix entries grouped by WoW directory. +fn appendAssetPrefixes(result: anytype, start_idx: usize, comptime paths: []const []const u8, comptime assets_prefix: []const u8) usize { + comptime { + // Collect unique directories + 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| { + // Count files in this directory + var file_count: usize = 0; + for (paths) |path| { + if (eqlSlice(comptimeWowDir(path, assets_prefix), dir)) file_count += 1; + } + // Build file entries for this group + 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 }; + idx += 1; + } + return idx; + } +} + +const addon_prefixes = buildAllPrefixes(); + fn findEmbeddedFile(path: [*:0]const u8) ?*const FileEntry { const path_span = std.mem.span(path); - for (&addon_prefixes) |*addon| { + for (addon_prefixes) |*addon| { if (path_span.len <= addon.prefix.len) continue; // Case-insensitive prefix check @@ -653,57 +790,53 @@ fn loadAddonsDetour(error_handler: u32) callconv(fc) void { var md5ctx = std.mem.zeroes([88]u8); - // Conditionally load module addons - if (build_opts.screenshot) { - callLoadFileListWithIncludes( - "Interface\\AddOns\\Screenshot\\Screenshot.toc", - &md5ctx, - error_handler, - ); + inline for (embed_modules) |mod| { + if (comptime mod.addon_name == null) continue; + if (!@field(build_options, "enable_" ++ mod.option)) continue; + + // Module-specific runtime checks + const load = if (comptime std.mem.eql(u8, mod.option, "worldmarkers")) + markers.isActive() + else + true; + + if (load) { + const addon_name = comptime mod.addon_name.?; + const paths = comptime @field(build_options, mod.addon_files_opt.?); + const toc_name = comptime findTocName(paths); + if (toc_name) |tn| { + callLoadFileListWithIncludes( + "Interface\\AddOns\\" ++ addon_name ++ "\\" ++ tn, + &md5ctx, + error_handler, + ); + } + if (comptime hasFile(paths, "Bindings.xml")) { + callLoadUIBindingsFromFile( + "Interface\\AddOns\\" ++ addon_name ++ "\\Bindings.xml", + &md5ctx, + error_handler, + ); + } + } } - if (build_opts.interact) { - callLoadFileListWithIncludes( - "Interface\\AddOns\\Interact\\Interact.toc", - &md5ctx, - error_handler, - ); - callLoadUIBindingsFromFile( - "Interface\\AddOns\\Interact\\Bindings.xml", - &md5ctx, - error_handler, - ); +} + +/// Find the .toc filename from an addon file path list. +fn findTocName(comptime paths: []const []const u8) ?[]const u8 { + for (paths) |path| { + const name = comptimeBasename(path); + if (name.len >= 4 and eqlSlice(name[name.len - 4 ..], ".toc")) return name; } - if (build_opts.outline) { - callLoadFileListWithIncludes( - "Interface\\AddOns\\Outline\\Outline.toc", - &md5ctx, - error_handler, - ); - callLoadUIBindingsFromFile( - "Interface\\AddOns\\Outline\\Bindings.xml", - &md5ctx, - error_handler, - ); - } - if (build_opts.logsessions) { - callLoadFileListWithIncludes( - "Interface\\AddOns\\LogSessions\\LogSessions.toc", - &md5ctx, - error_handler, - ); - } - if (build_opts.worldmarkers and markers.isActive()) { - callLoadFileListWithIncludes( - "Interface\\AddOns\\WorldMarkers\\WorldMarkers.toc", - &md5ctx, - error_handler, - ); - callLoadUIBindingsFromFile( - "Interface\\AddOns\\WorldMarkers\\Bindings.xml", - &md5ctx, - error_handler, - ); + return null; +} + +/// Check if a filename exists in the path list. +fn hasFile(comptime paths: []const []const u8, comptime target: []const u8) bool { + for (paths) |path| { + if (eqlSlice(comptimeBasename(path), target)) return true; } + return false; } fn callLoadFileListWithIncludes(toc_path: [*:0]const u8, md5ctx: *[88]u8, error_handler: u32) void { diff --git a/src/markers/addon/Markers.lua b/src/markers/addon/WorldMarkers.lua similarity index 100% rename from src/markers/addon/Markers.lua rename to src/markers/addon/WorldMarkers.lua diff --git a/src/markers/addon/Markers.toc b/src/markers/addon/WorldMarkers.toc similarity index 100% rename from src/markers/addon/Markers.toc rename to src/markers/addon/WorldMarkers.toc