feat(editor): add WBAB (Buff & Aura Book) — 102nd open format

Novel replacement for the implicit rank-chain
relationships that vanilla WoW encoded by burying
nextRank/prevRank pointers inside Spell.dbc with no
explicit graph structure. Each WBAB entry is one long-
duration class buff at one specific rank, with explicit
edges to adjacent ranks via previousRankId and
nextRankId fields. The graph-shaped data is novel among
the 100+ catalog set: most catalogs have flat rows; WBAB
is genuinely a graph where rows are nodes and the rank
fields are edges.

Both directions are stored explicitly so the spellbook
UI's "upgrade to next rank" button can traverse without
scanning the full table. Helper methods walkChainBack-
ToRoot() returns the full chain root->tip for the rank-
picker widget; findChainTip() returns the highest rank
for auto-cast logic.

Three preset emitters demonstrating the pattern:
makeMage (Arcane Intellect ranks 1-4 with chain edges),
makeDruid (Mark of the Wild ranks 1-5 with chain edges),
makeRaidMax (6 max-rank standalone raid buffs — one per
buffing class — with no chain edges to show the
standalone case).

Validator catches several rank-chain-specific bugs:
self-referencing edges (entry.next == entry.id would
create a 1-element cycle), missing referenced entries
(next/prev pointing to non-existent ids), and most
importantly back-edge symmetry — if A.nextRankId=B then
B.previousRankId MUST equal A.buffId or the spellbook
upgrade traversal will derail. Symmetric back-edge check
is unique to graph-shaped catalogs.

Also fixed a crash in --catalog-find where the recursive
directory iterator threw on permission-denied subdirs
(common when walking /tmp). Now uses the
skip_permission_denied directory_options + per-step
error_code clearing for defensive resumption.

Format count 101 -> 102. CLI flag count 1134 -> 1139.
This commit is contained in:
Kelsi 2026-05-10 01:13:42 -07:00
parent 471ddfef07
commit abf264abfe
11 changed files with 883 additions and 3 deletions

View file

