From 19af564a27ee408643de7766fb9fd432721cd1f7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 10 May 2026 04:44:42 -0700 Subject: [PATCH] feat(pipeline): WBND soulbind rules catalog (135th open format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel replacement for the implicit item-binding policy vanilla WoW carried in ItemTemplate.bondingType + per-item special-case rules in the server's LootMgr (the 2-hour raid-loot trade window was hard-coded; the account-bound-shared-across-faction rule for heirlooms was a TBC+ addition with no formal data- driven format). Each WBND entry binds one soulbind rule to its bind kind (BoP / BoE / BoU / BoA / Soulbound / NoBind), itemQualityFloor predicate (rule applies to items of this quality and above unless overridden by a stricter rule), tradable-window duration, raid-trade allowance, BoE-becomes- BoP trigger, and cross-faction sharing flag. Three presets capturing actual expansion-era policy evolution: --gen-bnd-vanilla 4 rules — Poor=NoBind, Common=BoE, Uncommon+=BoP no-window, Epic+=Soulbound. NO raid-trade window — the 1.12 master- loot drama era --gen-bnd-tbc 5 rules adding the iconic 2-hour raid- trade window for BoP items (Uncommon and Rare quality) --gen-bnd-wotlk 6 rules adding Heirloom = BindOnAccount + cross-faction (Alliance<->Horde via account-mail) for the WotLK level-1-to-80 twink path Validator catches: id+name required, bindKind 0..5, itemQualityFloor 0..7, no duplicate ruleIds, no duplicate (bindKind,qualityFloor) pairs (resolveForQuality lookup tie). Hard error: tradableForRaidGroup=true with window=0 (window expires instantly = no window at all). Warns on contradictions: tradableForRaidGroup with non-BoP kind, window > 0 without raid-trade flag, boeBecomesBoP without BoE kind, accountBoundCrossFaction without BoA kind (all flag-ignored at runtime). Format count 134 -> 135. CLI flag count 1409 -> 1416. --- CMakeLists.txt | 3 + include/pipeline/wowee_soulbind_rules.hpp | 165 ++++++++++ src/pipeline/wowee_soulbind_rules.cpp | 289 +++++++++++++++++ tools/editor/cli_arg_required.cpp | 2 + tools/editor/cli_dispatch.cpp | 2 + tools/editor/cli_format_table.cpp | 1 + tools/editor/cli_help.cpp | 10 + tools/editor/cli_list_formats.cpp | 1 + tools/editor/cli_soulbind_rules_catalog.cpp | 331 ++++++++++++++++++++ tools/editor/cli_soulbind_rules_catalog.hpp | 12 + 10 files changed, 816 insertions(+) create mode 100644 include/pipeline/wowee_soulbind_rules.hpp create mode 100644 src/pipeline/wowee_soulbind_rules.cpp create mode 100644 tools/editor/cli_soulbind_rules_catalog.cpp create mode 100644 tools/editor/cli_soulbind_rules_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 377502ce..f189d9c0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -723,6 +723,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_quest_graph.cpp src/pipeline/wowee_crafting_recipes.cpp src/pipeline/wowee_world_locations.cpp + src/pipeline/wowee_soulbind_rules.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1609,6 +1610,7 @@ add_executable(wowee_editor tools/editor/cli_quest_graph_catalog.cpp tools/editor/cli_crafting_recipes_catalog.cpp tools/editor/cli_world_locations_catalog.cpp + tools/editor/cli_soulbind_rules_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1814,6 +1816,7 @@ add_executable(wowee_editor src/pipeline/wowee_quest_graph.cpp src/pipeline/wowee_crafting_recipes.cpp src/pipeline/wowee_world_locations.cpp + src/pipeline/wowee_soulbind_rules.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_soulbind_rules.hpp b/include/pipeline/wowee_soulbind_rules.hpp new file mode 100644 index 00000000..a590a158 --- /dev/null +++ b/include/pipeline/wowee_soulbind_rules.hpp @@ -0,0 +1,165 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Soulbind Rules catalog (.wbnd) — +// novel replacement for the implicit item-binding +// policy vanilla WoW carried in +// ItemTemplate.bondingType + per-item special-case +// rules in the server's LootMgr (the 2-hour +// raid-loot trade window was hard-coded; the +// account-bound-shared-across-faction rule for +// heirlooms was a TBC+ addition with no formal +// data-driven format). Each WBND entry binds one +// soulbind rule to its bind kind (BoP / BoE / BoU / +// BoA / Soulbound / NoBind), tradable-window +// duration, raid-trade allowance, BoE-becomes-BoP +// trigger, and cross-faction sharing flag. +// +// Cross-references with previously-added formats: +// WIT: rules apply to items by qualityFloor +// predicate (every WIT item with quality >= +// floor uses this rule unless overridden). +// None to specific itemId — the rule catalog +// describes POLICY, not per-item bindings. +// +// Binary layout (little-endian): +// magic[4] = "WBND" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// ruleId (uint32) +// nameLen + name +// bindKind (uint8) — 0=BindOnPickup +// /1=BindOnEquip +// /2=BindOnUse +// /3=BindOnAccount +// /4=Soulbound +// (already on +// loot) +// /5=NoBind +// itemQualityFloor (uint8) — quality at +// which this rule +// applies (0= +// Poor..7=Heir- +// loom) +// tradableForRaidGroup (uint8) — 0/1 bool — BoP +// gets the 2hr +// raid-trade +// window +// boeBecomesBoP (uint8) — 0/1 bool — BoE +// becomes +// Soulbound on +// pickup +// accountBoundCrossFaction (uint8) — 0/1 bool — +// BoA can +// transfer +// Alliance<-> +// Horde via +// account +// (heirloom +// flag) +// pad0 (uint8) +// pad1 (uint16) +// tradableWindowSec (uint32) — duration of +// the raid-trade +// window (vanilla +// had no window; +// TBC default +// 7200=2hr) +// descriptionLen + description — human-readable +// policy summary +// for the editor +struct WoweeSoulbindRules { + enum BindKind : uint8_t { + BindOnPickup = 0, + BindOnEquip = 1, + BindOnUse = 2, + BindOnAccount = 3, + Soulbound = 4, // already-soulbound at + // loot time (rare) + NoBind = 5, // tradable forever + }; + + enum ItemQuality : uint8_t { + Poor = 0, + Common = 1, + Uncommon = 2, + Rare = 3, + Epic = 4, + Legendary = 5, + Artifact = 6, + Heirloom = 7, + }; + + struct Entry { + uint32_t ruleId = 0; + std::string name; + uint8_t bindKind = BindOnPickup; + uint8_t itemQualityFloor = Poor; + uint8_t tradableForRaidGroup = 0; + uint8_t boeBecomesBoP = 0; + uint8_t accountBoundCrossFaction = 0; + uint8_t pad0 = 0; + uint16_t pad1 = 0; + uint32_t tradableWindowSec = 0; + std::string description; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t ruleId) const; + + // Resolve the rule that applies to an item of a + // given quality. Returns the most-specific rule + // (highest qualityFloor that doesn't exceed + // itemQuality). The runtime walks all rules and + // picks the best match. + const Entry* resolveForQuality(uint8_t itemQuality) const; + + // Returns all rules of one bind kind — used by + // the inventory UI to color-code BoP vs BoE + // tooltips uniformly. + std::vector findByBindKind(uint8_t bindKind) const; +}; + +class WoweeSoulbindRulesLoader { +public: + static bool save(const WoweeSoulbindRules& cat, + const std::string& basePath); + static WoweeSoulbindRules load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-bnd* variants. + // + // makeVanillaPolicy — 4 vanilla rules (Poor= + // NoBind, Common=BoE, + // Uncommon+=BoP no-window, + // Epic+=Soulbound). No + // raid-trade window. + // makeTBCPolicy — 5 TBC rules (Poor=NoBind, + // Common=BoE, + // Uncommon+=BoP+2hr window, + // Rare+=BoP+2hr window, + // Epic+=Soulbound). Raid- + // trade window introduced. + // makeWotLKPolicy — 6 WotLK rules adding + // Heirloom=BindOnAccount + // cross-faction. Otherwise + // same as TBC. + static WoweeSoulbindRules makeVanillaPolicy(const std::string& catalogName); + static WoweeSoulbindRules makeTBCPolicy(const std::string& catalogName); + static WoweeSoulbindRules makeWotLKPolicy(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_soulbind_rules.cpp b/src/pipeline/wowee_soulbind_rules.cpp new file mode 100644 index 00000000..855ee89e --- /dev/null +++ b/src/pipeline/wowee_soulbind_rules.cpp @@ -0,0 +1,289 @@ +#include "pipeline/wowee_soulbind_rules.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'B', 'N', 'D'}; +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) != ".wbnd") { + base += ".wbnd"; + } + return base; +} + +} // namespace + +const WoweeSoulbindRules::Entry* +WoweeSoulbindRules::findById(uint32_t ruleId) const { + for (const auto& e : entries) + if (e.ruleId == ruleId) return &e; + return nullptr; +} + +const WoweeSoulbindRules::Entry* +WoweeSoulbindRules::resolveForQuality(uint8_t itemQuality) const { + // Walk all entries; pick the rule with highest + // qualityFloor that is <= itemQuality. Ties + // broken by ruleId (lower wins for stable + // resolution). + const Entry* best = nullptr; + for (const auto& e : entries) { + if (e.itemQualityFloor > itemQuality) continue; + if (!best || + e.itemQualityFloor > best->itemQualityFloor || + (e.itemQualityFloor == best->itemQualityFloor && + e.ruleId < best->ruleId)) { + best = &e; + } + } + return best; +} + +std::vector +WoweeSoulbindRules::findByBindKind(uint8_t bindKind) const { + std::vector out; + for (const auto& e : entries) + if (e.bindKind == bindKind) out.push_back(&e); + return out; +} + +bool WoweeSoulbindRulesLoader::save(const WoweeSoulbindRules& 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.ruleId); + writeStr(os, e.name); + writePOD(os, e.bindKind); + writePOD(os, e.itemQualityFloor); + writePOD(os, e.tradableForRaidGroup); + writePOD(os, e.boeBecomesBoP); + writePOD(os, e.accountBoundCrossFaction); + writePOD(os, e.pad0); + writePOD(os, e.pad1); + writePOD(os, e.tradableWindowSec); + writeStr(os, e.description); + } + return os.good(); +} + +WoweeSoulbindRules WoweeSoulbindRulesLoader::load( + const std::string& basePath) { + WoweeSoulbindRules 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.ruleId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.bindKind) || + !readPOD(is, e.itemQualityFloor) || + !readPOD(is, e.tradableForRaidGroup) || + !readPOD(is, e.boeBecomesBoP) || + !readPOD(is, e.accountBoundCrossFaction) || + !readPOD(is, e.pad0) || + !readPOD(is, e.pad1) || + !readPOD(is, e.tradableWindowSec)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.description)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeSoulbindRulesLoader::exists( + const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +namespace { + +WoweeSoulbindRules::Entry makeRule( + uint32_t ruleId, const char* name, + uint8_t bindKind, uint8_t qualityFloor, + uint8_t raidTrade, uint8_t boeToBoP, + uint8_t crossFaction, + uint32_t windowSec, const char* desc) { + WoweeSoulbindRules::Entry e; + e.ruleId = ruleId; e.name = name; + e.bindKind = bindKind; + e.itemQualityFloor = qualityFloor; + e.tradableForRaidGroup = raidTrade; + e.boeBecomesBoP = boeToBoP; + e.accountBoundCrossFaction = crossFaction; + e.tradableWindowSec = windowSec; + e.description = desc; + return e; +} + +} // namespace + +WoweeSoulbindRules WoweeSoulbindRulesLoader::makeVanillaPolicy( + const std::string& catalogName) { + using B = WoweeSoulbindRules; + WoweeSoulbindRules c; + c.name = catalogName; + // Vanilla 1.12: no raid-trade window. Items bind + // INSTANTLY on pickup with no second chance. + c.entries.push_back(makeRule( + 1, "Vanilla Poor (gray vendor trash)", + B::NoBind, B::Poor, 0, 0, 0, 0, + "Gray-quality items never bind — always " + "tradable / vendorable. Used for vendor trash " + "and crafting reagents.")); + c.entries.push_back(makeRule( + 2, "Vanilla Common (white)", + B::BindOnEquip, B::Common, 0, 0, 0, 0, + "White-quality items bind on equip. Once " + "equipped, bound to character permanently.")); + c.entries.push_back(makeRule( + 3, "Vanilla Uncommon (green) and above", + B::BindOnPickup, B::Uncommon, 0, 0, 0, 0, + "Green+ quality items bind on pickup. NO " + "trade window in vanilla — pick it up, it's " + "yours forever. Famous source of master-loot " + "drama.")); + c.entries.push_back(makeRule( + 4, "Vanilla Epic+ (purple/orange)", + B::Soulbound, B::Epic, 0, 0, 0, 0, + "Epic+ quality items arrive already " + "Soulbound at loot — no transfer possible " + "even before pickup acknowledgement.")); + return c; +} + +WoweeSoulbindRules WoweeSoulbindRulesLoader::makeTBCPolicy( + const std::string& catalogName) { + using B = WoweeSoulbindRules; + WoweeSoulbindRules c; + c.name = catalogName; + // TBC 2.4.3 added the 2-hour raid-trade window + // for BoP items. Players who looted a piece had + // 7200s to trade it to a raid member who was + // present at the kill. + c.entries.push_back(makeRule( + 10, "TBC Poor (gray vendor trash)", + B::NoBind, B::Poor, 0, 0, 0, 0, + "Gray-quality items never bind.")); + c.entries.push_back(makeRule( + 11, "TBC Common (white)", + B::BindOnEquip, B::Common, 0, 0, 0, 0, + "BoE — bind on equip.")); + c.entries.push_back(makeRule( + 12, "TBC Uncommon (green)", + B::BindOnPickup, B::Uncommon, 1, 0, 0, 7200, + "BoP with 2hr raid-trade window. Looter can " + "transfer to a raid member present at the " + "kill within 2hr.")); + c.entries.push_back(makeRule( + 13, "TBC Rare (blue)", + B::BindOnPickup, B::Rare, 1, 0, 0, 7200, + "BoP with 2hr raid-trade window.")); + c.entries.push_back(makeRule( + 14, "TBC Epic+ (purple/orange)", + B::Soulbound, B::Epic, 0, 0, 0, 0, + "Epic+ items arrive Soulbound — no trade " + "even within window.")); + return c; +} + +WoweeSoulbindRules WoweeSoulbindRulesLoader::makeWotLKPolicy( + const std::string& catalogName) { + using B = WoweeSoulbindRules; + WoweeSoulbindRules c; + c.name = catalogName; + // WotLK 3.3.5a kept TBC's raid-trade window and + // ADDED Heirloom (Account-Bound, cross-faction + // for the 80-twink path). + c.entries.push_back(makeRule( + 20, "WotLK Poor (gray)", + B::NoBind, B::Poor, 0, 0, 0, 0, + "Never binds.")); + c.entries.push_back(makeRule( + 21, "WotLK Common (white)", + B::BindOnEquip, B::Common, 0, 0, 0, 0, + "BoE.")); + c.entries.push_back(makeRule( + 22, "WotLK Uncommon (green)", + B::BindOnPickup, B::Uncommon, 1, 0, 0, 7200, + "BoP with 2hr raid-trade window.")); + c.entries.push_back(makeRule( + 23, "WotLK Rare (blue)", + B::BindOnPickup, B::Rare, 1, 0, 0, 7200, + "BoP with 2hr raid-trade window.")); + c.entries.push_back(makeRule( + 24, "WotLK Epic+ (purple/orange)", + B::Soulbound, B::Epic, 0, 0, 0, 0, + "Soulbound on loot — no trade.")); + c.entries.push_back(makeRule( + 25, "WotLK Heirloom (gold)", + B::BindOnAccount, B::Heirloom, 0, 0, 1, 0, + "Account-bound and cross-faction. Sent via " + "account-mail across Alliance/Horde. New in " + "WotLK for the level-1-to-80 twink path.")); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 920e5bd6..5812841e 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -413,6 +413,8 @@ const char* const kArgRequired[] = { "--gen-loc-poi", "--gen-loc-herb", "--gen-loc-rare", "--info-wloc", "--validate-wloc", "--export-wloc-json", "--import-wloc-json", + "--gen-bnd-vanilla", "--gen-bnd-tbc", "--gen-bnd-wotlk", + "--info-wbnd", "--validate-wbnd", "--gen-weather-temperate", "--gen-weather-arctic", "--gen-weather-desert", "--gen-weather-stormy", "--gen-zone-atmosphere", diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index b12222ef..cab8bb97 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -179,6 +179,7 @@ #include "cli_quest_graph_catalog.hpp" #include "cli_crafting_recipes_catalog.hpp" #include "cli_world_locations_catalog.hpp" +#include "cli_soulbind_rules_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -403,6 +404,7 @@ constexpr DispatchFn kDispatchTable[] = { handleQuestGraphCatalog, handleCraftingRecipesCatalog, handleWorldLocationsCatalog, + handleSoulbindRulesCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 21622a70..bf61144c 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -137,6 +137,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','Q','G','R'}, ".wqgr", "quests", "--info-wqgr", "Quest graph catalog"}, {{'W','C','R','A'}, ".wcra", "crafting", "--info-wcra", "Crafting recipe catalog"}, {{'W','L','O','C'}, ".wloc", "world", "--info-wloc", "World locations catalog"}, + {{'W','B','N','D'}, ".wbnd", "loot", "--info-wbnd", "Soulbind rules catalog"}, {{'W','F','A','C'}, ".wfac", "factions", nullptr, "Faction catalog"}, {{'W','L','C','K'}, ".wlck", "locks", nullptr, "Lock catalog"}, {{'W','S','K','L'}, ".wskl", "skills", nullptr, "Skill catalog"}, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 568298ab..2dc7cbc2 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2643,6 +2643,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wloc to a human-editable JSON sidecar (defaults to .wloc.json; emits both locKind and factionAccess as int + name string; floats preserved bit-for-bit)\n"); std::printf(" --import-wloc-json [out-base]\n"); std::printf(" Import a .wloc.json sidecar back into binary .wloc (locKind int OR \"poi\"/\"rarespawn\"/\"herbnode\"/\"mineralvein\"/\"fishingspot\"/\"areatrigger\"/\"portallanding\"; factionAccess int OR \"both\"/\"alliance\"/\"horde\"/\"neutral\")\n"); + std::printf(" --gen-bnd-vanilla [name]\n"); + std::printf(" Emit .wbnd vanilla 1.12 soulbind policy: 4 rules (Poor=NoBind, Common=BoE, Uncommon+=BoP no-window, Epic+=Soulbound). NO raid-trade window in vanilla\n"); + std::printf(" --gen-bnd-tbc [name]\n"); + std::printf(" Emit .wbnd TBC 2.4.3 soulbind policy: 5 rules adding the 2-hour raid-trade window for BoP items (Uncommon and Rare quality)\n"); + std::printf(" --gen-bnd-wotlk [name]\n"); + std::printf(" Emit .wbnd WotLK 3.3.5a soulbind policy: 6 rules adding Heirloom = BindOnAccount + cross-faction (Alliance<->Horde via account-mail) for the level-1-to-80 twink path\n"); + std::printf(" --info-wbnd [--json]\n"); + std::printf(" Print WBND entries (id / bindKind / itemQualityFloor / raid-trade flag / boe-becomes-bop / xfac / window-sec / name)\n"); + std::printf(" --validate-wbnd [--json]\n"); + std::printf(" Static checks: id+name required, bindKind 0..5, itemQualityFloor 0..7, no duplicate ruleIds, no duplicate (bindKind,qualityFloor) pairs (resolveForQuality tie); tradableForRaidGroup=true with window=0 errors (instant expiry = no window). Warns on contradictions: tradableForRaidGroup with non-BoP kind, window > 0 without raid-trade flag, boeBecomesBoP without BoE kind, accountBoundCrossFaction without BoA kind (all flag-ignored at runtime)\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 207eaf66..653801f2 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -159,6 +159,7 @@ constexpr FormatRow kFormats[] = { {"WQGR", ".wqgr", "quests", "QuestRelations.dbc + per-quest scripts","Quest graph catalog (chain prereq DAG + cycle detection)"}, {"WCRA", ".wcra", "crafting", "SpellReagents.dbc + Spell.dbc effect-24","Crafting recipe catalog (trade-skill recipe expansion)"}, {"WLOC", ".wloc", "world", "AreaPOI + GO spawns + AreaTrigger.dbc","World locations catalog (POI/RareSpawn/HerbNode/MineralVein/FishingSpot/Trigger/PortalLand)"}, + {"WBND", ".wbnd", "loot", "ItemTemplate.bondingType + LootMgr", "Soulbind rules catalog (BoP/BoE/BoU/BoA + raid-trade window)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_soulbind_rules_catalog.cpp b/tools/editor/cli_soulbind_rules_catalog.cpp new file mode 100644 index 00000000..327f9c13 --- /dev/null +++ b/tools/editor/cli_soulbind_rules_catalog.cpp @@ -0,0 +1,331 @@ +#include "cli_soulbind_rules_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_soulbind_rules.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWbndExt(std::string base) { + stripExt(base, ".wbnd"); + return base; +} + +const char* bindKindName(uint8_t k) { + using B = wowee::pipeline::WoweeSoulbindRules; + switch (k) { + case B::BindOnPickup: return "bindonpickup"; + case B::BindOnEquip: return "bindonequip"; + case B::BindOnUse: return "bindonuse"; + case B::BindOnAccount: return "bindonaccount"; + case B::Soulbound: return "soulbound"; + case B::NoBind: return "nobind"; + default: return "?"; + } +} + +const char* qualityName(uint8_t q) { + using B = wowee::pipeline::WoweeSoulbindRules; + switch (q) { + case B::Poor: return "poor"; + case B::Common: return "common"; + case B::Uncommon: return "uncommon"; + case B::Rare: return "rare"; + case B::Epic: return "epic"; + case B::Legendary: return "legendary"; + case B::Artifact: return "artifact"; + case B::Heirloom: return "heirloom"; + default: return "?"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeSoulbindRules& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeSoulbindRulesLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wbnd\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeSoulbindRules& c, + const std::string& base) { + std::printf("Wrote %s.wbnd\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" rules : %zu\n", c.entries.size()); +} + +int handleGenVanilla(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "VanillaSoulbindPolicy"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWbndExt(base); + auto c = wowee::pipeline::WoweeSoulbindRulesLoader:: + makeVanillaPolicy(name); + if (!saveOrError(c, base, "gen-bnd-vanilla")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenTBC(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "TBCSoulbindPolicy"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWbndExt(base); + auto c = wowee::pipeline::WoweeSoulbindRulesLoader:: + makeTBCPolicy(name); + if (!saveOrError(c, base, "gen-bnd-tbc")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenWotLK(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "WotLKSoulbindPolicy"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWbndExt(base); + auto c = wowee::pipeline::WoweeSoulbindRulesLoader:: + makeWotLKPolicy(name); + if (!saveOrError(c, base, "gen-bnd-wotlk")) 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 = stripWbndExt(base); + if (!wowee::pipeline::WoweeSoulbindRulesLoader::exists(base)) { + std::fprintf(stderr, "WBND not found: %s.wbnd\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSoulbindRulesLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wbnd"] = base + ".wbnd"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"ruleId", e.ruleId}, + {"name", e.name}, + {"bindKind", e.bindKind}, + {"bindKindName", bindKindName(e.bindKind)}, + {"itemQualityFloor", e.itemQualityFloor}, + {"itemQualityFloorName", + qualityName(e.itemQualityFloor)}, + {"tradableForRaidGroup", + e.tradableForRaidGroup != 0}, + {"boeBecomesBoP", e.boeBecomesBoP != 0}, + {"accountBoundCrossFaction", + e.accountBoundCrossFaction != 0}, + {"tradableWindowSec", e.tradableWindowSec}, + {"description", e.description}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WBND: %s.wbnd\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" rules : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id bind-kind quality raid boe2bop xfac window name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %-13s %-9s %s %s %s %5us %s\n", + e.ruleId, + bindKindName(e.bindKind), + qualityName(e.itemQualityFloor), + e.tradableForRaidGroup ? "Y" : "n", + e.boeBecomesBoP ? "Y" : "n", + e.accountBoundCrossFaction ? "Y" : "n", + e.tradableWindowSec, + 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 = stripWbndExt(base); + if (!wowee::pipeline::WoweeSoulbindRulesLoader::exists(base)) { + std::fprintf(stderr, + "validate-wbnd: WBND not found: %s.wbnd\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSoulbindRulesLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + using B = wowee::pipeline::WoweeSoulbindRules; + std::set idsSeen; + using Pair = std::pair; + std::set kindFloorPairs; + 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.ruleId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.ruleId == 0) + errors.push_back(ctx + ": ruleId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.bindKind > 5) { + errors.push_back(ctx + ": bindKind " + + std::to_string(e.bindKind) + + " out of range (0..5)"); + } + if (e.itemQualityFloor > 7) { + errors.push_back(ctx + ": itemQualityFloor " + + std::to_string(e.itemQualityFloor) + + " out of range (0..7)"); + } + // tradableForRaidGroup only meaningful for + // BindOnPickup. NoBind/BoE/BoU/BoA/Soulbound + // all ignore the raid-window flag. + if (e.tradableForRaidGroup && + e.bindKind != B::BindOnPickup) { + warnings.push_back(ctx + + ": tradableForRaidGroup=true but " + "bindKind is not BindOnPickup — flag " + "would be ignored at runtime"); + } + // tradableWindowSec only meaningful when raid- + // trade is enabled. + if (e.tradableWindowSec > 0 && + !e.tradableForRaidGroup) { + warnings.push_back(ctx + + ": tradableWindowSec=" + + std::to_string(e.tradableWindowSec) + + " set but tradableForRaidGroup=false " + "— window would never be reachable"); + } + // tradableForRaidGroup=true with window=0 is + // a contradiction (instant window expiry = + // no window at all). + if (e.tradableForRaidGroup && + e.tradableWindowSec == 0) { + errors.push_back(ctx + + ": tradableForRaidGroup=true with " + "tradableWindowSec=0 — window expires " + "instantly, equivalent to no window"); + } + // boeBecomesBoP only meaningful for BindOnEquip + // (it's the trigger that converts BoE to + // Soulbound on the pickup acknowledgement). + if (e.boeBecomesBoP && e.bindKind != B::BindOnEquip) { + warnings.push_back(ctx + + ": boeBecomesBoP=true but bindKind is " + "not BindOnEquip — flag would never " + "fire"); + } + // accountBoundCrossFaction only meaningful + // for BindOnAccount kind. + if (e.accountBoundCrossFaction && + e.bindKind != B::BindOnAccount) { + warnings.push_back(ctx + + ": accountBoundCrossFaction=true but " + "bindKind is not BindOnAccount — flag " + "would never apply"); + } + // (bindKind, itemQualityFloor) MUST be unique + // — runtime resolveForQuality() would + // ambiguously pick one rule when two rules + // tie on (bindKind, floor). + Pair p{e.bindKind, e.itemQualityFloor}; + if (!kindFloorPairs.insert(p).second) { + errors.push_back(ctx + + ": duplicate (bindKind=" + + std::to_string(e.bindKind) + + ", itemQualityFloor=" + + std::to_string(e.itemQualityFloor) + + ") — resolveForQuality() tie"); + } + if (!idsSeen.insert(e.ruleId).second) { + errors.push_back(ctx + ": duplicate ruleId"); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wbnd"] = base + ".wbnd"; + 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-wbnd: %s.wbnd\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu rules, all ruleIds + " + "(bindKind,itemQualityFloor) unique, " + "bindKind 0..5, quality 0..7, no " + "tradableForRaidGroup-with-window=0\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 handleSoulbindRulesCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-bnd-vanilla") == 0 && + i + 1 < argc) { + outRc = handleGenVanilla(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-bnd-tbc") == 0 && + i + 1 < argc) { + outRc = handleGenTBC(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-bnd-wotlk") == 0 && + i + 1 < argc) { + outRc = handleGenWotLK(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wbnd") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wbnd") == 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_soulbind_rules_catalog.hpp b/tools/editor/cli_soulbind_rules_catalog.hpp new file mode 100644 index 00000000..72b2f87c --- /dev/null +++ b/tools/editor/cli_soulbind_rules_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleSoulbindRulesCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee