From 9df1fa39cd4f8a58d4ac15a91021ae5b2a3018dd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 10 May 2026 03:31:21 -0700 Subject: [PATCH] feat(pipeline): WMOD addon manifest catalog (125th open format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel replacement for vanilla per-addon TOC (.toc) text files scattered across Interface/AddOns/. Each WMOD entry binds one addon to display metadata (name / description / version / author), client-build gate (minClientBuild), persistence + lazy-load flags (requiresSavedVariables / loadOnDemand), and required + optional dependency lists. Three presets: --gen-mod 4 vanilla-era addons (Recount standalone + Atlas standalone + Auctioneer optional-dep on Atlas + Questie standalone) --gen-mod-ui 3 UI-replacement chain (Bartender4 root -> ElvUI required-dep on Bartender4 -> SuperOrders required-dep on ElvUI). Exercises the chained required-dep resolution path. --gen-mod-util 3 standalone utility addons (XPerl, Decursive, GearVendor loadOnDemand) — empty-deps baseline. Validator catches: id+name+version required, duplicate addonIds, duplicate addon names (load-order ambiguity), self-dependency (load deadlock), missing required-dep addonId, full DFS cycle detection on required deps (deadlock at load — extracts the back-edge path so the user can see the loop). Warns on optional self-dep (no effect, prune) and on minClientBuild < 4500 (below vanilla floor — likely typo). Format count 124 -> 125. CLI flag count 1319 -> 1326. --- CMakeLists.txt | 3 + include/pipeline/wowee_addon_manifest.hpp | 118 +++++++ src/pipeline/wowee_addon_manifest.cpp | 317 +++++++++++++++++++ tools/editor/cli_addon_manifest_catalog.cpp | 321 ++++++++++++++++++++ tools/editor/cli_addon_manifest_catalog.hpp | 12 + tools/editor/cli_arg_required.cpp | 2 + tools/editor/cli_dispatch.cpp | 2 + tools/editor/cli_format_table.cpp | 1 + tools/editor/cli_help.cpp | 10 + tools/editor/cli_list_formats.cpp | 1 + 10 files changed, 787 insertions(+) create mode 100644 include/pipeline/wowee_addon_manifest.hpp create mode 100644 src/pipeline/wowee_addon_manifest.cpp create mode 100644 tools/editor/cli_addon_manifest_catalog.cpp create mode 100644 tools/editor/cli_addon_manifest_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 08513f81..613012da 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -713,6 +713,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_pvp_ranks.cpp src/pipeline/wowee_localization.cpp src/pipeline/wowee_global_channels.cpp + src/pipeline/wowee_addon_manifest.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1589,6 +1590,7 @@ add_executable(wowee_editor tools/editor/cli_pvp_ranks_catalog.cpp tools/editor/cli_localization_catalog.cpp tools/editor/cli_global_channels_catalog.cpp + tools/editor/cli_addon_manifest_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1784,6 +1786,7 @@ add_executable(wowee_editor src/pipeline/wowee_pvp_ranks.cpp src/pipeline/wowee_localization.cpp src/pipeline/wowee_global_channels.cpp + src/pipeline/wowee_addon_manifest.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_addon_manifest.hpp b/include/pipeline/wowee_addon_manifest.hpp new file mode 100644 index 00000000..4b1ac2d7 --- /dev/null +++ b/include/pipeline/wowee_addon_manifest.hpp @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Addon Manifest catalog (.wmod) — +// novel replacement for the per-addon TOC (.toc) text +// files vanilla WoW scattered across Interface/AddOns/. +// Each entry is one addon manifest binding the addon +// to its display metadata (name / description / +// version / author), client-build gate +// (minClientBuild), persistence + lazy-load flags, +// and required + optional dependency lists. +// +// The variable-length dependency arrays give the +// validator something interesting to check: a DFS +// cycle detector flags require-A-which-requires-A +// loops that would deadlock the addon loader. +// +// Cross-references with previously-added formats: +// None directly — addons are a client-side concept, +// so WMOD does not reference WMS / WCDB / spell +// data. Dependencies between WMOD entries are +// internal addonId references. +// +// Binary layout (little-endian): +// magic[4] = "WMOD" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// addonId (uint32) +// nameLen + name +// descLen + description +// versionLen + version (semver "1.2.3") +// authorLen + author +// minClientBuild (uint32) — lowest +// supported +// client patch +// number; 0 = no +// gate +// requiresSavedVariables (uint8) — 0/1 bool +// loadOnDemand (uint8) — 0/1 bool — LoD +// addons skip +// initial load +// pad0 (uint16) +// dependencyCount (uint32) +// dependencies (uint32 × count) — required addonIds +// optionalDependencyCount (uint32) +// optionalDependencies (uint32 × count) +struct WoweeAddonManifest { + struct Entry { + uint32_t addonId = 0; + std::string name; + std::string description; + std::string version; + std::string author; + uint32_t minClientBuild = 0; + uint8_t requiresSavedVariables = 0; + uint8_t loadOnDemand = 0; + uint16_t pad0 = 0; + std::vector dependencies; + std::vector optionalDependencies; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t addonId) const; + const Entry* findByName(const std::string& name) const; + + // Returns addons that depend on the given addonId + // (reverse-lookup, used by the addon-disable UI to + // warn "disabling this will also disable: X, Y"). + std::vector findDependents(uint32_t addonId) const; +}; + +class WoweeAddonManifestLoader { +public: + static bool save(const WoweeAddonManifest& cat, + const std::string& basePath); + static WoweeAddonManifest load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-mod* variants. + // + // makeStandardAddons — 4 vanilla-era addons + // (Recount / Atlas / + // Auctioneer / Questie) + // with realistic deps. + // Recount has no deps; + // Auctioneer optionally + // depends on Atlas for + // map links. + // makeUIReplacement — 3 full-UI replacements + // (Bartender4 / ElvUI / + // SuperOrders) with a + // chain dep where Super + // Orders requires Elv. + // makeUtility — 3 standalone utility + // addons (XPerl / + // Decursive / GearVendor) + // with no inter-deps — + // baseline for the + // empty-deps path. + static WoweeAddonManifest makeStandardAddons(const std::string& catalogName); + static WoweeAddonManifest makeUIReplacement(const std::string& catalogName); + static WoweeAddonManifest makeUtility(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_addon_manifest.cpp b/src/pipeline/wowee_addon_manifest.cpp new file mode 100644 index 00000000..9078c57a --- /dev/null +++ b/src/pipeline/wowee_addon_manifest.cpp @@ -0,0 +1,317 @@ +#include "pipeline/wowee_addon_manifest.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'M', 'O', 'D'}; +constexpr uint32_t kVersion = 1; + +template +void writePOD(std::ofstream& os, const T& v) { + os.write(reinterpret_cast(&v), sizeof(T)); +} + +template +bool readPOD(std::ifstream& is, T& v) { + is.read(reinterpret_cast(&v), sizeof(T)); + return is.gcount() == static_cast(sizeof(T)); +} + +void writeStr(std::ofstream& os, const std::string& s) { + uint32_t n = static_cast(s.size()); + writePOD(os, n); + if (n > 0) os.write(s.data(), n); +} + +bool readStr(std::ifstream& is, std::string& s) { + uint32_t n = 0; + if (!readPOD(is, n)) return false; + if (n > (1u << 20)) return false; + s.resize(n); + if (n > 0) { + is.read(s.data(), n); + if (is.gcount() != static_cast(n)) { + s.clear(); + return false; + } + } + return true; +} + +void writeU32Vec(std::ofstream& os, + const std::vector& v) { + uint32_t n = static_cast(v.size()); + writePOD(os, n); + if (n > 0) { + os.write(reinterpret_cast(v.data()), + static_cast(n * sizeof(uint32_t))); + } +} + +bool readU32Vec(std::ifstream& is, std::vector& v) { + uint32_t n = 0; + if (!readPOD(is, n)) return false; + // Hard cap on per-addon dependency count — prevents + // a corrupted input from allocating GBs. + if (n > 4096) return false; + v.resize(n); + if (n > 0) { + is.read(reinterpret_cast(v.data()), + static_cast(n * sizeof(uint32_t))); + if (is.gcount() != + static_cast(n * sizeof(uint32_t))) { + v.clear(); + return false; + } + } + return true; +} + +std::string normalizePath(std::string base) { + if (base.size() < 5 || base.substr(base.size() - 5) != ".wmod") { + base += ".wmod"; + } + return base; +} + +} // namespace + +const WoweeAddonManifest::Entry* +WoweeAddonManifest::findById(uint32_t addonId) const { + for (const auto& e : entries) + if (e.addonId == addonId) return &e; + return nullptr; +} + +const WoweeAddonManifest::Entry* +WoweeAddonManifest::findByName(const std::string& nm) const { + for (const auto& e : entries) + if (e.name == nm) return &e; + return nullptr; +} + +std::vector +WoweeAddonManifest::findDependents(uint32_t addonId) const { + std::vector out; + for (const auto& e : entries) { + for (uint32_t d : e.dependencies) { + if (d == addonId) { out.push_back(&e); break; } + } + } + return out; +} + +bool WoweeAddonManifestLoader::save( + const WoweeAddonManifest& cat, + const std::string& basePath) { + std::ofstream os(normalizePath(basePath), std::ios::binary); + if (!os) return false; + os.write(kMagic, 4); + writePOD(os, kVersion); + writeStr(os, cat.name); + uint32_t entryCount = static_cast(cat.entries.size()); + writePOD(os, entryCount); + for (const auto& e : cat.entries) { + writePOD(os, e.addonId); + writeStr(os, e.name); + writeStr(os, e.description); + writeStr(os, e.version); + writeStr(os, e.author); + writePOD(os, e.minClientBuild); + writePOD(os, e.requiresSavedVariables); + writePOD(os, e.loadOnDemand); + writePOD(os, e.pad0); + writeU32Vec(os, e.dependencies); + writeU32Vec(os, e.optionalDependencies); + } + return os.good(); +} + +WoweeAddonManifest WoweeAddonManifestLoader::load( + const std::string& basePath) { + WoweeAddonManifest out; + std::ifstream is(normalizePath(basePath), std::ios::binary); + if (!is) return out; + char magic[4]; + is.read(magic, 4); + if (std::memcmp(magic, kMagic, 4) != 0) return out; + uint32_t version = 0; + if (!readPOD(is, version) || version != kVersion) return out; + if (!readStr(is, out.name)) return out; + uint32_t entryCount = 0; + if (!readPOD(is, entryCount)) return out; + if (entryCount > (1u << 20)) return out; + out.entries.resize(entryCount); + for (auto& e : out.entries) { + if (!readPOD(is, e.addonId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name) || + !readStr(is, e.description) || + !readStr(is, e.version) || + !readStr(is, e.author)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.minClientBuild) || + !readPOD(is, e.requiresSavedVariables) || + !readPOD(is, e.loadOnDemand) || + !readPOD(is, e.pad0)) { + out.entries.clear(); return out; + } + if (!readU32Vec(is, e.dependencies) || + !readU32Vec(is, e.optionalDependencies)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeAddonManifestLoader::exists( + const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeAddonManifest WoweeAddonManifestLoader::makeStandardAddons( + const std::string& catalogName) { + using A = WoweeAddonManifest; + WoweeAddonManifest c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + const char* version, const char* author, + uint8_t needsSV, uint8_t lod, + std::vector deps, + std::vector optDeps, + const char* desc) { + A::Entry e; + e.addonId = id; e.name = name; + e.version = version; e.author = author; + e.description = desc; + e.minClientBuild = 5875; // 1.12 vanilla + // build floor + e.requiresSavedVariables = needsSV; + e.loadOnDemand = lod; + e.dependencies = std::move(deps); + e.optionalDependencies = std::move(optDeps); + c.entries.push_back(e); + }; + // Recount: standalone DPS meter — no deps, + // persists session combat history. + add(1, "Recount", "2.0.4", "Cryect/Elsia", 1, 0, + {}, {}, + "Damage meter — tracks DPS/HPS/threat per " + "encounter. Saves recent combat sessions to " + "SavedVariables."); + // Atlas: standalone instance map browser, no deps, + // no persistence. + add(2, "Atlas", "1.10.2", "DanGilbert", 0, 0, + {}, {}, + "Instance map browser — shows boss + loot " + "locations for vanilla dungeons / raids. " + "Static data, no SavedVariables."); + // Auctioneer: optionally depends on Atlas for + // map-link buttons in scan history (graceful + // degradation if Atlas absent). + add(3, "Auctioneer", "5.21.5497", "Norganna", 1, 0, + {}, {2}, + "Auction house scanner + market analysis. " + "Optionally uses Atlas for map links in scan " + "history (degrades gracefully if absent)."); + // Questie: standalone quest helper, persists quest + // log + completed-quest cache. + add(4, "Questie", "4.4.1", "Questie-Team", 1, 0, + {}, {}, + "Quest helper — overlay markers + objective " + "tracking. Persists per-character completed " + "quest list to SavedVariables."); + return c; +} + +WoweeAddonManifest WoweeAddonManifestLoader::makeUIReplacement( + const std::string& catalogName) { + using A = WoweeAddonManifest; + WoweeAddonManifest c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + const char* version, const char* author, + std::vector deps, + const char* desc) { + A::Entry e; + e.addonId = id; e.name = name; + e.version = version; e.author = author; + e.description = desc; + e.minClientBuild = 5875; + e.requiresSavedVariables = 1; // UI mods + // always need + // settings + // persistence + e.loadOnDemand = 0; + e.dependencies = std::move(deps); + c.entries.push_back(e); + }; + // Bartender4: action-bar replacement, root of the + // UI-replacement dep chain. + add(10, "Bartender4", "4.5.5", "Nevcairiel", + {}, + "Action-bar replacement — supports 10 movable " + "bars with per-bar visibility states. Standalone " + "root of the UI-replacement dep chain."); + // ElvUI: full UI replacement — depends on + // Bartender4 for action-bar layer (real ElvUI + // ships its own bar mod, but for this preset we + // model the dep chain). + add(11, "ElvUI", "1.21", "TukUI-Team", + {10}, + "Full UI replacement — unitframes / nameplates " + "/ chat / minimap. Depends on Bartender4 for " + "the action-bar layer (preset models a chain)."); + // SuperOrders: ElvUI extension for raid frames — + // requires ElvUI. + add(12, "SuperOrders", "0.9.3", "RaidLeader", + {11}, + "ElvUI raid-frame extension — adds clickcast " + "+ smartheal. Requires ElvUI as parent."); + return c; +} + +WoweeAddonManifest WoweeAddonManifestLoader::makeUtility( + const std::string& catalogName) { + using A = WoweeAddonManifest; + WoweeAddonManifest c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + const char* version, const char* author, + uint8_t lod, + const char* desc) { + A::Entry e; + e.addonId = id; e.name = name; + e.version = version; e.author = author; + e.description = desc; + e.minClientBuild = 5875; + e.requiresSavedVariables = 0; + e.loadOnDemand = lod; + c.entries.push_back(e); + }; + add(20, "XPerl", "3.7.5", "ZenTabi/XPerl-Team", 0, + "Unit-frame replacement — drop-in UI mod, no " + "deps, no persistence. Default-load."); + add(21, "Decursive", "2.7.7", "Archarodim", 0, + "Auto-decurse mouseover — keybind helper for " + "removing harmful auras. Default-load."); + // GearVendor is loadOnDemand: only loads when the + // user opens the gear-comparison popup. + add(22, "GearVendor", "1.0.2", "GearLab", 1, + "Item upgrade comparison popup — loadOnDemand: " + "skipped at login, loaded only when popup " + "opens. Saves favorite-item list."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_addon_manifest_catalog.cpp b/tools/editor/cli_addon_manifest_catalog.cpp new file mode 100644 index 00000000..b8004529 --- /dev/null +++ b/tools/editor/cli_addon_manifest_catalog.cpp @@ -0,0 +1,321 @@ +#include "cli_addon_manifest_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_addon_manifest.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWmodExt(std::string base) { + stripExt(base, ".wmod"); + return base; +} + +bool saveOrError(const wowee::pipeline::WoweeAddonManifest& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeAddonManifestLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wmod\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeAddonManifest& c, + const std::string& base) { + std::printf("Wrote %s.wmod\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" addons : %zu\n", c.entries.size()); +} + +int handleGenStandard(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "StandardAddons"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWmodExt(base); + auto c = wowee::pipeline::WoweeAddonManifestLoader:: + makeStandardAddons(name); + if (!saveOrError(c, base, "gen-mod")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenUI(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "UIReplacement"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWmodExt(base); + auto c = wowee::pipeline::WoweeAddonManifestLoader:: + makeUIReplacement(name); + if (!saveOrError(c, base, "gen-mod-ui")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenUtility(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "UtilityAddons"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWmodExt(base); + auto c = wowee::pipeline::WoweeAddonManifestLoader:: + makeUtility(name); + if (!saveOrError(c, base, "gen-mod-util")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleInfo(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWmodExt(base); + if (!wowee::pipeline::WoweeAddonManifestLoader::exists(base)) { + std::fprintf(stderr, "WMOD not found: %s.wmod\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeAddonManifestLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wmod"] = base + ".wmod"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"addonId", e.addonId}, + {"name", e.name}, + {"description", e.description}, + {"version", e.version}, + {"author", e.author}, + {"minClientBuild", e.minClientBuild}, + {"requiresSavedVariables", + e.requiresSavedVariables != 0}, + {"loadOnDemand", e.loadOnDemand != 0}, + {"dependencies", e.dependencies}, + {"optionalDependencies", e.optionalDependencies}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WMOD: %s.wmod\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" addons : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id version sv lod deps optDeps name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %-9s %s %s %4zu %7zu %s\n", + e.addonId, e.version.c_str(), + e.requiresSavedVariables ? "Y" : "n", + e.loadOnDemand ? "Y" : "n", + e.dependencies.size(), + e.optionalDependencies.size(), + e.name.c_str()); + } + return 0; +} + +// Stack-based DFS cycle detection. Returns the first +// cycle found as a vector of addonIds. Empty if no +// cycle. Considers ONLY required dependencies — optional +// deps don't deadlock. +std::vector findFirstCycle( + const wowee::pipeline::WoweeAddonManifest& c) { + std::map> graph; + std::set known; + for (const auto& e : c.entries) { + graph[e.addonId] = e.dependencies; + known.insert(e.addonId); + } + enum Color : uint8_t { White = 0, Gray = 1, Black = 2 }; + std::map color; + for (uint32_t id : known) color[id] = White; + std::vector path; + std::vector cycle; + std::function dfs = [&](uint32_t node) -> bool { + color[node] = Gray; + path.push_back(node); + for (uint32_t dep : graph[node]) { + if (!known.count(dep)) continue; + if (color[dep] == Gray) { + // Found back-edge to gray node — extract + // the cycle starting at dep in path. + auto it = std::find(path.begin(), path.end(), dep); + cycle.assign(it, path.end()); + cycle.push_back(dep); // close the loop + return true; + } + if (color[dep] == White) { + if (dfs(dep)) return true; + } + } + color[node] = Black; + path.pop_back(); + return false; + }; + for (uint32_t id : known) { + if (color[id] == White && dfs(id)) return cycle; + } + return {}; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWmodExt(base); + if (!wowee::pipeline::WoweeAddonManifestLoader::exists(base)) { + std::fprintf(stderr, + "validate-wmod: WMOD not found: %s.wmod\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeAddonManifestLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set idsSeen; + std::set namesSeen; + std::set knownIds; + for (const auto& e : c.entries) knownIds.insert(e.addonId); + for (size_t k = 0; k < c.entries.size(); ++k) { + const auto& e = c.entries[k]; + std::string ctx = "entry " + std::to_string(k) + + " (id=" + std::to_string(e.addonId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.addonId == 0) + errors.push_back(ctx + ": addonId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.version.empty()) + errors.push_back(ctx + ": version is empty " + "(every addon must declare a version)"); + if (!e.name.empty() && + !namesSeen.insert(e.name).second) { + errors.push_back(ctx + + ": duplicate addon name '" + e.name + + "' — addon-loader would dispatch ambiguously"); + } + if (!idsSeen.insert(e.addonId).second) { + errors.push_back(ctx + ": duplicate addonId"); + } + // Self-dependency: addon listing itself in its + // own deps would deadlock during load. + for (uint32_t dep : e.dependencies) { + if (dep == e.addonId) { + errors.push_back(ctx + + ": addon depends on itself " + "(deadlock at load)"); + } + if (!knownIds.count(dep)) { + errors.push_back(ctx + + ": required dependency addonId=" + + std::to_string(dep) + + " not found in catalog"); + } + } + for (uint32_t dep : e.optionalDependencies) { + if (dep == e.addonId) { + warnings.push_back(ctx + + ": addon optionally depends on " + "itself — has no effect, prune"); + } + // Optional deps to unknown ids are NOT an + // error — addon may degrade gracefully if + // the optional dep is absent. + } + if (e.minClientBuild != 0 && e.minClientBuild < 4500) { + warnings.push_back(ctx + + ": minClientBuild=" + + std::to_string(e.minClientBuild) + + " is below the lowest known WoW vanilla " + "build (4500); likely a typo"); + } + } + // DFS cycle detection over required dependencies. + auto cycle = findFirstCycle(c); + if (!cycle.empty()) { + std::string trail; + for (size_t k = 0; k < cycle.size(); ++k) { + if (k > 0) trail += " -> "; + trail += std::to_string(cycle[k]); + } + errors.push_back("dependency cycle detected: " + + trail + + " — addon-loader would deadlock"); + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wmod"] = base + ".wmod"; + j["ok"] = ok; + j["errors"] = errors; + j["warnings"] = warnings; + std::printf("%s\n", j.dump(2).c_str()); + return ok ? 0 : 1; + } + std::printf("validate-wmod: %s.wmod\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu addons, all addonIds + " + "names unique, no required-dep cycle, " + "no missing required deps, no self-" + "deps\n", c.entries.size()); + return 0; + } + if (!warnings.empty()) { + std::printf(" warnings (%zu):\n", warnings.size()); + for (const auto& w : warnings) + std::printf(" - %s\n", w.c_str()); + } + if (!errors.empty()) { + std::printf(" ERRORS (%zu):\n", errors.size()); + for (const auto& e : errors) + std::printf(" - %s\n", e.c_str()); + } + return ok ? 0 : 1; +} + +} // namespace + +bool handleAddonManifestCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-mod") == 0 && i + 1 < argc) { + outRc = handleGenStandard(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-mod-ui") == 0 && i + 1 < argc) { + outRc = handleGenUI(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-mod-util") == 0 && + i + 1 < argc) { + outRc = handleGenUtility(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wmod") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wmod") == 0 && i + 1 < argc) { + outRc = handleValidate(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_addon_manifest_catalog.hpp b/tools/editor/cli_addon_manifest_catalog.hpp new file mode 100644 index 00000000..90cf9286 --- /dev/null +++ b/tools/editor/cli_addon_manifest_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleAddonManifestCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index cce722da..9dccc928 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -383,6 +383,8 @@ const char* const kArgRequired[] = { "--gen-gch", "--gen-gch-rp", "--gen-gch-admin", "--info-wgch", "--validate-wgch", "--export-wgch-json", "--import-wgch-json", + "--gen-mod", "--gen-mod-ui", "--gen-mod-util", + "--info-wmod", "--validate-wmod", "--gen-weather-temperate", "--gen-weather-arctic", "--gen-weather-desert", "--gen-weather-stormy", "--gen-zone-atmosphere", diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index afc40ed5..43d423c0 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -169,6 +169,7 @@ #include "cli_pvp_ranks_catalog.hpp" #include "cli_localization_catalog.hpp" #include "cli_global_channels_catalog.hpp" +#include "cli_addon_manifest_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -383,6 +384,7 @@ constexpr DispatchFn kDispatchTable[] = { handlePvPRanksCatalog, handleLocalizationCatalog, handleGlobalChannelsCatalog, + handleAddonManifestCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 84f7c24d..33687f36 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -127,6 +127,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','P','R','G'}, ".wprg", "pvp", "--info-wprg", "PvP ranking grades catalog"}, {{'W','L','A','N'}, ".wlan", "i18n", "--info-wlan", "Localization catalog"}, {{'W','G','C','H'}, ".wgch", "chat", "--info-wgch", "Global chat channel catalog"}, + {{'W','M','O','D'}, ".wmod", "addons", "--info-wmod", "Addon manifest catalog"}, {{'W','F','A','C'}, ".wfac", "factions", nullptr, "Faction catalog"}, {{'W','L','C','K'}, ".wlck", "locks", nullptr, "Lock catalog"}, {{'W','S','K','L'}, ".wskl", "skills", nullptr, "Skill catalog"}, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 2656ef48..9cd1008a 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2503,6 +2503,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wgch to a human-editable JSON sidecar (defaults to .wgch.json; emits both channelKind and accessKind as int + name string)\n"); std::printf(" --import-wgch-json [out-base]\n"); std::printf(" Import a .wgch.json sidecar back into binary .wgch (channelKind int OR \"global\"/\"realmzone\"/\"faction\"/\"custom\"; accessKind int OR \"publicjoin\"/\"inviteonly\"/\"autojoinonzone\"/\"moderated\")\n"); + std::printf(" --gen-mod [name]\n"); + std::printf(" Emit .wmod 4 vanilla-era addons (Recount standalone + Atlas standalone + Auctioneer optional-dep on Atlas + Questie standalone)\n"); + std::printf(" --gen-mod-ui [name]\n"); + std::printf(" Emit .wmod 3 UI-replacement addons with chained required deps (Bartender4 root -> ElvUI requires Bartender4 -> SuperOrders requires ElvUI)\n"); + std::printf(" --gen-mod-util [name]\n"); + std::printf(" Emit .wmod 3 standalone utility addons (XPerl + Decursive + GearVendor loadOnDemand) — baseline empty-deps path\n"); + std::printf(" --info-wmod [--json]\n"); + std::printf(" Print WMOD entries (id / version / requiresSavedVariables / loadOnDemand / dep counts / name)\n"); + std::printf(" --validate-wmod [--json]\n"); + std::printf(" Static checks: id+name+version required, no duplicate addonIds, no duplicate addon names (load-order ambiguity), no self-dependency, no missing required-dep addonId, DFS cycle detection on required deps (deadlock at load); warns on optional self-dep and on minClientBuild < 4500 (likely typo)\n"); std::printf(" --catalog-pluck [--json]\n"); std::printf(" Extract one entry by id from any registered catalog format. Auto-detects magic, dispatches to the per-format --info-* handler internally, then prints just the matching entry. Primary-key field is auto-detected (first *Id field, or first numeric)\n"); std::printf(" --catalog-find [--magic ] [--json]\n"); diff --git a/tools/editor/cli_list_formats.cpp b/tools/editor/cli_list_formats.cpp index df06c703..0893c477 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -149,6 +149,7 @@ constexpr FormatRow kFormats[] = { {"WPRG", ".wprg", "pvp", "vanilla 14-rank PvP ladder ladder", "PvP ranking grades catalog (faction + tier + honor thresholds)"}, {"WLAN", ".wlan", "i18n", "Locale_*.MPQ + DBC trailing strings","Localization catalog (per-language string overlay)"}, {"WGCH", ".wgch", "chat", "ChatChannels.dbc + zone-default joins","Global chat channel catalog (access policy + zone auto-join)"}, + {"WMOD", ".wmod", "addons", "per-addon TOC text + load-order rules","Addon manifest catalog (deps + cycle detection)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine