From 6403d84a285e0f455dc046ec0076287aca3782b3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 10 May 2026 02:20:19 -0700 Subject: [PATCH] =?UTF-8?q?feat(editor):=20add=20WSPV=20(Spell=20Variant)?= =?UTF-8?q?=20=E2=80=94=20113th=20open=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel replacement for the implicit context-conditional spell substitution rules vanilla WoW encoded across SpellSpecificType, SpellEffect.EffectMechanic override fields, and the proc-modified spell tables in SpellProcEvent. Each entry binds one base spell to a variant spell that activates when a runtime condition is met (player in a specific stance, talent talented, racial buff active, weapon equipped, aura present). Six conditionKind values cover the full substitution surface: Stance / Form / Talent / Race / EquippedWeapon / AuraActive. The conditionValue field is polymorphic — its semantics depend on conditionKind (a stance spellId, a talentId, a race bit, etc.). The spell-cast pipeline iterates findByBaseSpell at cast time and picks the highest-priority variant whose condition is satisfied, falling through to the base spell if none matches. Three preset emitters demonstrating the pattern: makeWarriorStance (4 stance-conditional Warrior variants — Heroic Strike Berserker damage bonus, Battle baseline, Mocking Blow Defensive AoE taunt, Pummel Berserker-only gate), makeTalentMod (4 talent- modified variants — Frostbolt + Brain Freeze instant, Lava Burst + Flame Shock auto-crit, Earth Shield + Improved bonus heal, Ferocious Bite + Berserk), makeRacial (4 race-gated racials — Stoneform Dwarf, War Stomp Tauren, Berserking Troll, Will of the Forsaken). Validator's most novel check is the (baseSpell, conditionKind, conditionValue, priority) 4-tuple uniqueness — two variants with all four matching would tie at runtime and resolve non-deterministically (the spell-cast pipeline's std::sort by priority is stable but the underlying iteration order is undefined when priorities tie). Packs the tuple into 64 bits (base 32 | value 16 | kind 8 | prio 8) for set lookup. Format count 112 -> 113. CLI flag count 1213 -> 1218. --- CMakeLists.txt | 3 + include/pipeline/wowee_spell_variants.hpp | 132 +++++++++ src/pipeline/wowee_spell_variants.cpp | 277 +++++++++++++++++++ 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_spell_variants_catalog.cpp | 285 ++++++++++++++++++++ tools/editor/cli_spell_variants_catalog.hpp | 12 + 10 files changed, 725 insertions(+) create mode 100644 include/pipeline/wowee_spell_variants.hpp create mode 100644 src/pipeline/wowee_spell_variants.cpp create mode 100644 tools/editor/cli_spell_variants_catalog.cpp create mode 100644 tools/editor/cli_spell_variants_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5d0cbb43..6cb86f12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -701,6 +701,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_minimap_levels.cpp src/pipeline/wowee_pet_care.cpp src/pipeline/wowee_movie_credits.cpp + src/pipeline/wowee_spell_variants.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1565,6 +1566,7 @@ add_executable(wowee_editor tools/editor/cli_minimap_levels_catalog.cpp tools/editor/cli_pet_care_catalog.cpp tools/editor/cli_movie_credits_catalog.cpp + tools/editor/cli_spell_variants_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1748,6 +1750,7 @@ add_executable(wowee_editor src/pipeline/wowee_minimap_levels.cpp src/pipeline/wowee_pet_care.cpp src/pipeline/wowee_movie_credits.cpp + src/pipeline/wowee_spell_variants.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_spell_variants.hpp b/include/pipeline/wowee_spell_variants.hpp new file mode 100644 index 00000000..83fd53cb --- /dev/null +++ b/include/pipeline/wowee_spell_variants.hpp @@ -0,0 +1,132 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Spell Variant catalog (.wspv) — novel +// replacement for the implicit context-conditional +// spell substitution rules vanilla WoW encoded across +// SpellSpecificType, SpellEffect.EffectMechanic +// override fields, and the proc-modified spell tables +// in SpellProcEvent. Each entry binds one base spell +// to a variant spell that activates when a runtime +// condition is met (player in a specific stance, +// talent talented, racial buff active, etc.). +// +// The spell-cast pipeline consults this catalog at the +// moment of cast: if any variant whose condition is +// satisfied has higher priority than the base, the +// variant spell ID is substituted for the cast. +// +// Cross-references with previously-added formats: +// WSPL: baseSpellId and variantSpellId both reference +// the WSPL spell catalog. +// WCMG: when conditionKind=Stance/Form, conditionValue +// is a WCMG spellId (the stance/form active +// on the caster). +// WTAL: when conditionKind=Talent, conditionValue is +// a WTAL talent ID (the talented passive that +// enables the variant). +// WCHC: when conditionKind=Race, conditionValue is a +// WCHC race bit. +// +// Binary layout (little-endian): +// magic[4] = "WSPV" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// variantId (uint32) +// nameLen + name +// descLen + description +// baseSpellId (uint32) +// variantSpellId (uint32) +// conditionKind (uint8) — Stance / Form / +// Talent / Race / +// EquippedWeapon / +// AuraActive +// priority (uint8) — higher overrides +// lower; 0 = base +// spell baseline +// pad0 (uint8) / pad1 (uint8) +// conditionValue (uint32) — polymorphic — see +// conditionKind +// iconColorRGBA (uint32) +struct WoweeSpellVariants { + enum ConditionKind : uint8_t { + Stance = 0, // value = stance spellId + Form = 1, // value = druid form + // spellId + Talent = 2, // value = talentId + Race = 3, // value = WCHC race bit + EquippedWeapon = 4, // value = weapon-type + // bitmask (sword=2, + // mace=4, etc.) + AuraActive = 5, // value = aura spellId + // currently on caster + }; + + struct Entry { + uint32_t variantId = 0; + std::string name; + std::string description; + uint32_t baseSpellId = 0; + uint32_t variantSpellId = 0; + uint8_t conditionKind = Stance; + uint8_t priority = 1; + uint8_t pad0 = 0; + uint8_t pad1 = 0; + uint32_t conditionValue = 0; + uint32_t iconColorRGBA = 0xFFFFFFFFu; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t variantId) const; + + // Returns all variants of one base spell, sorted by + // descending priority. The spell-cast pipeline + // iterates this list at cast time and picks the + // highest-priority variant whose condition is + // satisfied (or falls through to the base spell). + std::vector findByBaseSpell(uint32_t baseSpellId) const; +}; + +class WoweeSpellVariantsLoader { +public: + static bool save(const WoweeSpellVariants& cat, + const std::string& basePath); + static WoweeSpellVariants load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-spv* variants. + // + // makeWarriorStance — 4 stance-conditional Warrior + // spell variants (Heroic Strike + // Battle vs Berserker damage + // bonus, Mocking Blow + // Defensive AoE, Pummel + // Berserker silence). + // makeTalentMod — 4 talent-modified spell + // variants (Frostbolt + Brain + // Freeze proc, Lava Burst + + // Flame Shock auto-crit, + // Earth Shield + Improved, + // Ferocious Bite + Berserk). + // makeRacial — 4 racial spell variants + // (Stoneform / War Stomp / + // Berserking / WoTF). + static WoweeSpellVariants makeWarriorStance(const std::string& catalogName); + static WoweeSpellVariants makeTalentMod(const std::string& catalogName); + static WoweeSpellVariants makeRacial(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_spell_variants.cpp b/src/pipeline/wowee_spell_variants.cpp new file mode 100644 index 00000000..9dc2b9a1 --- /dev/null +++ b/src/pipeline/wowee_spell_variants.cpp @@ -0,0 +1,277 @@ +#include "pipeline/wowee_spell_variants.hpp" + +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'S', 'P', 'V'}; +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) != ".wspv") { + base += ".wspv"; + } + 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 WoweeSpellVariants::Entry* +WoweeSpellVariants::findById(uint32_t variantId) const { + for (const auto& e : entries) + if (e.variantId == variantId) return &e; + return nullptr; +} + +std::vector +WoweeSpellVariants::findByBaseSpell(uint32_t baseSpellId) const { + std::vector out; + for (const auto& e : entries) + if (e.baseSpellId == baseSpellId) out.push_back(&e); + std::sort(out.begin(), out.end(), + [](const Entry* a, const Entry* b) { + return a->priority > b->priority; + }); + return out; +} + +bool WoweeSpellVariantsLoader::save(const WoweeSpellVariants& 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.variantId); + writeStr(os, e.name); + writeStr(os, e.description); + writePOD(os, e.baseSpellId); + writePOD(os, e.variantSpellId); + writePOD(os, e.conditionKind); + writePOD(os, e.priority); + writePOD(os, e.pad0); + writePOD(os, e.pad1); + writePOD(os, e.conditionValue); + writePOD(os, e.iconColorRGBA); + } + return os.good(); +} + +WoweeSpellVariants WoweeSpellVariantsLoader::load( + const std::string& basePath) { + WoweeSpellVariants 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.variantId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name) || !readStr(is, e.description)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.baseSpellId) || + !readPOD(is, e.variantSpellId) || + !readPOD(is, e.conditionKind) || + !readPOD(is, e.priority) || + !readPOD(is, e.pad0) || + !readPOD(is, e.pad1) || + !readPOD(is, e.conditionValue) || + !readPOD(is, e.iconColorRGBA)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeSpellVariantsLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeSpellVariants WoweeSpellVariantsLoader::makeWarriorStance( + const std::string& catalogName) { + using V = WoweeSpellVariants; + WoweeSpellVariants c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint32_t baseId, uint32_t variantSpell, + uint32_t stanceId, uint8_t prio, + const char* desc) { + V::Entry e; + e.variantId = id; e.name = name; e.description = desc; + e.baseSpellId = baseId; + e.variantSpellId = variantSpell; + e.conditionKind = V::Stance; + e.priority = prio; + e.conditionValue = stanceId; + e.iconColorRGBA = packRgba(220, 60, 60); // warrior red + c.entries.push_back(e); + }; + // Stance spell IDs (3.3.5a): + // Battle Stance = 2457, Defensive = 71, + // Berserker = 2458. + add(1, "HeroicStrikeBerserker", 78, 25286, 2458, 2, + "Heroic Strike — Berserker Stance variant. " + "Replaces base ID 78 with variant ID 25286 " + "(higher rage cost, higher damage). Priority 2 " + "overrides Battle Stance variant."); + add(2, "HeroicStrikeBattle", 78, 78, 2457, 1, + "Heroic Strike — Battle Stance baseline (no " + "modification). Priority 1 — falls through to " + "base when in Battle Stance."); + add(3, "MockingBlowDefensive", 694, 25266, 71, 3, + "Mocking Blow — Defensive Stance variant adds " + "AoE taunt component (3-target splash). " + "Priority 3 (active only when in Defensive)."); + add(4, "PummelBerserker", 6552, 6552, 2458, 1, + "Pummel — Berserker Stance only (cannot be cast " + "in other stances). Variant equals base ID " + "because the spell IS gated to Berserker — " + "the stance check provides the gate."); + return c; +} + +WoweeSpellVariants WoweeSpellVariantsLoader::makeTalentMod( + const std::string& catalogName) { + using V = WoweeSpellVariants; + WoweeSpellVariants c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint32_t baseId, uint32_t variantSpell, + uint32_t talentId, uint8_t prio, + const char* desc) { + V::Entry e; + e.variantId = id; e.name = name; e.description = desc; + e.baseSpellId = baseId; + e.variantSpellId = variantSpell; + e.conditionKind = V::Talent; + e.priority = prio; + e.conditionValue = talentId; + e.iconColorRGBA = packRgba(180, 100, 240); // talent purple + c.entries.push_back(e); + }; + add(100, "FrostboltBrainFreeze", 116, 44614, 11160, 5, + "Frostbolt becomes instant-cast when Brain " + "Freeze proc is active. Talent 11160 = Brain " + "Freeze passive. Priority 5 — high priority so " + "the proc takes effect immediately."); + add(101, "LavaBurstFlameShock", 51505, 51505, 60043, 3, + "Lava Burst — auto-crit when Flame Shock DoT is " + "active on target. Talent 60043 = Lava Flows " + "passive. Same spell ID; the proc replaces the " + "rolled outcome with guaranteed crit."); + add(102, "EarthShieldImproved", 974, 974, 16544, 2, + "Earth Shield — Improved variant adds bonus heal " + "per charge consumed. Talent 16544 = Improved " + "Earth Shield. Same spell ID; passive talent " + "modifies effect magnitude."); + add(103, "FerociousBiteBerserk", 22568, 22568, 50334, 4, + "Ferocious Bite — modified by Berserk passive " + "(50% energy reduction during Berserk). " + "Talent 50334 = Berserk. Same spell ID; " + "modifier overrides energy cost."); + return c; +} + +WoweeSpellVariants WoweeSpellVariantsLoader::makeRacial( + const std::string& catalogName) { + using V = WoweeSpellVariants; + WoweeSpellVariants c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint32_t baseId, uint32_t variantSpell, + uint32_t raceBit, uint8_t prio, + const char* desc) { + V::Entry e; + e.variantId = id; e.name = name; e.description = desc; + e.baseSpellId = baseId; + e.variantSpellId = variantSpell; + e.conditionKind = V::Race; + e.priority = prio; + e.conditionValue = raceBit; + e.iconColorRGBA = packRgba(220, 200, 80); // racial gold + c.entries.push_back(e); + }; + // Race bits (WCHC): + // Human=1, Orc=2, Dwarf=4, NightElf=8, + // Undead=16, Tauren=32, Gnome=64, Troll=128, + // BloodElf=512, Draenei=1024. + add(200, "Stoneform_Dwarf", 20594, 20594, 4, 5, + "Stoneform — Dwarf-only racial. Removes " + "bleed/poison/disease, +10%% armor for 8s. " + "Variant spell ID equals base; the race gate " + "does the activation."); + add(201, "WarStomp_Tauren", 20549, 20549, 32, 5, + "War Stomp — Tauren-only racial. 2-yard AoE " + "stun for 2s. Castable in any stance/form."); + add(202, "Berserking_Troll", 26297, 26297, 128, 5, + "Berserking — Troll-only racial. +10-30%% " + "haste for 10s, scaling with current health " + "%."); + add(203, "WilloftheForsaken", 7744, 7744, 16, 5, + "Will of the Forsaken — Undead-only racial. " + "Removes fear/sleep/charm effects, 2-min " + "cooldown."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index bf629b9c..7bbc06c9 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -347,6 +347,8 @@ const char* const kArgRequired[] = { "--gen-mvc", "--gen-mvc-quest", "--gen-mvc-starter", "--info-wmvc", "--validate-wmvc", "--export-wmvc-json", "--import-wmvc-json", + "--gen-spv", "--gen-spv-talent", "--gen-spv-racial", + "--info-wspv", "--validate-wspv", "--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 da02b4dd..d9e0b570 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -157,6 +157,7 @@ #include "cli_minimap_levels_catalog.hpp" #include "cli_pet_care_catalog.hpp" #include "cli_movie_credits_catalog.hpp" +#include "cli_spell_variants_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -359,6 +360,7 @@ constexpr DispatchFn kDispatchTable[] = { handleMinimapLevelsCatalog, handlePetCareCatalog, handleMovieCreditsCatalog, + handleSpellVariantsCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 1d14ba07..36f02427 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -115,6 +115,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','M','N','L'}, ".wmnl", "worldmap", "--info-wmnl", "Minimap multi-level catalog"}, {{'W','P','C','R'}, ".wpcr", "pets", "--info-wpcr", "Pet care + action catalog"}, {{'W','M','V','C'}, ".wmvc", "cinematic", "--info-wmvc", "Movie credits roll catalog"}, + {{'W','S','P','V'}, ".wspv", "spells", "--info-wspv", "Spell variant 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 aca5b120..778eeb3e 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2335,6 +2335,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wmvc to a human-editable JSON sidecar (defaults to .wmvc.json; emits category as both int AND name string; lines[] as JSON string array)\n"); std::printf(" --import-wmvc-json [out-base]\n"); std::printf(" Import a .wmvc.json sidecar back into binary .wmvc (category int OR \"production\"/\"music\"/\"audio\"/\"engineering\"/\"art\"/\"voice\"/\"special\"; lines[] is a JSON array of strings — directly editable to add/remove credit lines without binary tooling)\n"); + std::printf(" --gen-spv [name]\n"); + std::printf(" Emit .wspv 4 stance-conditional Warrior spell variants (Heroic Strike Berserker / Heroic Strike Battle / Mocking Blow Defensive / Pummel Berserker)\n"); + std::printf(" --gen-spv-talent [name]\n"); + std::printf(" Emit .wspv 4 talent-modified spell variants (Frostbolt + Brain Freeze instant / Lava Burst + Flame Shock auto-crit / Earth Shield + Improved bonus heal / Ferocious Bite + Berserk)\n"); + std::printf(" --gen-spv-racial [name]\n"); + std::printf(" Emit .wspv 4 racial spell variants (Stoneform Dwarf / War Stomp Tauren / Berserking Troll / Will of the Forsaken)\n"); + std::printf(" --info-wspv [--json]\n"); + std::printf(" Print WSPV entries (id / baseSpellId / variantSpellId / conditionKind / conditionValue / priority / name)\n"); + std::printf(" --validate-wspv [--json]\n"); + std::printf(" Static checks: id+name+baseSpellId+variantSpellId required, conditionKind 0..5, no duplicate variantIds, no two variants binding the same (baseSpell, conditionKind, conditionValue, priority) tuple (would tie at runtime and resolve non-deterministically); warns on conditionValue=0 (always-zero default match — gate becomes no-op)\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 0234ef88..cca02165 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -137,6 +137,7 @@ constexpr FormatRow kFormats[] = { {"WMNL", ".wmnl", "worldmap", "WorldMapTransforms.dbc + Overlay", "Minimap multi-level catalog (vertical zones)"}, {"WPCR", ".wpcr", "pets", "Spell.dbc pet ops + npc_text stable","Pet care + action catalog (Hunter / Warlock / stable mgmt)"}, {"WMVC", ".wmvc", "cinematic", "embedded cinematic credit-roll blob","Movie credits roll catalog (per-cinematic)"}, + {"WSPV", ".wspv", "spells", "implicit Spell.dbc context overrides","Spell variant catalog (stance/talent/racial substitution)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_spell_variants_catalog.cpp b/tools/editor/cli_spell_variants_catalog.cpp new file mode 100644 index 00000000..953c57ab --- /dev/null +++ b/tools/editor/cli_spell_variants_catalog.cpp @@ -0,0 +1,285 @@ +#include "cli_spell_variants_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_spell_variants.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWspvExt(std::string base) { + stripExt(base, ".wspv"); + return base; +} + +const char* conditionKindName(uint8_t k) { + using V = wowee::pipeline::WoweeSpellVariants; + switch (k) { + case V::Stance: return "stance"; + case V::Form: return "form"; + case V::Talent: return "talent"; + case V::Race: return "race"; + case V::EquippedWeapon: return "equippedweapon"; + case V::AuraActive: return "auraactive"; + default: return "unknown"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeSpellVariants& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeSpellVariantsLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wspv\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeSpellVariants& c, + const std::string& base) { + std::printf("Wrote %s.wspv\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" variants : %zu\n", c.entries.size()); +} + +int handleGenWarrior(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "WarriorStanceVariants"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWspvExt(base); + auto c = wowee::pipeline::WoweeSpellVariantsLoader::makeWarriorStance(name); + if (!saveOrError(c, base, "gen-spv")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenTalent(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "TalentModifiedVariants"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWspvExt(base); + auto c = wowee::pipeline::WoweeSpellVariantsLoader::makeTalentMod(name); + if (!saveOrError(c, base, "gen-spv-talent")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenRacial(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "RacialVariants"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWspvExt(base); + auto c = wowee::pipeline::WoweeSpellVariantsLoader::makeRacial(name); + if (!saveOrError(c, base, "gen-spv-racial")) 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 = stripWspvExt(base); + if (!wowee::pipeline::WoweeSpellVariantsLoader::exists(base)) { + std::fprintf(stderr, "WSPV not found: %s.wspv\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSpellVariantsLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wspv"] = base + ".wspv"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"variantId", e.variantId}, + {"name", e.name}, + {"description", e.description}, + {"baseSpellId", e.baseSpellId}, + {"variantSpellId", e.variantSpellId}, + {"conditionKind", e.conditionKind}, + {"conditionKindName", + conditionKindName(e.conditionKind)}, + {"priority", e.priority}, + {"conditionValue", e.conditionValue}, + {"iconColorRGBA", e.iconColorRGBA}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WSPV: %s.wspv\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" variants : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id baseSp varSp condition condVal prio name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %5u %5u %-13s %5u %3u %s\n", + e.variantId, e.baseSpellId, + e.variantSpellId, + conditionKindName(e.conditionKind), + e.conditionValue, e.priority, + 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 = stripWspvExt(base); + if (!wowee::pipeline::WoweeSpellVariantsLoader::exists(base)) { + std::fprintf(stderr, + "validate-wspv: WSPV not found: %s.wspv\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSpellVariantsLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set idsSeen; + // Per-(baseSpell, conditionKind, conditionValue, + // priority) tuple uniqueness — two variants with all + // four matching would tie at runtime and resolve + // non-deterministically. + std::set tupleSeen; + auto tupleKey = [](uint32_t base, uint8_t kind, + uint32_t value, uint8_t prio) { + // Pack into 64 bits: base (32) | value (16 + // truncated) | kind (8) | prio (8). Tight packing + // so we don't need a multi-key set. + uint64_t k = static_cast(base) << 32; + k |= (static_cast(value & 0xFFFF) << 16); + k |= (static_cast(kind) << 8); + k |= prio; + return k; + }; + 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.variantId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.variantId == 0) + errors.push_back(ctx + ": variantId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.baseSpellId == 0) { + errors.push_back(ctx + + ": baseSpellId is 0 — variant has no " + "base spell to substitute for"); + } + if (e.variantSpellId == 0) { + errors.push_back(ctx + + ": variantSpellId is 0 — variant has no " + "spell to substitute INTO"); + } + if (e.conditionKind > 5) { + errors.push_back(ctx + ": conditionKind " + + std::to_string(e.conditionKind) + + " out of range (must be 0..5)"); + } + if (e.conditionValue == 0) { + warnings.push_back(ctx + + ": conditionValue is 0 — condition would " + "match the always-zero default; verify " + "if intentional (the gate becomes a " + "no-op)"); + } + // Tuple uniqueness check. + uint64_t key = tupleKey(e.baseSpellId, + e.conditionKind, + e.conditionValue, + e.priority); + if (!tupleSeen.insert(key).second) { + errors.push_back(ctx + + ": (baseSpell=" + + std::to_string(e.baseSpellId) + + ", conditionKind=" + + std::string(conditionKindName(e.conditionKind)) + + ", conditionValue=" + + std::to_string(e.conditionValue) + + ", priority=" + + std::to_string(e.priority) + + ") tuple already bound by another variant " + "— spell-cast pipeline lookup would be " + "non-deterministic"); + } + if (!idsSeen.insert(e.variantId).second) { + errors.push_back(ctx + ": duplicate variantId"); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wspv"] = base + ".wspv"; + 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-wspv: %s.wspv\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu variants, all variantIds + " + "(base,kind,val,prio) tuples unique\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 handleSpellVariantsCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-spv") == 0 && i + 1 < argc) { + outRc = handleGenWarrior(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-spv-talent") == 0 && + i + 1 < argc) { + outRc = handleGenTalent(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-spv-racial") == 0 && + i + 1 < argc) { + outRc = handleGenRacial(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wspv") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wspv") == 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_spell_variants_catalog.hpp b/tools/editor/cli_spell_variants_catalog.hpp new file mode 100644 index 00000000..05f02b60 --- /dev/null +++ b/tools/editor/cli_spell_variants_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleSpellVariantsCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee