From d62ac954da9ce3d470eae687c34236c10a06f9a9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 10 May 2026 00:41:45 -0700 Subject: [PATCH] feat(editor): add WCMG (Combat Maneuver Group) open catalog format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel replacement for the hardcoded class-mutex tables the WoW client uses to grey out incompatible action-bar buttons. Each entry is one mutually-exclusive spell group: Warrior stances (Battle/Defensive/Berserker), Druid shapeshift forms (Bear/Cat/Travel/Tree/Moonkin), Hunter aspects (Hawk/Cheetah/Pack/Viper/Dragonhawk/Beast/ Wild), DK presences (Frost/Unholy/Blood). The action-bar update path uses the catalog to know which spells share a mutex bucket and clear "currently active" outlines when a sibling is cast. Six categoryKind enum values (Stance / Form / Aspect / Presence / Posture / Sigil) and variable-length members[] array of spell IDs (refs WSPL). Three presets: makeWarrior (Warrior 3-stance), makeDruid (5 shapeshift + 2 flight, separate buckets so flying isn't broken by Cat Form), makeAllMutex (cross-class catalog with one representative group per mutex-having class). Validator catches several authoring bugs: empty members[] (group has nothing to switch between), spellId 0, duplicate spellId within a group, and — most usefully — the same spellId appearing in two different exclusive groups (which would make the action-bar mutex undecidable: which group's outline does the bar use?). Warns on single-member groups (mutex with one element has no exclusion to enforce). Format count 98 -> 99 (one short of triple-digit milestone). CLI flag count 1112 -> 1117. --- CMakeLists.txt | 3 + include/pipeline/wowee_combat_maneuvers.hpp | 124 +++++++ src/pipeline/wowee_combat_maneuvers.cpp | 295 +++++++++++++++++ tools/editor/cli_arg_required.cpp | 2 + tools/editor/cli_combat_maneuvers_catalog.cpp | 310 ++++++++++++++++++ tools/editor/cli_combat_maneuvers_catalog.hpp | 12 + 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, 760 insertions(+) create mode 100644 include/pipeline/wowee_combat_maneuvers.hpp create mode 100644 src/pipeline/wowee_combat_maneuvers.cpp create mode 100644 tools/editor/cli_combat_maneuvers_catalog.cpp create mode 100644 tools/editor/cli_combat_maneuvers_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0a793eb0..6036944a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -687,6 +687,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_group_compositions.cpp src/pipeline/wowee_hearth_binds.cpp src/pipeline/wowee_server_broadcasts.cpp + src/pipeline/wowee_combat_maneuvers.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1537,6 +1538,7 @@ add_executable(wowee_editor tools/editor/cli_group_compositions_catalog.cpp tools/editor/cli_hearth_binds_catalog.cpp tools/editor/cli_server_broadcasts_catalog.cpp + tools/editor/cli_combat_maneuvers_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_quest_objective.cpp tools/editor/cli_quest_reward.cpp @@ -1703,6 +1705,7 @@ add_executable(wowee_editor src/pipeline/wowee_group_compositions.cpp src/pipeline/wowee_hearth_binds.cpp src/pipeline/wowee_server_broadcasts.cpp + src/pipeline/wowee_combat_maneuvers.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_combat_maneuvers.hpp b/include/pipeline/wowee_combat_maneuvers.hpp new file mode 100644 index 00000000..a0a2b9ff --- /dev/null +++ b/include/pipeline/wowee_combat_maneuvers.hpp @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Combat Maneuver Group catalog (.wcmg) — +// novel replacement for the hardcoded class-mutex tables +// that the WoW client uses to grey out incompatible +// action-bar buttons (only one Warrior stance active at +// a time, only one Hunter aspect, only one DK presence, +// only one Druid form). Each entry is one mutually- +// exclusive spell group with its member spell IDs. +// +// Cross-references with previously-added formats: +// WCHC: classMask uses the WCHC class-bit convention +// (1=Warrior / 2=Paladin / 4=Hunter / 8=Rogue / +// 16=Priest / 32=DK / 64=Shaman / 128=Mage / +// 256=Warlock / 1024=Druid). +// WSPL: members[] entries are spell IDs that must +// resolve in the WSPL catalog. Validator can +// optionally cross-check with --catalog-pluck if +// WSPL is available. +// WACT: WACT button entries with spellId in any +// exclusive WCMG group will display the "active +// state" outline when that spell is the +// currently-applied one for the player. +// +// Binary layout (little-endian): +// magic[4] = "WCMG" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// groupId (uint32) +// nameLen + name +// descLen + description +// classMask (uint32) +// categoryKind (uint8) — Stance / Form / Aspect / +// Presence / Posture / +// Sigil +// exclusive (uint8) — 0/1 bool, only one +// active at a time +// pad0 (uint8) / pad1 (uint8) +// iconColorRGBA (uint32) +// memberCount (uint32) +// members (memberCount × uint32 spell IDs) +struct WoweeCombatManeuvers { + enum CategoryKind : uint8_t { + Stance = 0, // Warrior Battle/Def/Berserker + Form = 1, // Druid Bear/Cat/Tree/Moonkin + Aspect = 2, // Hunter Hawk/Cheetah/Pack/Viper + Presence = 3, // DK Frost/Unholy/Blood + Posture = 4, // generic posture (e.g. siege + // engine driver/gunner) + Sigil = 5, // DK Sigils (one ground-anchored + // at a time) + }; + + struct Entry { + uint32_t groupId = 0; + std::string name; + std::string description; + uint32_t classMask = 0; + uint8_t categoryKind = Stance; + uint8_t exclusive = 1; // default: mutex group + uint8_t pad0 = 0; + uint8_t pad1 = 0; + uint32_t iconColorRGBA = 0xFFFFFFFFu; + std::vector members; // spell IDs + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t groupId) const; + + // Returns all maneuver groups available to a class + // (used by the action-bar UI to compute which spells + // share a mutex bucket and grey accordingly). + std::vector findByClass(uint32_t classBit) const; + + // Returns the group containing the given spell ID, if + // any. Used by the action-bar update path to know + // whether casting a spell should clear a "currently + // active" outline elsewhere on the bar. + const Entry* findGroupForSpell(uint32_t spellId) const; +}; + +class WoweeCombatManeuversLoader { +public: + static bool save(const WoweeCombatManeuvers& cat, + const std::string& basePath); + static WoweeCombatManeuvers load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-cmg* variants. + // + // makeWarrior — 1 entry: WarriorStances (3 spells: + // Battle / Defensive / Berserker) + // classMask=1, exclusive. + // makeDruid — 2 entries: DruidShapeshiftForms + // (5 spells: Bear / Cat / Travel / + // Tree of Life / Moonkin) and + // DruidFlightForms (2 spells: + // Flight Form / Swift Flight Form) + // classMask=1024, both exclusive. + // makeAllMutex — 4 entries spanning all classes + // that have mutex groups: Warrior + // stances / Hunter aspects / DK + // presences / Druid forms (one + // representative group per class). + static WoweeCombatManeuvers makeWarrior(const std::string& catalogName); + static WoweeCombatManeuvers makeDruid(const std::string& catalogName); + static WoweeCombatManeuvers makeAllMutex(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_combat_maneuvers.cpp b/src/pipeline/wowee_combat_maneuvers.cpp new file mode 100644 index 00000000..bad1934c --- /dev/null +++ b/src/pipeline/wowee_combat_maneuvers.cpp @@ -0,0 +1,295 @@ +#include "pipeline/wowee_combat_maneuvers.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'C', 'M', 'G'}; +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; +} + +std::string normalizePath(std::string base) { + if (base.size() < 5 || base.substr(base.size() - 5) != ".wcmg") { + base += ".wcmg"; + } + return base; +} + +uint32_t packRgba(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 0xFF) { + return (static_cast(a) << 24) | + (static_cast(b) << 16) | + (static_cast(g) << 8) | + static_cast(r); +} + +} // namespace + +const WoweeCombatManeuvers::Entry* +WoweeCombatManeuvers::findById(uint32_t groupId) const { + for (const auto& e : entries) + if (e.groupId == groupId) return &e; + return nullptr; +} + +std::vector +WoweeCombatManeuvers::findByClass(uint32_t classBit) const { + std::vector out; + for (const auto& e : entries) + if (e.classMask & classBit) out.push_back(&e); + return out; +} + +const WoweeCombatManeuvers::Entry* +WoweeCombatManeuvers::findGroupForSpell(uint32_t spellId) const { + for (const auto& e : entries) { + for (uint32_t m : e.members) { + if (m == spellId) return &e; + } + } + return nullptr; +} + +bool WoweeCombatManeuversLoader::save(const WoweeCombatManeuvers& 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.groupId); + writeStr(os, e.name); + writeStr(os, e.description); + writePOD(os, e.classMask); + writePOD(os, e.categoryKind); + writePOD(os, e.exclusive); + writePOD(os, e.pad0); + writePOD(os, e.pad1); + writePOD(os, e.iconColorRGBA); + uint32_t memberCount = static_cast(e.members.size()); + writePOD(os, memberCount); + for (uint32_t m : e.members) writePOD(os, m); + } + return os.good(); +} + +WoweeCombatManeuvers WoweeCombatManeuversLoader::load( + const std::string& basePath) { + WoweeCombatManeuvers 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.groupId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name) || !readStr(is, e.description)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.classMask) || + !readPOD(is, e.categoryKind) || + !readPOD(is, e.exclusive) || + !readPOD(is, e.pad0) || + !readPOD(is, e.pad1) || + !readPOD(is, e.iconColorRGBA)) { + out.entries.clear(); return out; + } + uint32_t memberCount = 0; + if (!readPOD(is, memberCount)) { + out.entries.clear(); return out; + } + if (memberCount > (1u << 16)) { + out.entries.clear(); return out; + } + e.members.resize(memberCount); + for (uint32_t k = 0; k < memberCount; ++k) { + if (!readPOD(is, e.members[k])) { + out.entries.clear(); return out; + } + } + } + return out; +} + +bool WoweeCombatManeuversLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeCombatManeuvers WoweeCombatManeuversLoader::makeWarrior( + const std::string& catalogName) { + using C = WoweeCombatManeuvers; + WoweeCombatManeuvers c; + c.name = catalogName; + C::Entry e; + e.groupId = 1; + e.name = "WarriorStances"; + e.description = + "Warrior combat stances — only one stance active " + "at a time. Battle Stance is the leveling default; " + "Defensive is tank-focused; Berserker enables " + "high-damage abilities at the cost of armor."; + e.classMask = 1; // Warrior + e.categoryKind = C::Stance; + e.exclusive = 1; + e.iconColorRGBA = packRgba(220, 60, 60); // warrior red + // WoW 3.3.5a stance spell IDs. + e.members = { + 2457, // Battle Stance + 71, // Defensive Stance + 2458, // Berserker Stance + }; + c.entries.push_back(e); + return c; +} + +WoweeCombatManeuvers WoweeCombatManeuversLoader::makeDruid( + const std::string& catalogName) { + using C = WoweeCombatManeuvers; + WoweeCombatManeuvers c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint8_t kind, uint8_t excl, + std::vector members, + const char* desc) { + C::Entry e; + e.groupId = id; e.name = name; e.description = desc; + e.classMask = 1024; // Druid + e.categoryKind = kind; + e.exclusive = excl; + e.iconColorRGBA = packRgba(255, 125, 10); // druid orange + e.members = std::move(members); + c.entries.push_back(e); + }; + add(100, "DruidShapeshiftForms", + C::Form, 1, + { + 5487, // Bear Form + 768, // Cat Form + 783, // Travel Form + 33891, // Tree of Life + 24858, // Moonkin Form + }, + "Druid ground shapeshift forms — only one active " + "at a time. Switching to a new form cancels the " + "previous and breaks invisible/stealth in many " + "cases."); + add(101, "DruidFlightForms", + C::Form, 1, + { + 33943, // Flight Form + 40120, // Swift Flight Form + }, + "Druid flight forms — separate mutex bucket from " + "ground shapeshift forms. Only available in flying " + "zones (Outland, Northrend, post-Cata old world)."); + return c; +} + +WoweeCombatManeuvers WoweeCombatManeuversLoader::makeAllMutex( + const std::string& catalogName) { + using C = WoweeCombatManeuvers; + WoweeCombatManeuvers c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint32_t classBit, uint8_t kind, + std::vector members, + uint32_t color, const char* desc) { + C::Entry e; + e.groupId = id; e.name = name; e.description = desc; + e.classMask = classBit; + e.categoryKind = kind; + e.exclusive = 1; + e.iconColorRGBA = color; + e.members = std::move(members); + c.entries.push_back(e); + }; + add(200, "WarriorStances", 1, C::Stance, + { 2457, 71, 2458 }, + packRgba(220, 60, 60), + "Warrior 3-stance mutex (Battle / Defensive / " + "Berserker)."); + add(201, "HunterAspects", 4, C::Aspect, + { + 13165, // Aspect of the Hawk + 5118, // Aspect of the Cheetah + 13159, // Aspect of the Pack + 34074, // Aspect of the Viper + 61846, // Aspect of the Dragonhawk + 13161, // Aspect of the Beast + 20043, // Aspect of the Wild + }, + packRgba(170, 210, 100), + "Hunter aspect mutex — 7 aspects, only one active " + "at a time. Dragonhawk added in WotLK; Beast and " + "Wild present since Vanilla."); + add(202, "DKPresences", 32, C::Presence, + { + 48263, // Blood Presence + 48266, // Frost Presence (note: shares ID + // with Frost spec aura in some + // builds) + 48265, // Unholy Presence + }, + packRgba(140, 50, 80), + "Death Knight 3-presence mutex (Blood for tanking, " + "Frost for haste, Unholy for movement+damage)."); + add(203, "DruidShapeshiftForms", 1024, C::Form, + { 5487, 768, 783, 33891, 24858 }, + packRgba(255, 125, 10), + "Druid ground shapeshift mutex (Bear / Cat / " + "Travel / Tree / Moonkin). Same five spells as the " + "DruidShapeshiftForms entry in the dedicated Druid " + "catalog."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 391495c7..ec31fc8b 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -304,6 +304,8 @@ const char* const kArgRequired[] = { "--gen-scb", "--gen-scb-maintenance", "--gen-scb-helptips", "--info-wscb", "--validate-wscb", "--export-wscb-json", "--import-wscb-json", + "--gen-cmg", "--gen-cmg-druid", "--gen-cmg-all", + "--info-wcmg", "--validate-wcmg", "--gen-weather-temperate", "--gen-weather-arctic", "--gen-weather-desert", "--gen-weather-stormy", "--gen-zone-atmosphere", diff --git a/tools/editor/cli_combat_maneuvers_catalog.cpp b/tools/editor/cli_combat_maneuvers_catalog.cpp new file mode 100644 index 00000000..d0fac672 --- /dev/null +++ b/tools/editor/cli_combat_maneuvers_catalog.cpp @@ -0,0 +1,310 @@ +#include "cli_combat_maneuvers_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_combat_maneuvers.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWcmgExt(std::string base) { + stripExt(base, ".wcmg"); + return base; +} + +const char* categoryKindName(uint8_t k) { + using C = wowee::pipeline::WoweeCombatManeuvers; + switch (k) { + case C::Stance: return "stance"; + case C::Form: return "form"; + case C::Aspect: return "aspect"; + case C::Presence: return "presence"; + case C::Posture: return "posture"; + case C::Sigil: return "sigil"; + default: return "unknown"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeCombatManeuvers& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeCombatManeuversLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wcmg\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeCombatManeuvers& c, + const std::string& base) { + size_t totalSpells = 0; + for (const auto& e : c.entries) totalSpells += e.members.size(); + std::printf("Wrote %s.wcmg\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" groups : %zu (%zu member spells total)\n", + c.entries.size(), totalSpells); +} + +int handleGenWarrior(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "WarriorStanceMutex"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWcmgExt(base); + auto c = wowee::pipeline::WoweeCombatManeuversLoader::makeWarrior(name); + if (!saveOrError(c, base, "gen-cmg")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenDruid(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "DruidShapeshiftMutex"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWcmgExt(base); + auto c = wowee::pipeline::WoweeCombatManeuversLoader::makeDruid(name); + if (!saveOrError(c, base, "gen-cmg-druid")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenAllMutex(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "AllClassMutexGroups"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWcmgExt(base); + auto c = wowee::pipeline::WoweeCombatManeuversLoader::makeAllMutex(name); + if (!saveOrError(c, base, "gen-cmg-all")) 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 = stripWcmgExt(base); + if (!wowee::pipeline::WoweeCombatManeuversLoader::exists(base)) { + std::fprintf(stderr, "WCMG not found: %s.wcmg\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeCombatManeuversLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wcmg"] = base + ".wcmg"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"groupId", e.groupId}, + {"name", e.name}, + {"description", e.description}, + {"classMask", e.classMask}, + {"categoryKind", e.categoryKind}, + {"categoryKindName", + categoryKindName(e.categoryKind)}, + {"exclusive", e.exclusive != 0}, + {"iconColorRGBA", e.iconColorRGBA}, + {"members", e.members}, + {"memberCount", e.members.size()}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WCMG: %s.wcmg\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" groups : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id classMask category excl members name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %8u %-9s %4s %7zu %s\n", + e.groupId, e.classMask, + categoryKindName(e.categoryKind), + e.exclusive ? "yes " : "no ", + e.members.size(), e.name.c_str()); + // Spell ID list as a wrapped sub-line for the + // operator's eye. + if (!e.members.empty()) { + std::printf(" members:"); + size_t col = 19; + for (size_t k = 0; k < e.members.size(); ++k) { + char buf[16]; + int n = std::snprintf(buf, sizeof(buf), + " %u%s", e.members[k], + (k + 1 < e.members.size()) ? "," : ""); + if (col + n > 78) { + std::printf("\n "); + col = 20; + } + std::printf("%s", buf); + col += n; + } + std::printf("\n"); + } + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWcmgExt(base); + if (!wowee::pipeline::WoweeCombatManeuversLoader::exists(base)) { + std::fprintf(stderr, + "validate-wcmg: WCMG not found: %s.wcmg\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeCombatManeuversLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::vector idsSeen; + // Track which spell IDs appear in any exclusive group + // — a spell that appears in TWO different exclusive + // groups creates an undecidable mutex (which group's + // outline does the action bar use?). + std::set spellInExclusiveGroup; + std::vector> doubleAssign; + 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.groupId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.groupId == 0) + errors.push_back(ctx + ": groupId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.classMask == 0) { + errors.push_back(ctx + + ": classMask is 0 — group is not " + "associated with any class"); + } + if (e.categoryKind > 5) { + errors.push_back(ctx + ": categoryKind " + + std::to_string(e.categoryKind) + + " out of range (must be 0..5)"); + } + if (e.members.empty()) { + errors.push_back(ctx + + ": members[] is empty — mutex group has " + "nothing to switch between"); + } + // A single-member mutex group is technically legal + // but suggests a content authoring error. + if (e.members.size() == 1) { + warnings.push_back(ctx + + ": only 1 member spell — mutex with one " + "element has no exclusion to enforce; " + "verify if intentional"); + } + // Check for duplicate spell IDs WITHIN this + // group's members list. + std::set seen; + for (uint32_t m : e.members) { + if (m == 0) { + errors.push_back(ctx + + ": members[] contains spellId 0"); + continue; + } + if (!seen.insert(m).second) { + errors.push_back(ctx + + ": duplicate spellId " + + std::to_string(m) + " within members[]"); + } + // Cross-group check: same spell in two + // exclusive groups is an authoring bug. + if (e.exclusive) { + if (!spellInExclusiveGroup.insert(m).second) { + doubleAssign.push_back({m, e.groupId}); + } + } + } + for (uint32_t prev : idsSeen) { + if (prev == e.groupId) { + errors.push_back(ctx + ": duplicate groupId"); + break; + } + } + idsSeen.push_back(e.groupId); + } + for (auto [spellId, groupId] : doubleAssign) { + errors.push_back( + "spellId " + std::to_string(spellId) + + " appears in multiple exclusive groups " + "(latest: groupId " + std::to_string(groupId) + + ") — action bar mutex would be undecidable"); + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wcmg"] = base + ".wcmg"; + 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-wcmg: %s.wcmg\n", base.c_str()); + if (ok && warnings.empty()) { + size_t totalSpells = 0; + for (const auto& e : c.entries) totalSpells += e.members.size(); + std::printf(" OK — %zu groups, %zu member spells, all " + "groupIds unique\n", + c.entries.size(), totalSpells); + 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 handleCombatManeuversCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-cmg") == 0 && i + 1 < argc) { + outRc = handleGenWarrior(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-cmg-druid") == 0 && i + 1 < argc) { + outRc = handleGenDruid(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-cmg-all") == 0 && i + 1 < argc) { + outRc = handleGenAllMutex(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wcmg") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wcmg") == 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_combat_maneuvers_catalog.hpp b/tools/editor/cli_combat_maneuvers_catalog.hpp new file mode 100644 index 00000000..f3784ff9 --- /dev/null +++ b/tools/editor/cli_combat_maneuvers_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleCombatManeuversCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index af4ead2e..d642f1fa 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -143,6 +143,7 @@ #include "cli_group_compositions_catalog.hpp" #include "cli_hearth_binds_catalog.hpp" #include "cli_server_broadcasts_catalog.hpp" +#include "cli_combat_maneuvers_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_quest_objective.hpp" #include "cli_quest_reward.hpp" @@ -328,6 +329,7 @@ constexpr DispatchFn kDispatchTable[] = { handleGroupCompositionsCatalog, handleHearthBindsCatalog, handleServerBroadcastsCatalog, + handleCombatManeuversCatalog, handleCatalogPluck, handleQuestObjective, handleQuestReward, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 61b05afa..6f66c065 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -101,6 +101,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','G','R','P'}, ".wgrp", "social", "--info-wgrp", "Group composition catalog"}, {{'W','H','R','T'}, ".whrt", "social", "--info-whrt", "Hearthstone bind point catalog"}, {{'W','S','C','B'}, ".wscb", "server", "--info-wscb", "Server channel broadcast catalog"}, + {{'W','C','M','G'}, ".wcmg", "spells", "--info-wcmg", "Combat maneuver group 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 922013ac..bced0acd 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2139,6 +2139,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wscb to a human-editable JSON sidecar (defaults to .wscb.json; emits both channelKind/factionFilter ints AND name strings)\n"); std::printf(" --import-wscb-json [out-base]\n"); std::printf(" Import a .wscb.json sidecar back into binary .wscb (channelKind int OR \"login\"/\"system\"/\"raidwarning\"/\"motd\"/\"helptip\"; factionFilter int OR \"alliance\"/\"horde\"/\"both\")\n"); + std::printf(" --gen-cmg [name]\n"); + std::printf(" Emit .wcmg 1 Warrior 3-stance mutex group (Battle / Defensive / Berserker)\n"); + std::printf(" --gen-cmg-druid [name]\n"); + std::printf(" Emit .wcmg 2 Druid mutex groups (5 ground shapeshift forms + 2 flight forms — separate buckets so flight isn't broken by Cat Form)\n"); + std::printf(" --gen-cmg-all [name]\n"); + std::printf(" Emit .wcmg 4 cross-class mutex groups (Warrior stances + Hunter aspects + DK presences + Druid forms — one representative group per mutex-having class)\n"); + std::printf(" --info-wcmg [--json]\n"); + std::printf(" Print WCMG entries (id / classMask / category / exclusive / member count / name) plus per-group spell ID list\n"); + std::printf(" --validate-wcmg [--json]\n"); + std::printf(" Static checks: id+name+classMask required, categoryKind 0..5, members[] non-empty, no duplicate spellIds within a group, no duplicate groupIds, no spellId in two exclusive groups (undecidable mutex); warns on single-member groups\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(" --gen-weather-temperate [zoneName]\n"); diff --git a/tools/editor/cli_list_formats.cpp b/tools/editor/cli_list_formats.cpp index f79efc2f..b7723ab4 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -123,6 +123,7 @@ constexpr FormatRow kFormats[] = { {"WGRP", ".wgrp", "social", "LFG group-composition rules", "Group composition catalog (role quotas)"}, {"WHRT", ".whrt", "social", "SMSG_BINDPOINTUPDATE bind list", "Hearthstone bind point catalog"}, {"WSCB", ".wscb", "server", "MOTD + scheduled SMSG_NOTIFICATION","Server channel broadcast catalog"}, + {"WCMG", ".wcmg", "spells", "Stance/Form/Aspect mutex tables", "Combat maneuver group catalog (mutex spells)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine