diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ddc870a..7102a26b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/include/pipeline/wowee_buff_book.hpp b/include/pipeline/wowee_buff_book.hpp new file mode 100644 index 00000000..296e034b --- /dev/null +++ b/include/pipeline/wowee_buff_book.hpp @@ -0,0 +1,154 @@ +#pragma once + +#include +#include +#include + +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 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 + 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 diff --git a/src/pipeline/wowee_buff_book.cpp b/src/pipeline/wowee_buff_book.cpp new file mode 100644 index 00000000..44b7acb9 --- /dev/null +++ b/src/pipeline/wowee_buff_book.cpp @@ -0,0 +1,327 @@ +#include "pipeline/wowee_buff_book.hpp" + +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'B', 'A', 'B'}; +constexpr uint32_t kVersion = 1; + +template +void writePOD(std::ofstream& os, const T& v) { + os.write(reinterpret_cast(&v), sizeof(T)); +} + +template +bool readPOD(std::ifstream& is, T& v) { + is.read(reinterpret_cast(&v), sizeof(T)); + return is.gcount() == static_cast(sizeof(T)); +} + +void writeStr(std::ofstream& os, const std::string& s) { + uint32_t n = static_cast(s.size()); + writePOD(os, n); + if (n > 0) os.write(s.data(), n); +} + +bool readStr(std::ifstream& is, std::string& s) { + uint32_t n = 0; + if (!readPOD(is, n)) return false; + if (n > (1u << 20)) return false; + s.resize(n); + if (n > 0) { + is.read(s.data(), n); + if (is.gcount() != static_cast(n)) { + s.clear(); + return false; + } + } + return true; +} + +std::string normalizePath(std::string base) { + if (base.size() < 5 || base.substr(base.size() - 5) != ".wbab") { + base += ".wbab"; + } + return base; +} + +uint32_t packRgba(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 0xFF) { + return (static_cast(a) << 24) | + (static_cast(b) << 16) | + (static_cast(g) << 8) | + static_cast(r); +} + +} // namespace + +const WoweeBuffBook::Entry* +WoweeBuffBook::findById(uint32_t buffId) const { + for (const auto& e : entries) + if (e.buffId == buffId) return &e; + return nullptr; +} + +std::vector +WoweeBuffBook::walkChainBackToRoot(uint32_t buffId) const { + std::vector out; + std::unordered_set 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 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(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 diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 8029ad1d..d20ce485 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -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", diff --git a/tools/editor/cli_buff_book_catalog.cpp b/tools/editor/cli_buff_book_catalog.cpp new file mode 100644 index 00000000..7c3793a0 --- /dev/null +++ b/tools/editor/cli_buff_book_catalog.cpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include + +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 errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set 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(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 diff --git a/tools/editor/cli_buff_book_catalog.hpp b/tools/editor/cli_buff_book_catalog.hpp new file mode 100644 index 00000000..30a9722a --- /dev/null +++ b/tools/editor/cli_buff_book_catalog.hpp @@ -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 diff --git a/tools/editor/cli_catalog_find.cpp b/tools/editor/cli_catalog_find.cpp index 42a98ad7..0a39a699 100644 --- a/tools/editor/cli_catalog_find.cpp +++ b/tools/editor/cli_catalog_find.cpp @@ -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); diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index 296a9167..34fbc56c 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -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, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index e9e3f9f5..47855975 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -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"}, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 398e5a62..d8a410b3 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2181,6 +2181,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wemo to a human-editable JSON sidecar (defaults to .wemo.json; emits all 3 enums as both int AND name string)\n"); std::printf(" --import-wemo-json [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 [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 [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 [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 [--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 [--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 [--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 d1c942b3..ff40c744 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -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