From 6d9d00fbb925eb4beab5a3d4af2dccdbe3b103de Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 10 May 2026 03:37:36 -0700 Subject: [PATCH] feat(pipeline): WSPK spell pack catalog (126th open format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel replacement for the implicit per-class spellbook layout that vanilla WoW derived from SkillLineAbility.dbc + the hard- coded per-spec tab order baked into the client UI. Each WSPK entry binds one (classId, tabIndex) pair to an ordered list of spellIds shown in that spellbook tab. Three presets seeded with canonical vanilla low-rank spellIds: --gen-spk-warrior 4 tabs (General + Arms/Fury/Protection) including Charge, Mortal Strike, Bloodthirst, Shield Block --gen-spk-mage 4 tabs (General + Arcane/Fire/Frost) including Frostbolt rank 1 (spellId 116) — the canonical "every mage starts here" --gen-spk-rogue 4 tabs (General + Assassination/Combat/ Subtlety) with poison + lethality picks Validator catches: packId+tabName required, classId in 1..11, tabIndex in 0..3, no duplicate packIds, no duplicate (classId,tabIndex) pairs (spellbook UI dispatch tie), no zero spellIds, no duplicate spellIds within any single tab (would render twice in spellbook). Warns on classId 6 and 10 (vanilla PlayerClass DBC gaps) and on empty tabs (player would see a blank spellbook tab). Format count 125 -> 126. CLI flag count 1328 -> 1335. --- CMakeLists.txt | 3 + include/pipeline/wowee_spell_pack.hpp | 100 ++++++++ src/pipeline/wowee_spell_pack.cpp | 290 +++++++++++++++++++++++ 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 + tools/editor/cli_spell_pack_catalog.cpp | 291 ++++++++++++++++++++++++ tools/editor/cli_spell_pack_catalog.hpp | 12 + 10 files changed, 712 insertions(+) create mode 100644 include/pipeline/wowee_spell_pack.hpp create mode 100644 src/pipeline/wowee_spell_pack.cpp create mode 100644 tools/editor/cli_spell_pack_catalog.cpp create mode 100644 tools/editor/cli_spell_pack_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 613012da..8106acba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -714,6 +714,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_localization.cpp src/pipeline/wowee_global_channels.cpp src/pipeline/wowee_addon_manifest.cpp + src/pipeline/wowee_spell_pack.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1591,6 +1592,7 @@ add_executable(wowee_editor tools/editor/cli_localization_catalog.cpp tools/editor/cli_global_channels_catalog.cpp tools/editor/cli_addon_manifest_catalog.cpp + tools/editor/cli_spell_pack_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1787,6 +1789,7 @@ add_executable(wowee_editor src/pipeline/wowee_localization.cpp src/pipeline/wowee_global_channels.cpp src/pipeline/wowee_addon_manifest.cpp + src/pipeline/wowee_spell_pack.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_spell_pack.hpp b/include/pipeline/wowee_spell_pack.hpp new file mode 100644 index 00000000..76ca4699 --- /dev/null +++ b/include/pipeline/wowee_spell_pack.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Spell Pack catalog (.wspk) — novel +// replacement for the implicit per-class spellbook +// layout that vanilla WoW derived from +// SkillLineAbility.dbc + SpellTabIcon mappings + the +// hard-coded per-spec tab order baked into the client +// UI. Each WSPK entry binds one (classId, tabIndex) +// pair to an ordered list of spellIds shown in that +// spellbook tab. +// +// Cross-references with previously-added formats: +// WSPL: spellIds in the ordered list are looked up +// against WSPL spell catalog at runtime. +// WCDB: classId references the playable-class +// catalog (currently 1..11 in vanilla: +// Warrior=1 ... Druid=11). +// +// Binary layout (little-endian): +// magic[4] = "WSPK" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// packId (uint32) — surrogate primary key +// for cross-format +// --catalog-find lookups +// classId (uint8) — 1..11 vanilla class +// tabIndex (uint8) — 0=General/3 spec tabs +// iconIndex (uint8) — SpellIcon row id for +// the tab header glyph +// pad0 (uint8) +// tabNameLen + tabName — display label for the +// spellbook tab +// spellCount (uint32) +// spellIds (uint32 × count) — ordered display +// list (top-to- +// bottom in tab) +struct WoweeSpellPack { + struct Entry { + uint32_t packId = 0; + uint8_t classId = 0; + uint8_t tabIndex = 0; + uint8_t iconIndex = 0; + uint8_t pad0 = 0; + std::string tabName; + std::vector spellIds; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t packId) const; + const Entry* findByClassTab(uint8_t classId, + uint8_t tabIndex) const; + + // Returns all packs for a class (typically 4: General + // + 3 spec tabs). Used by the spellbook-screen UI to + // populate per-class tab order. + std::vector findByClass(uint8_t classId) const; +}; + +class WoweeSpellPackLoader { +public: + static bool save(const WoweeSpellPack& cat, + const std::string& basePath); + static WoweeSpellPack load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-spk* variants. + // + // makeWarriorPack — 4 tabs (General + 3 trees: + // Arms/Fury/Protection). Each + // tab seeded with canonical + // vanilla spellIds. + // makeMagePack — 4 tabs (General + Arcane/ + // Fire/Frost). Frost tab + // includes Frostbolt rank-1 + // spellId 116 — the canonical + // "every mage starts here" spell. + // makeRoguePack — 4 tabs (General + Assassin/ + // Combat/Subtlety). Combat tab + // seeded with poison-application + // and lethality picks. + static WoweeSpellPack makeWarriorPack(const std::string& catalogName); + static WoweeSpellPack makeMagePack(const std::string& catalogName); + static WoweeSpellPack makeRoguePack(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_spell_pack.cpp b/src/pipeline/wowee_spell_pack.cpp new file mode 100644 index 00000000..c86da520 --- /dev/null +++ b/src/pipeline/wowee_spell_pack.cpp @@ -0,0 +1,290 @@ +#include "pipeline/wowee_spell_pack.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'S', 'P', 'K'}; +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; + 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) != ".wspk") { + base += ".wspk"; + } + return base; +} + +} // namespace + +const WoweeSpellPack::Entry* +WoweeSpellPack::findById(uint32_t packId) const { + for (const auto& e : entries) + if (e.packId == packId) return &e; + return nullptr; +} + +const WoweeSpellPack::Entry* +WoweeSpellPack::findByClassTab(uint8_t classId, + uint8_t tabIndex) const { + for (const auto& e : entries) + if (e.classId == classId && e.tabIndex == tabIndex) + return &e; + return nullptr; +} + +std::vector +WoweeSpellPack::findByClass(uint8_t classId) const { + std::vector out; + for (const auto& e : entries) + if (e.classId == classId) out.push_back(&e); + return out; +} + +bool WoweeSpellPackLoader::save(const WoweeSpellPack& 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.packId); + writePOD(os, e.classId); + writePOD(os, e.tabIndex); + writePOD(os, e.iconIndex); + writePOD(os, e.pad0); + writeStr(os, e.tabName); + writeU32Vec(os, e.spellIds); + } + return os.good(); +} + +WoweeSpellPack WoweeSpellPackLoader::load( + const std::string& basePath) { + WoweeSpellPack 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.packId) || + !readPOD(is, e.classId) || + !readPOD(is, e.tabIndex) || + !readPOD(is, e.iconIndex) || + !readPOD(is, e.pad0)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.tabName)) { + out.entries.clear(); return out; + } + if (!readU32Vec(is, e.spellIds)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeSpellPackLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +namespace { + +// Helper to build one tab entry. classId follows +// vanilla DBC class IDs: Warrior=1, Mage=8, Rogue=4. +struct TabSpec { + uint32_t packId; + uint8_t classId; + uint8_t tabIndex; + uint8_t iconIndex; + const char* tabName; + std::vector spellIds; +}; + +WoweeSpellPack makeFromTabs(const std::string& catalogName, + std::vector tabs) { + using P = WoweeSpellPack; + WoweeSpellPack c; + c.name = catalogName; + for (auto& t : tabs) { + P::Entry e; + e.packId = t.packId; + e.classId = t.classId; + e.tabIndex = t.tabIndex; + e.iconIndex = t.iconIndex; + e.tabName = t.tabName; + e.spellIds = std::move(t.spellIds); + c.entries.push_back(std::move(e)); + } + return c; +} + +} // namespace + +WoweeSpellPack WoweeSpellPackLoader::makeWarriorPack( + const std::string& catalogName) { + // classId=1 (Warrior). Tab 0=General, 1=Arms, + // 2=Fury, 3=Protection. SpellIds are canonical + // vanilla low-rank picks: Charge=100, Heroic + // Strike=78, Mortal Strike=12294, Bloodthirst=23881, + // Shield Block=2565, etc. + return makeFromTabs(catalogName, { + {1001, 1, 0, 1, "General", + {78, // Heroic Strike rank 1 + 100, // Charge rank 1 + 6673, // Battle Shout rank 1 + 2457, // Battle Stance + }}, + {1002, 1, 1, 30, "Arms", + {12294, // Mortal Strike + 1680, // Whirlwind + 7384, // Overpower + }}, + {1003, 1, 2, 31, "Fury", + {23881, // Bloodthirst + 5308, // Execute + 1719, // Recklessness + }}, + {1004, 1, 3, 32, "Protection", + {2565, // Shield Block + 871, // Shield Wall + 355, // Taunt + }}, + }); +} + +WoweeSpellPack WoweeSpellPackLoader::makeMagePack( + const std::string& catalogName) { + // classId=8 (Mage). Frost tab includes Frostbolt + // rank 1 (spellId 116) — the canonical "every + // mage starts with this" spell. + return makeFromTabs(catalogName, { + {2001, 8, 0, 5, "General", + {133, // Fireball rank 1 + 168, // Frost Armor rank 1 + 1459, // Arcane Intellect rank 1 + }}, + {2002, 8, 1, 50, "Arcane", + {1449, // Arcane Explosion rank 1 + 5143, // Arcane Missiles rank 1 + 1953, // Blink + }}, + {2003, 8, 2, 51, "Fire", + {2120, // Flamestrike rank 1 + 11366, // Pyroblast rank 1 + 2948, // Scorch rank 1 + }}, + {2004, 8, 3, 52, "Frost", + {116, // Frostbolt rank 1 — every mage + // begins here + 122, // Frost Nova rank 1 + 10, // Blizzard rank 1 + }}, + }); +} + +WoweeSpellPack WoweeSpellPackLoader::makeRoguePack( + const std::string& catalogName) { + // classId=4 (Rogue). Combat tab seeded with + // poison-application + lethality picks. + return makeFromTabs(catalogName, { + {3001, 4, 0, 7, "General", + {1752, // Sinister Strike rank 1 + 1784, // Stealth rank 1 + 921, // Pickpocket + }}, + {3002, 4, 1, 70, "Assassination", + {703, // Garrote rank 1 + 8676, // Ambush rank 1 + 2098, // Eviscerate rank 1 + }}, + {3003, 4, 2, 71, "Combat", + {2983, // Sprint rank 1 + 1856, // Vanish rank 1 + 8647, // Expose Armor rank 1 + }}, + {3004, 4, 3, 72, "Subtlety", + {1857, // Vanish rank 2 + 5277, // Evasion + 14185, // Preparation + }}, + }); +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index e956a3e1..af7692fc 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -386,6 +386,8 @@ const char* const kArgRequired[] = { "--gen-mod", "--gen-mod-ui", "--gen-mod-util", "--info-wmod", "--validate-wmod", "--export-wmod-json", "--import-wmod-json", + "--gen-spk-warrior", "--gen-spk-mage", "--gen-spk-rogue", + "--info-wspk", "--validate-wspk", "--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 43d423c0..b1b92bf6 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -170,6 +170,7 @@ #include "cli_localization_catalog.hpp" #include "cli_global_channels_catalog.hpp" #include "cli_addon_manifest_catalog.hpp" +#include "cli_spell_pack_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -385,6 +386,7 @@ constexpr DispatchFn kDispatchTable[] = { handleLocalizationCatalog, handleGlobalChannelsCatalog, handleAddonManifestCatalog, + handleSpellPackCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 33687f36..146ff7ba 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -128,6 +128,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'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','S','P','K'}, ".wspk", "spells", "--info-wspk", "Spell pack 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 25576a2d..f8e36848 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2517,6 +2517,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wmod to a human-editable JSON sidecar (defaults to .wmod.json; emits dependencies and optionalDependencies as JSON int arrays)\n"); std::printf(" --import-wmod-json [out-base]\n"); std::printf(" Import a .wmod.json sidecar back into binary .wmod (dependency arrays accept JSON int arrays — round-trips chained dep graphs byte-identical)\n"); + std::printf(" --gen-spk-warrior [name]\n"); + std::printf(" Emit .wspk Warrior class spellbook layout — 4 tabs (General + Arms/Fury/Protection) with canonical vanilla low-rank spellIds\n"); + std::printf(" --gen-spk-mage [name]\n"); + std::printf(" Emit .wspk Mage class spellbook layout — 4 tabs (General + Arcane/Fire/Frost) including Frostbolt rank 1 (spellId 116)\n"); + std::printf(" --gen-spk-rogue [name]\n"); + std::printf(" Emit .wspk Rogue class spellbook layout — 4 tabs (General + Assassination/Combat/Subtlety) with poison + lethality picks\n"); + std::printf(" --info-wspk [--json]\n"); + std::printf(" Print WSPK entries (packId / classId+name / tabIndex / iconIndex / spell count / tabName)\n"); + std::printf(" --validate-wspk [--json]\n"); + std::printf(" Static checks: packId+tabName required, classId in 1..11, tabIndex in 0..3, no duplicate packIds, no duplicate (classId,tabIndex) pairs (spellbook UI dispatch tie), no zero spellIds, no duplicate spellIds within any tab; warns on classId 6/10 (vanilla DBC gap) and on empty tabs (player would see blank spellbook)\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 0893c477..8efa28e8 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -150,6 +150,7 @@ constexpr FormatRow kFormats[] = { {"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)"}, + {"WSPK", ".wspk", "spells", "SkillLineAbility + per-spec tab order","Spell pack catalog (per-class spellbook tab layout)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_spell_pack_catalog.cpp b/tools/editor/cli_spell_pack_catalog.cpp new file mode 100644 index 00000000..89833270 --- /dev/null +++ b/tools/editor/cli_spell_pack_catalog.cpp @@ -0,0 +1,291 @@ +#include "cli_spell_pack_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_spell_pack.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWspkExt(std::string base) { + stripExt(base, ".wspk"); + return base; +} + +bool saveOrError(const wowee::pipeline::WoweeSpellPack& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeSpellPackLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wspk\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeSpellPack& c, + const std::string& base) { + std::printf("Wrote %s.wspk\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" packs : %zu\n", c.entries.size()); +} + +int handleGenWarrior(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "WarriorSpellPack"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWspkExt(base); + auto c = wowee::pipeline::WoweeSpellPackLoader:: + makeWarriorPack(name); + if (!saveOrError(c, base, "gen-spk-warrior")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenMage(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "MageSpellPack"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWspkExt(base); + auto c = wowee::pipeline::WoweeSpellPackLoader:: + makeMagePack(name); + if (!saveOrError(c, base, "gen-spk-mage")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenRogue(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "RogueSpellPack"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWspkExt(base); + auto c = wowee::pipeline::WoweeSpellPackLoader:: + makeRoguePack(name); + if (!saveOrError(c, base, "gen-spk-rogue")) return 1; + printGenSummary(c, base); + return 0; +} + +const char* classIdName(uint8_t c) { + // Vanilla 1.12 PlayerClass DBC ids — used for the + // info-table display only. + switch (c) { + case 1: return "Warrior"; + case 2: return "Paladin"; + case 3: return "Hunter"; + case 4: return "Rogue"; + case 5: return "Priest"; + case 7: return "Shaman"; + case 8: return "Mage"; + case 9: return "Warlock"; + case 11: return "Druid"; + default: return "?"; + } +} + +int handleInfo(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWspkExt(base); + if (!wowee::pipeline::WoweeSpellPackLoader::exists(base)) { + std::fprintf(stderr, "WSPK not found: %s.wspk\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSpellPackLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wspk"] = base + ".wspk"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"packId", e.packId}, + {"classId", e.classId}, + {"className", classIdName(e.classId)}, + {"tabIndex", e.tabIndex}, + {"iconIndex", e.iconIndex}, + {"tabName", e.tabName}, + {"spellIds", e.spellIds}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WSPK: %s.wspk\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" packs : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id class tab icon spells tabName\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %2u %-9s %3u %4u %5zu %s\n", + e.packId, e.classId, + classIdName(e.classId), + e.tabIndex, e.iconIndex, + e.spellIds.size(), e.tabName.c_str()); + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWspkExt(base); + if (!wowee::pipeline::WoweeSpellPackLoader::exists(base)) { + std::fprintf(stderr, + "validate-wspk: WSPK not found: %s.wspk\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSpellPackLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set packIdsSeen; + std::set> classTabPairs; + for (size_t k = 0; k < c.entries.size(); ++k) { + const auto& e = c.entries[k]; + std::string ctx = "entry " + std::to_string(k) + + " (packId=" + std::to_string(e.packId); + if (!e.tabName.empty()) ctx += " " + e.tabName; + ctx += ")"; + if (e.packId == 0) + errors.push_back(ctx + ": packId is 0"); + if (e.tabName.empty()) + errors.push_back(ctx + ": tabName is empty"); + // Vanilla classes: 1..11 with id 6 + 10 unused. + if (e.classId == 0 || e.classId > 11) { + errors.push_back(ctx + ": classId " + + std::to_string(e.classId) + + " out of vanilla range (1..11)"); + } + if (e.classId == 6 || e.classId == 10) { + warnings.push_back(ctx + ": classId " + + std::to_string(e.classId) + + " is unused in vanilla (gap in PlayerClass DBC)"); + } + // Tab 0 = General; 1..3 = the three spec trees. + if (e.tabIndex > 3) { + errors.push_back(ctx + ": tabIndex " + + std::to_string(e.tabIndex) + + " out of range (0..3 — General + 3 specs)"); + } + // (classId, tabIndex) MUST be unique — the + // spellbook UI dispatches by this pair, two + // entries with the same pair would tie. + auto pair = std::make_pair(e.classId, e.tabIndex); + if (!classTabPairs.insert(pair).second) { + errors.push_back(ctx + + ": duplicate (classId=" + + std::to_string(e.classId) + + ", tabIndex=" + + std::to_string(e.tabIndex) + + ") — spellbook UI tab dispatch tie"); + } + if (!packIdsSeen.insert(e.packId).second) { + errors.push_back(ctx + ": duplicate packId"); + } + // Per-tab spell uniqueness — the same spellId + // appearing twice in one tab is a copy-paste bug + // (the UI would render it twice). + std::set spellsInTab; + for (uint32_t sid : e.spellIds) { + if (sid == 0) { + errors.push_back(ctx + + ": tab contains spellId 0 (placeholder " + "or copy-paste error)"); + } + if (sid != 0 && !spellsInTab.insert(sid).second) { + errors.push_back(ctx + + ": duplicate spellId " + + std::to_string(sid) + + " within tab — would render twice in " + "spellbook"); + } + } + // Empty tab: warn — General tab with zero spells + // means the player starts with no abilities at + // all on that tree. + if (e.spellIds.empty()) { + warnings.push_back(ctx + + ": tab has zero spells — player would see " + "an empty spellbook tab"); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wspk"] = base + ".wspk"; + 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-wspk: %s.wspk\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu packs, all packIds unique, " + "(classId,tabIndex) unique, classId in " + "1..11, tabIndex in 0..3, no duplicate " + "spellIds within any tab\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 handleSpellPackCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-spk-warrior") == 0 && + i + 1 < argc) { + outRc = handleGenWarrior(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-spk-mage") == 0 && + i + 1 < argc) { + outRc = handleGenMage(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-spk-rogue") == 0 && + i + 1 < argc) { + outRc = handleGenRogue(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wspk") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wspk") == 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_spell_pack_catalog.hpp b/tools/editor/cli_spell_pack_catalog.hpp new file mode 100644 index 00000000..d427f1c8 --- /dev/null +++ b/tools/editor/cli_spell_pack_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleSpellPackCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee