Kelsidavis-WoWee/tools/editor/cli_spell_pack_catalog.cpp
Kelsi 6d9d00fbb9 feat(pipeline): WSPK spell pack catalog (126th open format)
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.
2026-05-10 03:37:36 -07:00

291 lines
9.8 KiB
C++

#include "cli_spell_pack_catalog.hpp"
#include "cli_arg_parse.hpp"
#include "cli_box_emitter.hpp"
#include "pipeline/wowee_spell_pack.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <set>
#include <string>
#include <utility>
#include <vector>
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<std::string> errors;
std::vector<std::string> warnings;
if (c.entries.empty()) {
warnings.push_back("catalog has zero entries");
}
std::set<uint32_t> packIdsSeen;
std::set<std::pair<uint8_t, uint8_t>> 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<uint32_t> 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