@ -690,6 +690,7 @@ set(WOWEE_SOURCES
src/pipeline/wowee_combat_maneuvers.cpp
src/pipeline/wowee_realm_list.cpp
src/pipeline/wowee_emotes.cpp
src/pipeline/wowee_buff_book.cpp
src/pipeline/custom_zone_discovery.cpp
src/pipeline/dbc_layout.cpp
@ -1543,6 +1544,7 @@ add_executable(wowee_editor
tools/editor/cli_combat_maneuvers_catalog.cpp
tools/editor/cli_realm_list_catalog.cpp
tools/editor/cli_emotes_catalog.cpp
tools/editor/cli_buff_book_catalog.cpp
tools/editor/cli_catalog_pluck.cpp
tools/editor/cli_catalog_find.cpp
tools/editor/cli_quest_objective.cpp
@ -1713,6 +1715,7 @@ add_executable(wowee_editor
src/pipeline/wowee_combat_maneuvers.cpp
src/pipeline/wowee_realm_list.cpp
src/pipeline/wowee_emotes.cpp
src/pipeline/wowee_buff_book.cpp
src/pipeline/custom_zone_discovery.cpp
src/pipeline/terrain_mesh.cpp

View file

@ -0,0 +1,154 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace wowee {
namespace pipeline {
// Wowee Open Buff & Aura Book catalog (.wbab) — novel
// replacement for the implicit rank-chain relationships
// that vanilla WoW encoded by burying nextRank/prevRank
// pointers inside Spell.dbc. Each entry is one long-
// duration class buff (Mark of the Wild, Arcane
// Intellect, Power Word: Fortitude, Battle Shout, etc.)
// at one specific rank, with explicit edges to the
// previous and next ranks via previousRankId /
// nextRankId.
//
// The rank-chain pattern is novel among the catalog set:
// most catalogs have flat entries (one row per concept);
// WBAB is a graph where rows are nodes connected by edge
// fields. Both directions are stored explicitly so the
// rank-step UI ("upgrade to next rank" button) can
// traverse without scanning the full table.
//
// Cross-references with previously-added formats:
// WSPL: spellId references the WSPL spell catalog (the
// actual spell that gets cast).
// WCHC: castClassMask uses the WCHC class-bit
// convention.
// WBAB: previousRankId / nextRankId reference OTHER
// entries in the same WBAB catalog — internal
// self-reference for the rank chain. Validator
// can check the back-edges (if A.next=B then
// B.prev should = A).
//
// Binary layout (little-endian):
// magic[4] = "WBAB"
// version (uint32) = current 1
// nameLen + name (catalog label)
// entryCount (uint32)
// entries (each):
// buffId (uint32)
// nameLen + name
// descLen + description
// spellId (uint32)
// castClassMask (uint32)
// targetTypeMask (uint8) — Self / Party / Raid /
// Friendly bitmask
// statBonusKind (uint8) — Stamina / Intellect /
// Spirit / AllStats /
// Armor / SpellPower /
// AttackPower / Crit /
// Haste / Mastery
// rank (uint8) — 1-based rank number
// maxStackCount (uint8) — typically 1
// statBonusAmount (int32) — signed magnitude
// (negative = debuff)
// duration (uint32) — seconds (0 = until
// cancel / log out)
// previousRankId (uint32) — 0 if rank 1
// nextRankId (uint32) — 0 if max rank
// iconColorRGBA (uint32)
struct WoweeBuffBook {
enum TargetTypeBit : uint8_t {
TargetSelf = 0x01,
TargetParty = 0x02,
TargetRaid = 0x04,
TargetFriendly = 0x08, // any friendly target
// including outside party
};
enum StatBonusKind : uint8_t {
Stamina = 0,
Intellect = 1,
Spirit = 2,
AllStats = 3,
Armor = 4,
SpellPower = 5,
AttackPower = 6,
CritRating = 7,
HasteRating = 8,
ManaRegen = 9,
Other = 255, // not statted (e.g.
// Trueshot Aura is %DPS,
// not a flat stat)
};
struct Entry {
uint32_t buffId = 0;
std::string name;
std::string description;
uint32_t spellId = 0;
uint32_t castClassMask = 0;
uint8_t targetTypeMask = TargetSelf | TargetParty;
uint8_t statBonusKind = Other;
uint8_t rank = 1;
uint8_t maxStackCount = 1;
int32_t statBonusAmount = 0;
uint32_t duration = 0;
uint32_t previousRankId = 0;
uint32_t nextRankId = 0;
uint32_t iconColorRGBA = 0xFFFFFFFFu;
};
std::string name;
std::vector<Entry> entries;
bool isValid() const { return !entries.empty(); }
const Entry* findById(uint32_t buffId) const;
// Walks the rank chain from buffId all the way to
// rank 1, returning entries from first to last. Used
// by the spellbook UI's "rank picker" widget which
// shows all ranks the player can currently train.
std::vector<const Entry*>
walkChainBackToRoot(uint32_t buffId) const;
// Returns the highest rank in the chain starting from
// buffId (the entry with nextRankId=0 reachable from
// here). Used by the auto-cast logic to always apply
// the highest rank the caster knows.
const Entry* findChainTip(uint32_t buffId) const;
};
class WoweeBuffBookLoader {
public:
static bool save(const WoweeBuffBook& cat,
const std::string& basePath);
static WoweeBuffBook load(const std::string& basePath);
static bool exists(const std::string& basePath);
// Preset emitters used by --gen-bab* variants.
//
// makeMage — 4 entries: Arcane Intellect ranks
// 1-4 with explicit rank chain.
// makeDruid — 5 entries: Mark of the Wild ranks
// 1-5 with explicit rank chain.
// makeRaidMax — 6 entries: one max-rank buff per
// buffing class (Mark of the Wild
// R7, Power Word: Fortitude R8,
// Arcane Intellect R6, Blessing
// of Kings R1, Battle Shout R9,
// Trueshot Aura R3) — no chain
// edges since each is standalone.
static WoweeBuffBook makeMage(const std::string& catalogName);
static WoweeBuffBook makeDruid(const std::string& catalogName);
static WoweeBuffBook makeRaidMax(const std::string& catalogName);
};
} // namespace pipeline
} // namespace wowee

View file

@ -0,0 +1,327 @@
#include "pipeline/wowee_buff_book.hpp"
#include <cstdio>
#include <cstring>
#include <fstream>
#include <unordered_set>
namespace wowee {
namespace pipeline {
namespace {
constexpr char kMagic[4] = {'W', 'B', 'A', 'B'};
constexpr uint32_t kVersion = 1;
template <typename T>
void writePOD(std::ofstream& os, const T& v) {
os.write(reinterpret_cast<const char*>(&v), sizeof(T));
}
template <typename T>
bool readPOD(std::ifstream& is, T& v) {
is.read(reinterpret_cast<char*>(&v), sizeof(T));
return is.gcount() == static_cast<std::streamsize>(sizeof(T));
}
void writeStr(std::ofstream& os, const std::string& s) {
uint32_t n = static_cast<uint32_t>(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<std::streamsize>(n)) {
s.clear();
return false;
}
}
return true;
}
std::string normalizePath(std::string base) {
if (base.size() < 5 || base.substr(base.size() - 5) != ".wbab") {
base += ".wbab";
}
return base;
}
uint32_t packRgba(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 0xFF) {
return (static_cast<uint32_t>(a) << 24) |
(static_cast<uint32_t>(b) << 16) |
(static_cast<uint32_t>(g) << 8) |
static_cast<uint32_t>(r);
}
} // namespace
const WoweeBuffBook::Entry*
WoweeBuffBook::findById(uint32_t buffId) const {
for (const auto& e : entries)
if (e.buffId == buffId) return &e;
return nullptr;
}
std::vector<const WoweeBuffBook::Entry*>
WoweeBuffBook::walkChainBackToRoot(uint32_t buffId) const {
std::vector<const Entry*> out;
std::unordered_set<uint32_t> visited;
const Entry* cur = findById(buffId);
while (cur != nullptr && visited.insert(cur->buffId).second) {
out.push_back(cur);
if (cur->previousRankId == 0) break;
cur = findById(cur->previousRankId);
}
// Reverse so output flows root → tip.
for (size_t a = 0, b = out.size(); a + 1 < b; ++a, --b) {
std::swap(out[a], out[b - 1]);
}
return out;
}
const WoweeBuffBook::Entry*
WoweeBuffBook::findChainTip(uint32_t buffId) const {
const Entry* cur = findById(buffId);
std::unordered_set<uint32_t> visited;
while (cur != nullptr && cur->nextRankId != 0 &&
visited.insert(cur->buffId).second) {
cur = findById(cur->nextRankId);
}
return cur;
}
bool WoweeBuffBookLoader::save(const WoweeBuffBook& 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<uint32_t>(cat.entries.size());
writePOD(os, entryCount);
for (const auto& e : cat.entries) {
writePOD(os, e.buffId);
writeStr(os, e.name);
writeStr(os, e.description);
writePOD(os, e.spellId);
writePOD(os, e.castClassMask);
writePOD(os, e.targetTypeMask);
writePOD(os, e.statBonusKind);
writePOD(os, e.rank);
writePOD(os, e.maxStackCount);
writePOD(os, e.statBonusAmount);
writePOD(os, e.duration);
writePOD(os, e.previousRankId);
writePOD(os, e.nextRankId);
writePOD(os, e.iconColorRGBA);
}
return os.good();
}
WoweeBuffBook WoweeBuffBookLoader::load(const std::string& basePath) {
WoweeBuffBook 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.buffId)) {
out.entries.clear(); return out;
}
if (!readStr(is, e.name) || !readStr(is, e.description)) {
out.entries.clear(); return out;
}
if (!readPOD(is, e.spellId) ||
!readPOD(is, e.castClassMask) ||
!readPOD(is, e.targetTypeMask) ||
!readPOD(is, e.statBonusKind) ||
!readPOD(is, e.rank) ||
!readPOD(is, e.maxStackCount) ||
!readPOD(is, e.statBonusAmount) ||
!readPOD(is, e.duration) ||
!readPOD(is, e.previousRankId) ||
!readPOD(is, e.nextRankId) ||
!readPOD(is, e.iconColorRGBA)) {
out.entries.clear(); return out;
}
}
return out;
}
bool WoweeBuffBookLoader::exists(const std::string& basePath) {
std::ifstream is(normalizePath(basePath), std::ios::binary);
return is.good();
}
WoweeBuffBook WoweeBuffBookLoader::makeMage(
const std::string& catalogName) {
using B = WoweeBuffBook;
WoweeBuffBook c;
c.name = catalogName;
auto add = [&](uint32_t id, const char* name,
uint32_t spellId, uint8_t rank,
int32_t statAmount, uint32_t prevId,
uint32_t nextId, const char* desc) {
B::Entry e;
e.buffId = id; e.name = name; e.description = desc;
e.spellId = spellId;
e.castClassMask = 128; // Mage
e.targetTypeMask = B::TargetSelf | B::TargetParty;
e.statBonusKind = B::Intellect;
e.rank = rank;
e.maxStackCount = 1;
e.statBonusAmount = statAmount;
e.duration = 1800; // 30 minutes
e.previousRankId = prevId;
e.nextRankId = nextId;
e.iconColorRGBA = packRgba(140, 200, 255); // mage blue
c.entries.push_back(e);
};
// Arcane Intellect rank chain — spell IDs from
// Spell.dbc 3.3.5a; intellect bonus per rank.
add(1, "ArcaneIntellect_R1", 1459, 1, 3, 0, 2,
"Arcane Intellect Rank 1 — +3 Intellect, "
"30 min party-wide. Trained at level 8.");
add(2, "ArcaneIntellect_R2", 1460, 2, 7, 1, 3,
"Arcane Intellect Rank 2 — +7 Intellect. "
"Trained at level 22.");
add(3, "ArcaneIntellect_R3", 1461, 3, 15, 2, 4,
"Arcane Intellect Rank 3 — +15 Intellect. "
"Trained at level 36.");
add(4, "ArcaneIntellect_R4", 10157, 4, 25, 3, 0,
"Arcane Intellect Rank 4 — +25 Intellect. "
"Trained at level 50. (Max rank in this preset; "
"real WoTLK has higher ranks via Brilliance "
"variant.)");
return c;
}
WoweeBuffBook WoweeBuffBookLoader::makeDruid(
const std::string& catalogName) {
using B = WoweeBuffBook;
WoweeBuffBook c;
c.name = catalogName;
auto add = [&](uint32_t id, const char* name,
uint32_t spellId, uint8_t rank,
int32_t statAmount, uint32_t prevId,
uint32_t nextId, const char* desc) {
B::Entry e;
e.buffId = id; e.name = name; e.description = desc;
e.spellId = spellId;
e.castClassMask = 1024; // Druid
e.targetTypeMask = B::TargetSelf |
B::TargetParty |
B::TargetFriendly;
e.statBonusKind = B::AllStats;
e.rank = rank;
e.maxStackCount = 1;
e.statBonusAmount = statAmount;
e.duration = 1800;
e.previousRankId = prevId;
e.nextRankId = nextId;
e.iconColorRGBA = packRgba(255, 125, 10); // druid orange
c.entries.push_back(e);
};
add(100, "MarkOfTheWild_R1", 1126, 1, 3, 0, 101,
"Mark of the Wild Rank 1 — +3 to all stats. "
"Trained at level 1.");
add(101, "MarkOfTheWild_R2", 5232, 2, 6, 100, 102,
"Mark of the Wild Rank 2 — +6 to all stats. "
"Trained at level 10.");
add(102, "MarkOfTheWild_R3", 6756, 3, 10, 101, 103,
"Mark of the Wild Rank 3 — +10 to all stats. "
"Trained at level 20.");
add(103, "MarkOfTheWild_R4", 5234, 4, 14, 102, 104,
"Mark of the Wild Rank 4 — +14 to all stats. "
"Trained at level 30.");
add(104, "MarkOfTheWild_R5", 8907, 5, 18, 103, 0,
"Mark of the Wild Rank 5 — +18 to all stats. "
"Trained at level 40. (Top rank in this preset.)");
return c;
}
WoweeBuffBook WoweeBuffBookLoader::makeRaidMax(
const std::string& catalogName) {
using B = WoweeBuffBook;
WoweeBuffBook c;
c.name = catalogName;
auto add = [&](uint32_t id, const char* name,
uint32_t spellId, uint32_t classMask,
uint8_t targetMask, uint8_t statKind,
uint8_t rank, int32_t statAmount,
uint32_t duration, uint32_t color,
const char* desc) {
B::Entry e;
e.buffId = id; e.name = name; e.description = desc;
e.spellId = spellId;
e.castClassMask = classMask;
e.targetTypeMask = targetMask;
e.statBonusKind = statKind;
e.rank = rank;
e.maxStackCount = 1;
e.statBonusAmount = statAmount;
e.duration = duration;
// No rank chain — these are max-rank standalone
// entries pulled from each class's top buff.
e.previousRankId = 0;
e.nextRankId = 0;
e.iconColorRGBA = color;
c.entries.push_back(e);
};
add(200, "MarkOfTheWild_Max", 26990, 1024,
B::TargetSelf | B::TargetRaid, B::AllStats,
7, 35, 1800,
packRgba(255, 125, 10),
"Druid raid buff — Mark of the Wild rank 7, "
"+35 to all stats, 30min, raid-wide.");
add(201, "PowerWordFortitude_Max", 25389, 16,
B::TargetSelf | B::TargetRaid, B::Stamina,
8, 79, 1800,
packRgba(255, 255, 255),
"Priest raid buff — Prayer of Fortitude rank 4, "
"+79 Stamina, 60min, raid-wide.");
add(202, "ArcaneIntellect_Max", 27126, 128,
B::TargetSelf | B::TargetRaid, B::Intellect,
6, 60, 1800,
packRgba(140, 200, 255),
"Mage raid buff — Arcane Brilliance rank 2, "
"+60 Intellect, 60min, raid-wide.");
add(203, "BlessingOfKings", 25898, 2,
B::TargetSelf | B::TargetRaid, B::AllStats,
1, 10, 1800,
packRgba(220, 220, 100),
"Paladin raid buff — Greater Blessing of Kings, "
"+10% to all stats, 60min, raid-wide.");
add(204, "BattleShout_Max", 47436, 1,
B::TargetSelf | B::TargetParty, B::AttackPower,
9, 553, 120,
packRgba(220, 60, 60),
"Warrior raid buff — Battle Shout rank 9, "
"+553 Attack Power, 2min, party-wide.");
add(205, "TrueshotAura_Max", 19506, 4,
B::TargetSelf | B::TargetRaid, B::Other,
3, 0, 0,
packRgba(170, 210, 100),
"Hunter raid buff — Trueshot Aura, +10% AP "
"for all party/raid (until cancel). statKind="
"Other because it's a percentage modifier, not a "
"flat stat.");
return c;
}
} // namespace pipeline
} // namespace wowee

