feat(pipeline): add WTAL (Wowee Talent catalog) format
Novel open replacement for Blizzard's TalentTab.dbc +
Talent.dbc + the AzerothCore-style talent_progression SQL
tables. The 25th open format added to the editor.
Defines class talent specialization trees: per-class set
of named tabs (Arms / Fury / Protection for warrior, Fire
/ Frost / Arcane for mage), each with talents arranged in
a row/column grid, each talent having up to 5 ranks and
an optional prerequisite chain.
Cross-references with previously-added formats:
WTAL.talent.prereqTalentId -> WTAL.talent.talentId
(intra-format chain)
WTAL.talent.rankSpellIds[] -> WSPL.entry.spellId
(spell granted at each rank)
Format:
• magic "WTAL", version 1, little-endian
• per tree: treeId / name / iconPath / requiredClassMask /
talents[] (row, col, maxRank, prereqTalentId+rank,
rankSpellIds[5] zero-padded for unused ranks)
Enums:
• ClassMask: bit positions match canonical CharClasses.dbc
classIds — Warrior / Paladin / Hunter / Rogue / Priest /
DK / Shaman / Mage / Warlock / Druid
API: WoweeTalentLoader::save / load / exists +
WoweeTalent::findTree / findTalent (global lookup across
all trees in the catalog).
Three preset emitters showcase tree shapes:
• makeStarter — 1 small tree (3-talent vertical chain)
• makeWarrior — 3 trees (Arms 4 / Fury 4 / Protection 3)
with WSPL cross-refs at capstones
(Mortal Strike -> WSPL 12294, Battle Shout
-> WSPL 6673, Thunder Clap -> WSPL 6343)
• makeMage — 3 trees (Arcane / Fire / Frost) with
capstones referencing Frostbolt 116 /
Fireball 133 / Blink 1953 from WSPL
CLI added (5 flags, 571 documented total now):
--gen-talents / --gen-talents-warrior / --gen-talents-mage
--info-wtal / --validate-wtal
Validator catches: tree+talent ids=0 or duplicates, empty
tree name, requiredClassMask=0 (every class would see this
tree — usually a typo), maxRank not in 1..5, talent listing
itself as prerequisite, prereqTalentId pointing at a
talent that doesn't exist in this catalog (intra-format
cross-reference resolution), prereqRank=0 or > the prereq
talent's maxRank (catches off-by-one references), gaps in
rankSpellIds progression (rank N has spell but rank N-1
doesn't — usually a typo).
The validator caught a real authoring bug in the makeMage /
makeWarrior presets during smoke testing — initial check
was comparing prereqRank against the WRONG talent's maxRank
(this talent's rather than the prereq's). Fixed in the same
commit by hoisting the check into the cross-reference
resolution pass where the prereq talent is in hand.
2026-05-09 16:33:45 -07:00
|
|
|
#include "cli_talents_catalog.hpp"
|
|
|
|
|
#include "cli_arg_parse.hpp"
|
|
|
|
|
#include "cli_box_emitter.hpp"
|
|
|
|
|
|
|
|
|
|
#include "pipeline/wowee_talents.hpp"
|
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
|
|
|
|
|
|
#include <cstdint>
|
|
|
|
|
#include <cstdio>
|
|
|
|
|
#include <cstring>
|
|
|
|
|
#include <fstream>
|
|
|
|
|
#include <string>
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
|
|
|
|
namespace wowee {
|
|
|
|
|
namespace editor {
|
|
|
|
|
namespace cli {
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
std::string stripWtalExt(std::string base) {
|
|
|
|
|
stripExt(base, ".wtal");
|
|
|
|
|
return base;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool saveOrError(const wowee::pipeline::WoweeTalent& c,
|
|
|
|
|
const std::string& base, const char* cmd) {
|
|
|
|
|
if (!wowee::pipeline::WoweeTalentLoader::save(c, base)) {
|
|
|
|
|
std::fprintf(stderr, "%s: failed to save %s.wtal\n",
|
|
|
|
|
cmd, base.c_str());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint32_t totalTalents(const wowee::pipeline::WoweeTalent& c) {
|
|
|
|
|
uint32_t n = 0;
|
|
|
|
|
for (const auto& t : c.trees) n += static_cast<uint32_t>(t.talents.size());
|
|
|
|
|
return n;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void printGenSummary(const wowee::pipeline::WoweeTalent& c,
|
|
|
|
|
const std::string& base) {
|
|
|
|
|
std::printf("Wrote %s.wtal\n", base.c_str());
|
|
|
|
|
std::printf(" catalog : %s\n", c.name.c_str());
|
|
|
|
|
std::printf(" trees : %zu (%u talents total)\n",
|
|
|
|
|
c.trees.size(), totalTalents(c));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int handleGenStarter(int& i, int argc, char** argv) {
|
|
|
|
|
std::string base = argv[++i];
|
|
|
|
|
std::string name = "StarterTalents";
|
|
|
|
|
if (parseOptArg(i, argc, argv)) name = argv[++i];
|
|
|
|
|
base = stripWtalExt(base);
|
|
|
|
|
auto c = wowee::pipeline::WoweeTalentLoader::makeStarter(name);
|
|
|
|
|
if (!saveOrError(c, base, "gen-talents")) return 1;
|
|
|
|
|
printGenSummary(c, base);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int handleGenWarrior(int& i, int argc, char** argv) {
|
|
|
|
|
std::string base = argv[++i];
|
|
|
|
|
std::string name = "WarriorTalents";
|
|
|
|
|
if (parseOptArg(i, argc, argv)) name = argv[++i];
|
|
|
|
|
base = stripWtalExt(base);
|
|
|
|
|
auto c = wowee::pipeline::WoweeTalentLoader::makeWarrior(name);
|
|
|
|
|
if (!saveOrError(c, base, "gen-talents-warrior")) return 1;
|
|
|
|
|
printGenSummary(c, base);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int handleGenMage(int& i, int argc, char** argv) {
|
|
|
|
|
std::string base = argv[++i];
|
|
|
|
|
std::string name = "MageTalents";
|
|
|
|
|
if (parseOptArg(i, argc, argv)) name = argv[++i];
|
|
|
|
|
base = stripWtalExt(base);
|
|
|
|
|
auto c = wowee::pipeline::WoweeTalentLoader::makeMage(name);
|
|
|
|
|
if (!saveOrError(c, base, "gen-talents-mage")) 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 = stripWtalExt(base);
|
|
|
|
|
if (!wowee::pipeline::WoweeTalentLoader::exists(base)) {
|
|
|
|
|
std::fprintf(stderr, "WTAL not found: %s.wtal\n", base.c_str());
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
auto c = wowee::pipeline::WoweeTalentLoader::load(base);
|
|
|
|
|
if (jsonOut) {
|
|
|
|
|
nlohmann::json j;
|
|
|
|
|
j["wtal"] = base + ".wtal";
|
|
|
|
|
j["name"] = c.name;
|
|
|
|
|
j["treeCount"] = c.trees.size();
|
|
|
|
|
j["totalTalents"] = totalTalents(c);
|
|
|
|
|
nlohmann::json arr = nlohmann::json::array();
|
|
|
|
|
for (const auto& t : c.trees) {
|
|
|
|
|
nlohmann::json jt;
|
|
|
|
|
jt["treeId"] = t.treeId;
|
|
|
|
|
jt["name"] = t.name;
|
|
|
|
|
jt["iconPath"] = t.iconPath;
|
|
|
|
|
jt["requiredClassMask"] = t.requiredClassMask;
|
|
|
|
|
nlohmann::json ta = nlohmann::json::array();
|
|
|
|
|
for (const auto& a : t.talents) {
|
|
|
|
|
nlohmann::json ja;
|
|
|
|
|
ja["talentId"] = a.talentId;
|
|
|
|
|
ja["row"] = a.row;
|
|
|
|
|
ja["col"] = a.col;
|
|
|
|
|
ja["maxRank"] = a.maxRank;
|
|
|
|
|
ja["prereqTalentId"] = a.prereqTalentId;
|
|
|
|
|
ja["prereqRank"] = a.prereqRank;
|
|
|
|
|
nlohmann::json sa = nlohmann::json::array();
|
|
|
|
|
for (int r = 0; r < wowee::pipeline::WoweeTalent::kMaxRanks; ++r) {
|
|
|
|
|
sa.push_back(a.rankSpellIds[r]);
|
|
|
|
|
}
|
|
|
|
|
ja["rankSpellIds"] = sa;
|
|
|
|
|
ta.push_back(ja);
|
|
|
|
|
}
|
|
|
|
|
jt["talents"] = ta;
|
|
|
|
|
arr.push_back(jt);
|
|
|
|
|
}
|
|
|
|
|
j["trees"] = arr;
|
|
|
|
|
std::printf("%s\n", j.dump(2).c_str());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
std::printf("WTAL: %s.wtal\n", base.c_str());
|
|
|
|
|
std::printf(" catalog : %s\n", c.name.c_str());
|
|
|
|
|
std::printf(" trees : %zu (%u talents total)\n",
|
|
|
|
|
c.trees.size(), totalTalents(c));
|
|
|
|
|
if (c.trees.empty()) return 0;
|
|
|
|
|
for (const auto& t : c.trees) {
|
|
|
|
|
std::printf("\n treeId=%u classMask=0x%x %s (%zu talents)\n",
|
|
|
|
|
t.treeId, t.requiredClassMask,
|
|
|
|
|
t.name.c_str(), t.talents.size());
|
|
|
|
|
if (t.talents.empty()) {
|
|
|
|
|
std::printf(" *no talents*\n");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
std::printf(" id row col maxRank prereq spellAtR1\n");
|
|
|
|
|
for (const auto& a : t.talents) {
|
|
|
|
|
std::printf(" %5u %u %u %u %5u/%u %u\n",
|
|
|
|
|
a.talentId, a.row, a.col, a.maxRank,
|
|
|
|
|
a.prereqTalentId, a.prereqRank,
|
|
|
|
|
a.rankSpellIds[0]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 16:41:37 -07:00
|
|
|
int handleExportJson(int& i, int argc, char** argv) {
|
|
|
|
|
// Mirrors the JSON pairs added for every other novel
|
|
|
|
|
// open format. Each tree emits scalar fields plus the
|
|
|
|
|
// talent array; rankSpellIds becomes a 5-element JSON
|
|
|
|
|
// array.
|
|
|
|
|
std::string base = argv[++i];
|
|
|
|
|
std::string outPath;
|
|
|
|
|
if (parseOptArg(i, argc, argv)) outPath = argv[++i];
|
|
|
|
|
base = stripWtalExt(base);
|
|
|
|
|
if (outPath.empty()) outPath = base + ".wtal.json";
|
|
|
|
|
if (!wowee::pipeline::WoweeTalentLoader::exists(base)) {
|
|
|
|
|
std::fprintf(stderr,
|
|
|
|
|
"export-wtal-json: WTAL not found: %s.wtal\n", base.c_str());
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
auto c = wowee::pipeline::WoweeTalentLoader::load(base);
|
|
|
|
|
nlohmann::json j;
|
|
|
|
|
j["name"] = c.name;
|
|
|
|
|
nlohmann::json arr = nlohmann::json::array();
|
|
|
|
|
for (const auto& t : c.trees) {
|
|
|
|
|
nlohmann::json jt;
|
|
|
|
|
jt["treeId"] = t.treeId;
|
|
|
|
|
jt["name"] = t.name;
|
|
|
|
|
jt["iconPath"] = t.iconPath;
|
|
|
|
|
jt["requiredClassMask"] = t.requiredClassMask;
|
|
|
|
|
nlohmann::json ta = nlohmann::json::array();
|
|
|
|
|
for (const auto& a : t.talents) {
|
|
|
|
|
nlohmann::json ja;
|
|
|
|
|
ja["talentId"] = a.talentId;
|
|
|
|
|
ja["row"] = a.row;
|
|
|
|
|
ja["col"] = a.col;
|
|
|
|
|
ja["maxRank"] = a.maxRank;
|
|
|
|
|
ja["prereqTalentId"] = a.prereqTalentId;
|
|
|
|
|
ja["prereqRank"] = a.prereqRank;
|
|
|
|
|
nlohmann::json sa = nlohmann::json::array();
|
|
|
|
|
for (int r = 0; r < wowee::pipeline::WoweeTalent::kMaxRanks; ++r) {
|
|
|
|
|
sa.push_back(a.rankSpellIds[r]);
|
|
|
|
|
}
|
|
|
|
|
ja["rankSpellIds"] = sa;
|
|
|
|
|
ta.push_back(ja);
|
|
|
|
|
}
|
|
|
|
|
jt["talents"] = ta;
|
|
|
|
|
arr.push_back(jt);
|
|
|
|
|
}
|
|
|
|
|
j["trees"] = arr;
|
|
|
|
|
std::ofstream out(outPath);
|
|
|
|
|
if (!out) {
|
|
|
|
|
std::fprintf(stderr,
|
|
|
|
|
"export-wtal-json: cannot write %s\n", outPath.c_str());
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
out << j.dump(2) << "\n";
|
|
|
|
|
out.close();
|
|
|
|
|
std::printf("Wrote %s\n", outPath.c_str());
|
|
|
|
|
std::printf(" source : %s.wtal\n", base.c_str());
|
|
|
|
|
std::printf(" trees : %zu\n", c.trees.size());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int handleImportJson(int& i, int argc, char** argv) {
|
|
|
|
|
std::string jsonPath = argv[++i];
|
|
|
|
|
std::string outBase;
|
|
|
|
|
if (parseOptArg(i, argc, argv)) outBase = argv[++i];
|
|
|
|
|
if (outBase.empty()) {
|
|
|
|
|
outBase = jsonPath;
|
|
|
|
|
std::string suffix = ".wtal.json";
|
|
|
|
|
if (outBase.size() > suffix.size() &&
|
|
|
|
|
outBase.substr(outBase.size() - suffix.size()) == suffix) {
|
|
|
|
|
outBase = outBase.substr(0, outBase.size() - suffix.size());
|
|
|
|
|
} else if (outBase.size() > 5 &&
|
|
|
|
|
outBase.substr(outBase.size() - 5) == ".json") {
|
|
|
|
|
outBase = outBase.substr(0, outBase.size() - 5);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
outBase = stripWtalExt(outBase);
|
|
|
|
|
std::ifstream in(jsonPath);
|
|
|
|
|
if (!in) {
|
|
|
|
|
std::fprintf(stderr,
|
|
|
|
|
"import-wtal-json: cannot read %s\n", jsonPath.c_str());
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
nlohmann::json j;
|
|
|
|
|
try { in >> j; }
|
|
|
|
|
catch (const std::exception& e) {
|
|
|
|
|
std::fprintf(stderr,
|
|
|
|
|
"import-wtal-json: bad JSON in %s: %s\n",
|
|
|
|
|
jsonPath.c_str(), e.what());
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
wowee::pipeline::WoweeTalent c;
|
|
|
|
|
c.name = j.value("name", std::string{});
|
|
|
|
|
if (j.contains("trees") && j["trees"].is_array()) {
|
|
|
|
|
for (const auto& jt : j["trees"]) {
|
|
|
|
|
wowee::pipeline::WoweeTalent::Tree t;
|
|
|
|
|
t.treeId = jt.value("treeId", 0u);
|
|
|
|
|
t.name = jt.value("name", std::string{});
|
|
|
|
|
t.iconPath = jt.value("iconPath", std::string{});
|
|
|
|
|
t.requiredClassMask = jt.value("requiredClassMask", 0u);
|
|
|
|
|
if (jt.contains("talents") && jt["talents"].is_array()) {
|
|
|
|
|
for (const auto& ja : jt["talents"]) {
|
|
|
|
|
wowee::pipeline::WoweeTalent::Talent a;
|
|
|
|
|
a.talentId = ja.value("talentId", 0u);
|
|
|
|
|
a.row = static_cast<uint8_t>(ja.value("row", 0));
|
|
|
|
|
a.col = static_cast<uint8_t>(ja.value("col", 0));
|
|
|
|
|
a.maxRank = static_cast<uint8_t>(ja.value("maxRank", 1));
|
|
|
|
|
a.prereqTalentId = ja.value("prereqTalentId", 0u);
|
|
|
|
|
a.prereqRank = static_cast<uint8_t>(
|
|
|
|
|
ja.value("prereqRank", 0));
|
|
|
|
|
if (ja.contains("rankSpellIds") &&
|
|
|
|
|
ja["rankSpellIds"].is_array()) {
|
|
|
|
|
const auto& sa = ja["rankSpellIds"];
|
|
|
|
|
for (int r = 0;
|
|
|
|
|
r < wowee::pipeline::WoweeTalent::kMaxRanks &&
|
|
|
|
|
r < static_cast<int>(sa.size()); ++r) {
|
|
|
|
|
if (sa[r].is_number_integer()) {
|
|
|
|
|
a.rankSpellIds[r] = sa[r].get<uint32_t>();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
t.talents.push_back(a);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
c.trees.push_back(std::move(t));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!wowee::pipeline::WoweeTalentLoader::save(c, outBase)) {
|
|
|
|
|
std::fprintf(stderr,
|
|
|
|
|
"import-wtal-json: failed to save %s.wtal\n", outBase.c_str());
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
std::printf("Wrote %s.wtal\n", outBase.c_str());
|
|
|
|
|
std::printf(" source : %s\n", jsonPath.c_str());
|
|
|
|
|
std::printf(" trees : %zu\n", c.trees.size());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
feat(pipeline): add WTAL (Wowee Talent catalog) format
Novel open replacement for Blizzard's TalentTab.dbc +
Talent.dbc + the AzerothCore-style talent_progression SQL
tables. The 25th open format added to the editor.
Defines class talent specialization trees: per-class set
of named tabs (Arms / Fury / Protection for warrior, Fire
/ Frost / Arcane for mage), each with talents arranged in
a row/column grid, each talent having up to 5 ranks and
an optional prerequisite chain.
Cross-references with previously-added formats:
WTAL.talent.prereqTalentId -> WTAL.talent.talentId
(intra-format chain)
WTAL.talent.rankSpellIds[] -> WSPL.entry.spellId
(spell granted at each rank)
Format:
• magic "WTAL", version 1, little-endian
• per tree: treeId / name / iconPath / requiredClassMask /
talents[] (row, col, maxRank, prereqTalentId+rank,
rankSpellIds[5] zero-padded for unused ranks)
Enums:
• ClassMask: bit positions match canonical CharClasses.dbc
classIds — Warrior / Paladin / Hunter / Rogue / Priest /
DK / Shaman / Mage / Warlock / Druid
API: WoweeTalentLoader::save / load / exists +
WoweeTalent::findTree / findTalent (global lookup across
all trees in the catalog).
Three preset emitters showcase tree shapes:
• makeStarter — 1 small tree (3-talent vertical chain)
• makeWarrior — 3 trees (Arms 4 / Fury 4 / Protection 3)
with WSPL cross-refs at capstones
(Mortal Strike -> WSPL 12294, Battle Shout
-> WSPL 6673, Thunder Clap -> WSPL 6343)
• makeMage — 3 trees (Arcane / Fire / Frost) with
capstones referencing Frostbolt 116 /
Fireball 133 / Blink 1953 from WSPL
CLI added (5 flags, 571 documented total now):
--gen-talents / --gen-talents-warrior / --gen-talents-mage
--info-wtal / --validate-wtal
Validator catches: tree+talent ids=0 or duplicates, empty
tree name, requiredClassMask=0 (every class would see this
tree — usually a typo), maxRank not in 1..5, talent listing
itself as prerequisite, prereqTalentId pointing at a
talent that doesn't exist in this catalog (intra-format
cross-reference resolution), prereqRank=0 or > the prereq
talent's maxRank (catches off-by-one references), gaps in
rankSpellIds progression (rank N has spell but rank N-1
doesn't — usually a typo).
The validator caught a real authoring bug in the makeMage /
makeWarrior presets during smoke testing — initial check
was comparing prereqRank against the WRONG talent's maxRank
(this talent's rather than the prereq's). Fixed in the same
commit by hoisting the check into the cross-reference
resolution pass where the prereq talent is in hand.
2026-05-09 16:33:45 -07:00
|
|
|
int handleValidate(int& i, int argc, char** argv) {
|
|
|
|
|
std::string base = argv[++i];
|
|
|
|
|
bool jsonOut = consumeJsonFlag(i, argc, argv);
|
|
|
|
|
base = stripWtalExt(base);
|
|
|
|
|
if (!wowee::pipeline::WoweeTalentLoader::exists(base)) {
|
|
|
|
|
std::fprintf(stderr,
|
|
|
|
|
"validate-wtal: WTAL not found: %s.wtal\n", base.c_str());
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
auto c = wowee::pipeline::WoweeTalentLoader::load(base);
|
|
|
|
|
std::vector<std::string> errors;
|
|
|
|
|
std::vector<std::string> warnings;
|
|
|
|
|
if (c.trees.empty()) {
|
|
|
|
|
warnings.push_back("catalog has zero trees");
|
|
|
|
|
}
|
|
|
|
|
std::vector<uint32_t> treeIdsSeen;
|
|
|
|
|
std::vector<uint32_t> talentIdsSeen;
|
|
|
|
|
for (size_t k = 0; k < c.trees.size(); ++k) {
|
|
|
|
|
const auto& t = c.trees[k];
|
|
|
|
|
std::string ctx = "tree " + std::to_string(k) +
|
|
|
|
|
" (id=" + std::to_string(t.treeId);
|
|
|
|
|
if (!t.name.empty()) ctx += " " + t.name;
|
|
|
|
|
ctx += ")";
|
|
|
|
|
if (t.treeId == 0) errors.push_back(ctx + ": treeId is 0");
|
|
|
|
|
if (t.name.empty()) errors.push_back(ctx + ": name is empty");
|
|
|
|
|
if (t.requiredClassMask == 0) {
|
|
|
|
|
warnings.push_back(ctx +
|
|
|
|
|
": requiredClassMask=0 (every class would see this tree)");
|
|
|
|
|
}
|
|
|
|
|
for (uint32_t prev : treeIdsSeen) {
|
|
|
|
|
if (prev == t.treeId) {
|
|
|
|
|
errors.push_back(ctx + ": duplicate treeId");
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
treeIdsSeen.push_back(t.treeId);
|
|
|
|
|
for (size_t ti = 0; ti < t.talents.size(); ++ti) {
|
|
|
|
|
const auto& a = t.talents[ti];
|
|
|
|
|
std::string actx = ctx + " talent " + std::to_string(ti) +
|
|
|
|
|
" (id=" + std::to_string(a.talentId) + ")";
|
|
|
|
|
if (a.talentId == 0) {
|
|
|
|
|
errors.push_back(actx + ": talentId is 0");
|
|
|
|
|
}
|
|
|
|
|
if (a.maxRank == 0 ||
|
|
|
|
|
a.maxRank > wowee::pipeline::WoweeTalent::kMaxRanks) {
|
|
|
|
|
errors.push_back(actx + ": maxRank " +
|
|
|
|
|
std::to_string(a.maxRank) + " not in 1..5");
|
|
|
|
|
}
|
|
|
|
|
// prereqRank check moved into the second pass below
|
|
|
|
|
// where we have the prereq talent in hand to compare
|
|
|
|
|
// against its actual maxRank.
|
|
|
|
|
if (a.prereqTalentId == a.talentId) {
|
|
|
|
|
errors.push_back(actx +
|
|
|
|
|
": talent lists itself as prerequisite");
|
|
|
|
|
}
|
|
|
|
|
// Active spell talents typically have rankSpellIds[0]
|
|
|
|
|
// set even at rank 1 — a passive (stat-modifier) talent
|
|
|
|
|
// may legitimately leave them all 0. Just check for
|
|
|
|
|
// ascending non-zero ordering: if rank N has a spell,
|
|
|
|
|
// rank N-1 should too.
|
|
|
|
|
for (int r = 1; r < wowee::pipeline::WoweeTalent::kMaxRanks; ++r) {
|
|
|
|
|
if (a.rankSpellIds[r] != 0 && a.rankSpellIds[r - 1] == 0) {
|
|
|
|
|
warnings.push_back(actx +
|
|
|
|
|
": rankSpellIds[" + std::to_string(r) +
|
|
|
|
|
"] set but rank " + std::to_string(r - 1) +
|
|
|
|
|
" is empty (gap in rank progression)");
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (uint32_t prev : talentIdsSeen) {
|
|
|
|
|
if (prev == a.talentId) {
|
|
|
|
|
errors.push_back(actx + ": duplicate talentId");
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
talentIdsSeen.push_back(a.talentId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Second pass: verify prereqTalentId references resolve
|
|
|
|
|
// and prereqRank fits within the prereq talent's own
|
|
|
|
|
// maxRank.
|
|
|
|
|
for (const auto& t : c.trees) {
|
|
|
|
|
for (const auto& a : t.talents) {
|
|
|
|
|
if (a.prereqTalentId == 0) continue;
|
|
|
|
|
const auto* prereq = c.findTalent(a.prereqTalentId);
|
|
|
|
|
if (!prereq) {
|
|
|
|
|
errors.push_back("talent " + std::to_string(a.talentId) +
|
|
|
|
|
": prereqTalentId " +
|
|
|
|
|
std::to_string(a.prereqTalentId) +
|
|
|
|
|
" does not exist in this catalog");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (a.prereqRank == 0 || a.prereqRank > prereq->maxRank) {
|
|
|
|
|
errors.push_back("talent " + std::to_string(a.talentId) +
|
|
|
|
|
": prereqRank " + std::to_string(a.prereqRank) +
|
|
|
|
|
" not in 1.." + std::to_string(prereq->maxRank) +
|
|
|
|
|
" (prereq talent's maxRank)");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
bool ok = errors.empty();
|
|
|
|
|
if (jsonOut) {
|
|
|
|
|
nlohmann::json j;
|
|
|
|
|
j["wtal"] = base + ".wtal";
|
|
|
|
|
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-wtal: %s.wtal\n", base.c_str());
|
|
|
|
|
if (ok && warnings.empty()) {
|
|
|
|
|
std::printf(" OK — %zu trees, %u talents, all IDs unique\n",
|
|
|
|
|
c.trees.size(), totalTalents(c));
|
|
|
|
|
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 handleTalentsCatalog(int& i, int argc, char** argv, int& outRc) {
|
|
|
|
|
if (std::strcmp(argv[i], "--gen-talents") == 0 && i + 1 < argc) {
|
|
|
|
|
outRc = handleGenStarter(i, argc, argv); return true;
|
|
|
|
|
}
|
|
|
|
|
if (std::strcmp(argv[i], "--gen-talents-warrior") == 0 && i + 1 < argc) {
|
|
|
|
|
outRc = handleGenWarrior(i, argc, argv); return true;
|
|
|
|
|
}
|
|
|
|
|
if (std::strcmp(argv[i], "--gen-talents-mage") == 0 && i + 1 < argc) {
|
|
|
|
|
outRc = handleGenMage(i, argc, argv); return true;
|
|
|
|
|
}
|
|
|
|
|
if (std::strcmp(argv[i], "--info-wtal") == 0 && i + 1 < argc) {
|
|
|
|
|
outRc = handleInfo(i, argc, argv); return true;
|
|
|
|
|
}
|
|
|
|
|
if (std::strcmp(argv[i], "--validate-wtal") == 0 && i + 1 < argc) {
|
|
|
|
|
outRc = handleValidate(i, argc, argv); return true;
|
|
|
|
|
}
|
2026-05-09 16:41:37 -07:00
|
|
|
if (std::strcmp(argv[i], "--export-wtal-json") == 0 && i + 1 < argc) {
|
|
|
|
|
outRc = handleExportJson(i, argc, argv); return true;
|
|
|
|
|
}
|
|
|
|
|
if (std::strcmp(argv[i], "--import-wtal-json") == 0 && i + 1 < argc) {
|
|
|
|
|
outRc = handleImportJson(i, argc, argv); return true;
|
|
|
|
|
}
|
feat(pipeline): add WTAL (Wowee Talent catalog) format
Novel open replacement for Blizzard's TalentTab.dbc +
Talent.dbc + the AzerothCore-style talent_progression SQL
tables. The 25th open format added to the editor.
Defines class talent specialization trees: per-class set
of named tabs (Arms / Fury / Protection for warrior, Fire
/ Frost / Arcane for mage), each with talents arranged in
a row/column grid, each talent having up to 5 ranks and
an optional prerequisite chain.
Cross-references with previously-added formats:
WTAL.talent.prereqTalentId -> WTAL.talent.talentId
(intra-format chain)
WTAL.talent.rankSpellIds[] -> WSPL.entry.spellId
(spell granted at each rank)
Format:
• magic "WTAL", version 1, little-endian
• per tree: treeId / name / iconPath / requiredClassMask /
talents[] (row, col, maxRank, prereqTalentId+rank,
rankSpellIds[5] zero-padded for unused ranks)
Enums:
• ClassMask: bit positions match canonical CharClasses.dbc
classIds — Warrior / Paladin / Hunter / Rogue / Priest /
DK / Shaman / Mage / Warlock / Druid
API: WoweeTalentLoader::save / load / exists +
WoweeTalent::findTree / findTalent (global lookup across
all trees in the catalog).
Three preset emitters showcase tree shapes:
• makeStarter — 1 small tree (3-talent vertical chain)
• makeWarrior — 3 trees (Arms 4 / Fury 4 / Protection 3)
with WSPL cross-refs at capstones
(Mortal Strike -> WSPL 12294, Battle Shout
-> WSPL 6673, Thunder Clap -> WSPL 6343)
• makeMage — 3 trees (Arcane / Fire / Frost) with
capstones referencing Frostbolt 116 /
Fireball 133 / Blink 1953 from WSPL
CLI added (5 flags, 571 documented total now):
--gen-talents / --gen-talents-warrior / --gen-talents-mage
--info-wtal / --validate-wtal
Validator catches: tree+talent ids=0 or duplicates, empty
tree name, requiredClassMask=0 (every class would see this
tree — usually a typo), maxRank not in 1..5, talent listing
itself as prerequisite, prereqTalentId pointing at a
talent that doesn't exist in this catalog (intra-format
cross-reference resolution), prereqRank=0 or > the prereq
talent's maxRank (catches off-by-one references), gaps in
rankSpellIds progression (rank N has spell but rank N-1
doesn't — usually a typo).
The validator caught a real authoring bug in the makeMage /
makeWarrior presets during smoke testing — initial check
was comparing prereqRank against the WRONG talent's maxRank
(this talent's rather than the prereq's). Fixed in the same
commit by hoisting the check into the cross-reference
resolution pass where the prereq talent is in hand.
2026-05-09 16:33:45 -07:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace cli
|
|
|
|
|
} // namespace editor
|
|
|
|
|
} // namespace wowee
|