View file

@ -313,6 +313,8 @@ const char* const kArgRequired[] = {
"--gen-emo", "--gen-emo-combat", "--gen-emo-rp",
"--info-wemo", "--validate-wemo",
"--export-wemo-json", "--import-wemo-json",
"--gen-bab", "--gen-bab-druid", "--gen-bab-raid",
"--info-wbab", "--validate-wbab",
"--gen-weather-temperate", "--gen-weather-arctic",
"--gen-weather-desert", "--gen-weather-stormy",
"--gen-zone-atmosphere",

View file

@ -0,0 +1,341 @@
#include "cli_buff_book_catalog.hpp"
#include "cli_arg_parse.hpp"
#include "cli_box_emitter.hpp"
#include "pipeline/wowee_buff_book.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <set>
#include <string>
#include <vector>
namespace wowee {
namespace editor {
namespace cli {
namespace {
std::string stripWbabExt(std::string base) {
stripExt(base, ".wbab");
return base;
}
const char* statBonusKindName(uint8_t k) {
using B = wowee::pipeline::WoweeBuffBook;
switch (k) {
case B::Stamina: return "stamina";
case B::Intellect: return "intellect";
case B::Spirit: return "spirit";
case B::AllStats: return "allstats";
case B::Armor: return "armor";
case B::SpellPower: return "spellpower";
case B::AttackPower: return "attackpower";
case B::CritRating: return "critrating";
case B::HasteRating: return "hasterating";
case B::ManaRegen: return "manaregen";
case B::Other: return "other";
default: return "unknown";
}
}
std::string targetMaskString(uint8_t m) {
using B = wowee::pipeline::WoweeBuffBook;
std::string out;
auto add = [&](const char* tag) {
if (!out.empty()) out += "+";
out += tag;
};
if (m & B::TargetSelf) add("self");
if (m & B::TargetParty) add("party");
if (m & B::TargetRaid) add("raid");
if (m & B::TargetFriendly) add("friendly");
if (out.empty()) out = "none";
return out;
}
bool saveOrError(const wowee::pipeline::WoweeBuffBook& c,
const std::string& base, const char* cmd) {
if (!wowee::pipeline::WoweeBuffBookLoader::save(c, base)) {
std::fprintf(stderr, "%s: failed to save %s.wbab\n",
cmd, base.c_str());
return false;
}
return true;
}
void printGenSummary(const wowee::pipeline::WoweeBuffBook& c,
const std::string& base) {
std::printf("Wrote %s.wbab\n", base.c_str());
std::printf(" catalog : %s\n", c.name.c_str());
std::printf(" buffs : %zu\n", c.entries.size());
}
int handleGenMage(int& i, int argc, char** argv) {
std::string base = argv[++i];
std::string name = "MageBuffBook";
if (parseOptArg(i, argc, argv)) name = argv[++i];
base = stripWbabExt(base);
auto c = wowee::pipeline::WoweeBuffBookLoader::makeMage(name);
if (!saveOrError(c, base, "gen-bab")) return 1;
printGenSummary(c, base);
return 0;
}
int handleGenDruid(int& i, int argc, char** argv) {
std::string base = argv[++i];
std::string name = "DruidBuffBook";
if (parseOptArg(i, argc, argv)) name = argv[++i];
base = stripWbabExt(base);
auto c = wowee::pipeline::WoweeBuffBookLoader::makeDruid(name);
if (!saveOrError(c, base, "gen-bab-druid")) return 1;
printGenSummary(c, base);
return 0;
}
int handleGenRaidMax(int& i, int argc, char** argv) {
std::string base = argv[++i];
std::string name = "RaidMaxBuffs";
if (parseOptArg(i, argc, argv)) name = argv[++i];
base = stripWbabExt(base);
auto c = wowee::pipeline::WoweeBuffBookLoader::makeRaidMax(name);
if (!saveOrError(c, base, "gen-bab-raid")) 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 = stripWbabExt(base);
if (!wowee::pipeline::WoweeBuffBookLoader::exists(base)) {
std::fprintf(stderr, "WBAB not found: %s.wbab\n", base.c_str());
return 1;
}
auto c = wowee::pipeline::WoweeBuffBookLoader::load(base);
if (jsonOut) {
nlohmann::json j;
j["wbab"] = base + ".wbab";
j["name"] = c.name;
j["count"] = c.entries.size();
nlohmann::json arr = nlohmann::json::array();
for (const auto& e : c.entries) {
arr.push_back({
{"buffId", e.buffId},
{"name", e.name},
{"description", e.description},
{"spellId", e.spellId},
{"castClassMask", e.castClassMask},
{"targetTypeMask", e.targetTypeMask},
{"targetTypeNames", targetMaskString(e.targetTypeMask)},
{"statBonusKind", e.statBonusKind},
{"statBonusKindName",
statBonusKindName(e.statBonusKind)},
{"rank", e.rank},
{"maxStackCount", e.maxStackCount},
{"statBonusAmount", e.statBonusAmount},
{"duration", e.duration},
{"previousRankId", e.previousRankId},
{"nextRankId", e.nextRankId},
{"iconColorRGBA", e.iconColorRGBA},
});
}
j["entries"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("WBAB: %s.wbab\n", base.c_str());
std::printf(" catalog : %s\n", c.name.c_str());
std::printf(" buffs : %zu\n", c.entries.size());
if (c.entries.empty()) return 0;
std::printf(" id spell class tgt stat rk amt dur(s) prev next name\n");
for (const auto& e : c.entries) {
std::printf(" %4u %5u %4u %-15s %-11s %2u %4d %5u %4u %4u %s\n",
e.buffId, e.spellId, e.castClassMask,
targetMaskString(e.targetTypeMask).c_str(),
statBonusKindName(e.statBonusKind),
e.rank, e.statBonusAmount, e.duration,
e.previousRankId, e.nextRankId,
e.name.c_str());
}
return 0;
}
int handleValidate(int& i, int argc, char** argv) {
std::string base = argv[++i];
bool jsonOut = consumeJsonFlag(i, argc, argv);
base = stripWbabExt(base);
if (!wowee::pipeline::WoweeBuffBookLoader::exists(base)) {
std::fprintf(stderr,
"validate-wbab: WBAB not found: %s.wbab\n",
base.c_str());
return 1;
}
auto c = wowee::pipeline::WoweeBuffBookLoader::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> idsSeen;
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.buffId);
if (!e.name.empty()) ctx += " " + e.name;
ctx += ")";
if (e.buffId == 0)
errors.push_back(ctx + ": buffId is 0");
if (e.name.empty())
errors.push_back(ctx + ": name is empty");
if (e.spellId == 0) {
errors.push_back(ctx +
": spellId is 0 — buff has no spell to "
"cast");
}
if (e.castClassMask == 0) {
errors.push_back(ctx +
": castClassMask is 0 — no class can cast "
"this buff");
}
if (e.targetTypeMask == 0) {
errors.push_back(ctx +
": targetTypeMask is 0 — buff has no valid "
"targets");
}
if (e.statBonusKind > 9 && e.statBonusKind != 255) {
errors.push_back(ctx + ": statBonusKind " +
std::to_string(e.statBonusKind) +
" out of range (must be 0..9 or 255 Other)");
}
if (e.rank == 0) {
warnings.push_back(ctx +
": rank is 0 — ranks are 1-indexed; rank 0 "
"may sort unexpectedly in spellbook UI");
}
if (e.maxStackCount == 0) {
warnings.push_back(ctx +
": maxStackCount=0 — buff cannot be applied "
"(zero stack ceiling)");
}
// Self-reference check: an entry's own id should
// never appear in its own next/previous fields.
if (e.previousRankId == e.buffId) {
errors.push_back(ctx +
": previousRankId equals buffId — would "
"create a 1-element rank cycle");
}
if (e.nextRankId == e.buffId) {
errors.push_back(ctx +
": nextRankId equals buffId — would create "
"a 1-element rank cycle");
}
if (!idsSeen.insert(e.buffId).second) {
errors.push_back(ctx + ": duplicate buffId");
}
}
// Cross-entry checks: validate the rank chain back-
// edges. If A.nextRankId = B then B.previousRankId
// must = A.buffId, and vice versa. Also detect
// chain cycles.
auto findIdx = [&](uint32_t id) -> int {
for (size_t k = 0; k < c.entries.size(); ++k) {
if (c.entries[k].buffId == id) {
return static_cast<int>(k);
}
}
return -1;
};
for (const auto& e : c.entries) {
if (e.nextRankId != 0) {
int next = findIdx(e.nextRankId);
if (next < 0) {
errors.push_back("entry id=" +
std::to_string(e.buffId) +
" (" + e.name + "): nextRankId=" +
std::to_string(e.nextRankId) +
" references missing entry");
} else if (c.entries[next].previousRankId !=
e.buffId) {
errors.push_back("rank chain back-edge "
"broken: id=" + std::to_string(e.buffId) +
" (" + e.name + ").nextRankId=" +
std::to_string(e.nextRankId) +
" but id=" + std::to_string(e.nextRankId) +
" (" + c.entries[next].name +
").previousRankId=" +
std::to_string(
c.entries[next].previousRankId) +
" (expected " +
std::to_string(e.buffId) + ")");
}
}
if (e.previousRankId != 0) {
int prev = findIdx(e.previousRankId);
if (prev < 0) {
errors.push_back("entry id=" +
std::to_string(e.buffId) +
" (" + e.name + "): previousRankId=" +
std::to_string(e.previousRankId) +
" references missing entry");
}
}
}
bool ok = errors.empty();
if (jsonOut) {
nlohmann::json j;
j["wbab"] = base + ".wbab";
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-wbab: %s.wbab\n", base.c_str());
if (ok && warnings.empty()) {
std::printf(" OK — %zu buffs, all buffIds unique, "
"rank chain back-edges symmetric\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 handleBuffBookCatalog(int& i, int argc, char** argv,
int& outRc) {
if (std::strcmp(argv[i], "--gen-bab") == 0 && i + 1 < argc) {
outRc = handleGenMage(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--gen-bab-druid") == 0 && i + 1 < argc) {
outRc = handleGenDruid(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--gen-bab-raid") == 0 && i + 1 < argc) {
outRc = handleGenRaidMax(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--info-wbab") == 0 && i + 1 < argc) {
outRc = handleInfo(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--validate-wbab") == 0 && i + 1 < argc) {
outRc = handleValidate(i, argc, argv); return true;
}
return false;
}
} // namespace cli
} // namespace editor
} // namespace wowee

View file

@ -0,0 +1,12 @@
#pragma once
namespace wowee {
namespace editor {
namespace cli {
bool handleBuffBookCatalog(int& i, int argc, char** argv,
int& outRc);
} // namespace cli
} // namespace editor
} // namespace wowee

View file

@ -191,9 +191,36 @@ int handleFind(int& i, int argc, char** argv) {
size_t skippedNoFlag = 0;
size_t skippedUnknownMagic = 0;
for (const auto& dirent :
fs::recursive_directory_iterator(dir)) {
if (!dirent.is_regular_file()) continue;
// skip_permission_denied prevents the iterator from
// throwing on unreadable subdirectories (common when
// walking /tmp or system trees that contain other-user
// files). Errors are swallowed silently — catalog-find
// is a best-effort search, not an audit.
std::error_code walkEc;
fs::recursive_directory_iterator it(
dir, fs::directory_options::skip_permission_denied,
walkEc);
fs::recursive_directory_iterator end;
if (walkEc) {
std::fprintf(stderr,
"catalog-find: cannot open directory '%s': %s\n",
dir.c_str(), walkEc.message().c_str());
return 1;
}
for (; it != end; it.increment(walkEc)) {
if (walkEc) {
// A subdirectory failed mid-walk; clear and
// continue. The skip_permission_denied option
// covers most cases but defensive code stays
// safer.
walkEc.clear();
continue;
}
const auto& dirent = *it;
if (!dirent.is_regular_file(walkEc)) {
walkEc.clear();
continue;
}
char magic[4]{};
if (!peekMagic(dirent.path(), magic)) continue;
const FormatMagicEntry* fmt = findFormatByMagic(magic);

View file

@ -146,6 +146,7 @@
#include "cli_combat_maneuvers_catalog.hpp"
#include "cli_realm_list_catalog.hpp"
#include "cli_emotes_catalog.hpp"
#include "cli_buff_book_catalog.hpp"
#include "cli_catalog_pluck.hpp"
#include "cli_catalog_find.hpp"
#include "cli_quest_objective.hpp"
@ -335,6 +336,7 @@ constexpr DispatchFn kDispatchTable[] = {
handleCombatManeuversCatalog,
handleRealmListCatalog,
handleEmotesCatalog,
handleBuffBookCatalog,
handleCatalogPluck,
handleCatalogFind,
handleQuestObjective,

View file

@ -104,6 +104,7 @@ constexpr FormatMagicEntry kFormats[] = {
{{'W','C','M','G'}, ".wcmg", "spells", "--info-wcmg", "Combat maneuver group catalog"},
{{'W','M','S','P'}, ".wmsp", "server", "--info-wmsp", "Master server profile / realmlist catalog"},
{{'W','E','M','O'}, ".wemo", "social", "--info-wemo", "Emote definition catalog"},
{{'W','B','A','B'}, ".wbab", "spells", "--info-wbab", "Buff & Aura book (rank chains)"},
{{'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"},

View file

@ -2181,6 +2181,16 @@ void printUsage(const char* argv0) {
std::printf(" Export binary .wemo to a human-editable JSON sidecar (defaults to <base>.wemo.json; emits all 3 enums as both int AND name string)\n");
std::printf(" --import-wemo-json <json-path> [out-base]\n");
std::printf(" Import a .wemo.json sidecar back into binary .wemo (emoteKind int OR \"social\"/\"combat\"/\"roleplay\"/\"system\"; sex int OR \"both\"/\"male\"/\"female\"; ttsHint int OR \"talk\"/\"whisper\"/\"yell\"/\"silent\")\n");
std::printf(" --gen-bab <wbab-base> [name]\n");
std::printf(" Emit .wbab 4 Mage Arcane Intellect rank chain (R1 +3 Int -> R4 +25 Int) with explicit prev/nextRankId edges\n");
std::printf(" --gen-bab-druid <wbab-base> [name]\n");
std::printf(" Emit .wbab 5 Druid Mark of the Wild rank chain (R1 +3 stats -> R5 +18 stats) with explicit prev/nextRankId edges\n");
std::printf(" --gen-bab-raid <wbab-base> [name]\n");
std::printf(" Emit .wbab 6 max-rank standalone raid buffs (Mark of the Wild R7 / Prayer of Fortitude R4 / Arcane Brilliance R2 / Greater Blessing of Kings / Battle Shout R9 / Trueshot Aura) — one per buffing class\n");
std::printf(" --info-wbab <wbab-base> [--json]\n");
std::printf(" Print WBAB entries (id / spellId / classMask / target mask / stat kind / rank / amount / duration / prev+next rank ids / name)\n");
std::printf(" --validate-wbab <wbab-base> [--json]\n");
std::printf(" Static checks: id+name+spellId+castClassMask+targetTypeMask required, statBonusKind 0..9 OR 255, no duplicate ids, no self-referencing rank edges, all next/prev IDs resolve to existing entries, AND back-edges symmetric (A.next=B implies B.prev=A); warns on rank=0, maxStackCount=0\n");
std::printf(" --catalog-pluck <wXXX-file> <id> [--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 <directory> <id> [--magic <WXXX>] [--json]\n");

View file

@ -126,6 +126,7 @@ constexpr FormatRow kFormats[] = {
{"WCMG", ".wcmg", "spells", "Stance/Form/Aspect mutex tables", "Combat maneuver group catalog (mutex spells)"},
{"WMSP", ".wmsp", "server", "realmlist + SMSG_REALM_LIST data", "Master server profile / realmlist catalog"},
{"WEMO", ".wemo", "social", "EmotesText.dbc + EmotesTextSound", "Emote definition catalog (/dance, /wave, etc.)"},
{"WBAB", ".wbab", "spells", "Spell.dbc nextRank/prevRank ptrs", "Buff & Aura book — long-duration class buffs with rank chains"},
// Additional pipeline catalogs without the alternating
// gen/info/validate CLI surface (loaded by the engine