From 62a10937e0f3a5aba1d8002945755cc74d22a7e9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 10 May 2026 01:29:56 -0700 Subject: [PATCH] =?UTF-8?q?feat(editor):=20add=20WSPM=20(Spell=20Persisten?= =?UTF-8?q?t=20Marker)=20=E2=80=94=20104th=20open=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel replacement for the SpellAreaTrigger.dbc + AreaTriggerCreateProperties pair vanilla used for AoE ground decals. Each entry binds one spellId to a ground-tracked decal: texture path, radius (in yards), duration, damage tick interval, RGBA decal color, edge- fade rendering mode (Hard / SoftEdge / Pulse), stack flag, and destroy-on-cancel semantics for channeled spells. The catalog covers three distinct gameplay surfaces in one shape: player-cast AoE (Blizzard, Flamestrike, etc. that the visual effects pipeline spawns at cast time), boss-arena hazard zones (Putricide poison pool, Sindragosa frost tomb, Marrowgar Bone Storm radius that raid encounters need to render so players know to move), and persistent environmental effects (Wintergrasp lightning strike, Silithus sandstorm cone that the weather system spawns). Three preset emitters one per surface: makeMageAoE (Blizzard/Flamestrike/BlastWave/FrostNova), makeRaid- Hazards (5 ICC encounter zones), makeEnvironment (3 weather/world hazards). Hazard variants set destroyOnCancel=0 since they persist beyond any caster; environment variants additionally set stackable=1 since multiple lightning strikes can overlap. Validator's most novel check is spellId uniqueness — multiple WSPM entries binding the same spellId would make the spell-cast lookup ambiguous (which decal does the spell spawn?). Also catches empty texture paths (decal would render solid color), radius<=0 (zero area), tickIntervalMs<100ms (perf risk for stackable markers), decalColor alpha=0 (invisible), and edge-fade enum range. Format count 103 -> 104. CLI flag count 1148 -> 1153. --- CMakeLists.txt | 3 + include/pipeline/wowee_spell_markers.hpp | 130 +++++++++ src/pipeline/wowee_spell_markers.cpp | 324 +++++++++++++++++++++ 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_markers_catalog.cpp | 280 ++++++++++++++++++ tools/editor/cli_spell_markers_catalog.hpp | 12 + 10 files changed, 765 insertions(+) create mode 100644 include/pipeline/wowee_spell_markers.hpp create mode 100644 src/pipeline/wowee_spell_markers.cpp create mode 100644 tools/editor/cli_spell_markers_catalog.cpp create mode 100644 tools/editor/cli_spell_markers_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c558cf7f..d2f57e8b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -692,6 +692,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_emotes.cpp src/pipeline/wowee_buff_book.cpp src/pipeline/wowee_tabards.cpp + src/pipeline/wowee_spell_markers.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1547,6 +1548,7 @@ add_executable(wowee_editor tools/editor/cli_emotes_catalog.cpp tools/editor/cli_buff_book_catalog.cpp tools/editor/cli_tabards_catalog.cpp + tools/editor/cli_spell_markers_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_quest_objective.cpp @@ -1719,6 +1721,7 @@ add_executable(wowee_editor src/pipeline/wowee_emotes.cpp src/pipeline/wowee_buff_book.cpp src/pipeline/wowee_tabards.cpp + src/pipeline/wowee_spell_markers.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_spell_markers.hpp b/include/pipeline/wowee_spell_markers.hpp new file mode 100644 index 00000000..ed7d9a42 --- /dev/null +++ b/include/pipeline/wowee_spell_markers.hpp @@ -0,0 +1,130 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Spell Persistent Marker catalog (.wspm) — +// novel replacement for the SpellAreaTrigger.dbc + +// AreaTriggerCreateProperties combination that vanilla +// WoW used for AoE ground decals (Blizzard, Hurricane, +// Consecration, Death and Decay, Rain of Fire, Flame +// Strike), boss-arena hazard zones (Putricide poison +// pools, Sindragosa frost tombs), and environmental +// effects (Wintergrasp lightning storm strike radius, +// Silithus sandstorm cones). +// +// Each entry binds one spellId to a ground decal: a +// texture path, radius (in world units), duration, +// damage tick interval, edge-fade rendering mode, and +// stack/destroy semantics. The visual effects pipeline +// reads this catalog at spell cast time to spawn the +// right ground-tracked decal. +// +// Cross-references with previously-added formats: +// WSPL: spellId references the WSPL spell catalog +// (the spell whose cast creates this marker). +// WSND: tickSoundId references the WSND sound catalog +// (the per-tick audio cue, e.g. crackling fire). +// +// Binary layout (little-endian): +// magic[4] = "WSPM" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// markerId (uint32) +// nameLen + name +// descLen + description +// spellId (uint32) +// pathLen + groundTexturePath — BLP/WOT path +// radius (float) — world units +// duration (float) — seconds (0 = until +// caster cancels / +// mana drains) +// tickIntervalMs (uint32) — ms between damage +// ticks +// decalColor (uint32) — RGBA tint applied to +// the texture +// edgeFadeMode (uint8) — Hard / SoftEdge / +// Pulse +// stackable (uint8) — 0/1 bool +// destroyOnCancel (uint8) — 0/1: vanish when the +// caster cancels the +// channel +// pad0 (uint8) +// tickSoundId (uint32) — 0 if silent +// iconColorRGBA (uint32) +struct WoweeSpellMarkers { + enum EdgeFadeMode : uint8_t { + Hard = 0, // sharp circle edge — no fade + SoftEdge = 1, // alpha-fade outer 20% of radius + Pulse = 2, // sinusoidal alpha pulse, full + // radius + }; + + struct Entry { + uint32_t markerId = 0; + std::string name; + std::string description; + uint32_t spellId = 0; + std::string groundTexturePath; + float radius = 8.0f; + float duration = 8.0f; + uint32_t tickIntervalMs = 1000; + uint32_t decalColor = 0xFFFFFFFFu; + uint8_t edgeFadeMode = SoftEdge; + uint8_t stackable = 0; + uint8_t destroyOnCancel = 1; + uint8_t pad0 = 0; + uint32_t tickSoundId = 0; + uint32_t iconColorRGBA = 0xFFFFFFFFu; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t markerId) const; + + // Returns the marker (if any) bound to the given + // spellId. Used by the spell-cast pipeline to look + // up "which decal does this spell spawn?" + const Entry* findBySpell(uint32_t spellId) const; +}; + +class WoweeSpellMarkersLoader { +public: + static bool save(const WoweeSpellMarkers& cat, + const std::string& basePath); + static WoweeSpellMarkers load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-spm* variants. + // + // makeMageAoE — 4 mage AoE ground spells + // (Blizzard / Flamestrike / + // BlastWave / Frost Nova + // visual ring). + // makeRaidHazards — 5 boss-arena hazard zones + // (Putricide poison pool / + // Sindragosa frost tomb / + // Saurfang blood-frenzy bonus / + // DBS shadow puddle / Marrowgar + // Bone Storm radius). + // makeEnvironment — 3 environmental effects + // (Wintergrasp lightning strike + // radius / Silithus sandstorm + // cone / open-world blizzard + // zone). + static WoweeSpellMarkers makeMageAoE(const std::string& catalogName); + static WoweeSpellMarkers makeRaidHazards(const std::string& catalogName); + static WoweeSpellMarkers makeEnvironment(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_spell_markers.cpp b/src/pipeline/wowee_spell_markers.cpp new file mode 100644 index 00000000..dd63f581 --- /dev/null +++ b/src/pipeline/wowee_spell_markers.cpp @@ -0,0 +1,324 @@ +#include "pipeline/wowee_spell_markers.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'S', 'P', 'M'}; +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) != ".wspm") { + base += ".wspm"; + } + 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 WoweeSpellMarkers::Entry* +WoweeSpellMarkers::findById(uint32_t markerId) const { + for (const auto& e : entries) + if (e.markerId == markerId) return &e; + return nullptr; +} + +const WoweeSpellMarkers::Entry* +WoweeSpellMarkers::findBySpell(uint32_t spellId) const { + for (const auto& e : entries) + if (e.spellId == spellId) return &e; + return nullptr; +} + +bool WoweeSpellMarkersLoader::save(const WoweeSpellMarkers& 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.markerId); + writeStr(os, e.name); + writeStr(os, e.description); + writePOD(os, e.spellId); + writeStr(os, e.groundTexturePath); + writePOD(os, e.radius); + writePOD(os, e.duration); + writePOD(os, e.tickIntervalMs); + writePOD(os, e.decalColor); + writePOD(os, e.edgeFadeMode); + writePOD(os, e.stackable); + writePOD(os, e.destroyOnCancel); + writePOD(os, e.pad0); + writePOD(os, e.tickSoundId); + writePOD(os, e.iconColorRGBA); + } + return os.good(); +} + +WoweeSpellMarkers WoweeSpellMarkersLoader::load( + const std::string& basePath) { + WoweeSpellMarkers 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.markerId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name) || !readStr(is, e.description)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.spellId) || + !readStr(is, e.groundTexturePath) || + !readPOD(is, e.radius) || + !readPOD(is, e.duration) || + !readPOD(is, e.tickIntervalMs) || + !readPOD(is, e.decalColor) || + !readPOD(is, e.edgeFadeMode) || + !readPOD(is, e.stackable) || + !readPOD(is, e.destroyOnCancel) || + !readPOD(is, e.pad0) || + !readPOD(is, e.tickSoundId) || + !readPOD(is, e.iconColorRGBA)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeSpellMarkersLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeSpellMarkers WoweeSpellMarkersLoader::makeMageAoE( + const std::string& catalogName) { + using S = WoweeSpellMarkers; + WoweeSpellMarkers c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint32_t spellId, const char* texPath, + float radius, float duration, + uint32_t tickMs, uint32_t color, + uint8_t fade, uint32_t soundId, + const char* desc) { + S::Entry e; + e.markerId = id; e.name = name; e.description = desc; + e.spellId = spellId; + e.groundTexturePath = texPath; + e.radius = radius; + e.duration = duration; + e.tickIntervalMs = tickMs; + e.decalColor = color; + e.edgeFadeMode = fade; + e.stackable = 0; + e.destroyOnCancel = 1; + e.tickSoundId = soundId; + e.iconColorRGBA = packRgba(140, 200, 255); // mage blue + c.entries.push_back(e); + }; + add(1, "Blizzard", 10, "Spell\\Blizzard\\BlizzardGround.blp", + 12.0f, 8.0f, 1000, packRgba(180, 220, 255, 200), + S::SoftEdge, 11075, + "Mage Blizzard ground decal — 12yd radius, 8s " + "channel, 1s ticks. Soft-edge fade, blue tint, " + "wind-howl tick sound."); + add(2, "Flamestrike", 11, "Spell\\Flamestrike\\FlamestrikeRing.blp", + 8.0f, 8.0f, 1000, packRgba(220, 100, 30, 220), + S::Pulse, 6580, + "Mage Flamestrike ground decal — 8yd radius, 8s " + "lingering DoT after initial impact. Pulse fade " + "to suggest ongoing flames."); + add(3, "BlastWaveRing", 13, "Spell\\BlastWave\\ImpactRing.blp", + 10.0f, 0.5f, 0, packRgba(255, 180, 80, 255), + S::Hard, 0, + "Mage Blast Wave impact ring — 10yd radius, 0.5s " + "burst, no ticks (single-instance damage). Hard " + "edge for sharp shockwave."); + add(4, "FrostNovaRing", 14, "Spell\\FrostNova\\FreezeRing.blp", + 10.0f, 1.0f, 0, packRgba(180, 220, 255, 180), + S::SoftEdge, 0, + "Mage Frost Nova freeze indicator — 10yd radius, " + "1s visible after cast (the actual root lasts 8s " + "via the spell aura, the marker is just the visual " + "cue)."); + return c; +} + +WoweeSpellMarkers WoweeSpellMarkersLoader::makeRaidHazards( + const std::string& catalogName) { + using S = WoweeSpellMarkers; + WoweeSpellMarkers c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint32_t spellId, const char* texPath, + float radius, float duration, + uint32_t tickMs, uint32_t color, + uint8_t fade, const char* desc) { + S::Entry e; + e.markerId = id; e.name = name; e.description = desc; + e.spellId = spellId; + e.groundTexturePath = texPath; + e.radius = radius; + e.duration = duration; + e.tickIntervalMs = tickMs; + e.decalColor = color; + e.edgeFadeMode = fade; + e.stackable = 0; + e.destroyOnCancel = 0; // hazards persist + // until duration expires + e.tickSoundId = 0; + e.iconColorRGBA = packRgba(220, 80, 80); // raid red + c.entries.push_back(e); + }; + add(100, "PutricidePoisonPool", 70341, + "Spell\\Poison\\PoisonPool.blp", + 4.0f, 30.0f, 500, packRgba(80, 220, 60, 200), + S::Pulse, + "Putricide poison-pool ground decal — 4yd " + "radius, 30s, 500ms damage ticks. Pulse fade. " + "Standing in this is a wipe risk."); + add(101, "SindragosaFrostTomb", 70106, + "Spell\\Frost\\IceBlock.blp", + 2.5f, 12.0f, 0, packRgba(180, 220, 255, 220), + S::Hard, + "Sindragosa frost-tomb circle — 2.5yd radius, " + "12s, no ticks (damage is from the ice block aura, " + "not the marker). Hard edge for clear stay-out " + "boundary."); + add(102, "SaurfangBloodFrenzy", 72444, + "Spell\\Blood\\BloodFrenzy.blp", + 15.0f, 60.0f, 1000, packRgba(220, 60, 60, 180), + S::SoftEdge, + "Deathbringer Saurfang Mark of the Fallen " + "Champion zone — 15yd radius, 60s, 1s healing " + "ticks for Saurfang. DPS this player or wipe."); + add(103, "ProfessorPutricideShadow", 70447, + "Spell\\Shadow\\ShadowPuddle.blp", + 5.0f, 25.0f, 500, packRgba(60, 30, 100, 200), + S::Pulse, + "DBS / Putricide phase 2 shadow puddle — 5yd " + "radius, 25s, 500ms shadow ticks."); + add(104, "MarrowgarBoneStorm", 70233, + "Spell\\Bone\\BoneStorm.blp", + 18.0f, 20.0f, 1000, packRgba(220, 220, 200, 180), + S::Pulse, + "Lord Marrowgar Bone Storm radius — 18yd, 20s, " + "1s ticks. Marker tracks Marrowgar himself; " + "stays during the channel, vanishes when he " + "stops."); + return c; +} + +WoweeSpellMarkers WoweeSpellMarkersLoader::makeEnvironment( + const std::string& catalogName) { + using S = WoweeSpellMarkers; + WoweeSpellMarkers c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint32_t spellId, const char* texPath, + float radius, float duration, + uint32_t tickMs, uint32_t color, + uint8_t fade, const char* desc) { + S::Entry e; + e.markerId = id; e.name = name; e.description = desc; + e.spellId = spellId; + e.groundTexturePath = texPath; + e.radius = radius; + e.duration = duration; + e.tickIntervalMs = tickMs; + e.decalColor = color; + e.edgeFadeMode = fade; + e.stackable = 1; // environmental effects + // can stack (multiple + // lightning strikes) + e.destroyOnCancel = 0; // environment, no caster + // to cancel + e.tickSoundId = 0; + e.iconColorRGBA = packRgba(180, 180, 200); // env neutral + c.entries.push_back(e); + }; + add(200, "WintergraspLightning", 60014, + "Spell\\Weather\\LightningStrike.blp", + 6.0f, 1.0f, 0, packRgba(220, 220, 100, 240), + S::Hard, + "Wintergrasp open-world lightning strike — 6yd " + "blast radius, 1s flash. Hard edge for clear " + "danger zone."); + add(201, "SilithusSandstormCone", 38567, + "Spell\\Weather\\SandstormCone.blp", + 25.0f, 0.0f, 2000, packRgba(220, 200, 140, 100), + S::SoftEdge, + "Silithus environmental sandstorm cone — 25yd " + "radius, no time limit (until weather changes), " + "2s movement-slow ticks."); + add(202, "OpenWorldBlizzardZone", 30000, + "Spell\\Weather\\BlizzardZone.blp", + 40.0f, 0.0f, 5000, packRgba(220, 220, 240, 80), + S::SoftEdge, + "Northrend open-world blizzard zone — 40yd " + "radius, no time limit, 5s minor frost ticks. " + "Pure visual + ambient gameplay (no real damage " + "in Wrath; placeholder for storm system)."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index a3fcf86a..d65129b5 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -319,6 +319,8 @@ const char* const kArgRequired[] = { "--gen-tbd", "--gen-tbd-horde", "--gen-tbd-faction", "--info-wtbd", "--validate-wtbd", "--export-wtbd-json", "--import-wtbd-json", + "--gen-spm", "--gen-spm-raid", "--gen-spm-env", + "--info-wspm", "--validate-wspm", "--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 b4178e3d..a02e7c46 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -148,6 +148,7 @@ #include "cli_emotes_catalog.hpp" #include "cli_buff_book_catalog.hpp" #include "cli_tabards_catalog.hpp" +#include "cli_spell_markers_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_quest_objective.hpp" @@ -339,6 +340,7 @@ constexpr DispatchFn kDispatchTable[] = { handleEmotesCatalog, handleBuffBookCatalog, handleTabardsCatalog, + handleSpellMarkersCatalog, handleCatalogPluck, handleCatalogFind, handleQuestObjective, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index ec471930..199fb147 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -106,6 +106,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'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','T','B','D'}, ".wtbd", "guilds", "--info-wtbd", "Tabard design / heraldry catalog"}, + {{'W','S','P','M'}, ".wspm", "spellfx", "--info-wspm", "Spell persistent marker 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 0c676d7b..4eafdfbf 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2209,6 +2209,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wtbd to a human-editable JSON sidecar (defaults to .wtbd.json; emits both backgroundPattern/borderPattern ints AND name strings; all 3 colors as 0xAARRGGBB uint32)\n"); std::printf(" --import-wtbd-json [out-base]\n"); std::printf(" Import a .wtbd.json sidecar back into binary .wtbd (backgroundPattern int OR \"solid\"/\"gradient\"/\"chevron\"/\"quartered\"/\"starburst\"; borderPattern int OR \"none\"/\"thin\"/\"thick\"/\"decorative\"; isApproved bool OR int)\n"); + std::printf(" --gen-spm [name]\n"); + std::printf(" Emit .wspm 4 mage AoE ground markers (Blizzard / Flamestrike / BlastWave ring / Frost Nova ring)\n"); + std::printf(" --gen-spm-raid [name]\n"); + std::printf(" Emit .wspm 5 boss-arena hazard zones (Putricide poison pool / Sindragosa frost tomb / Saurfang Mark zone / Putricide shadow puddle / Marrowgar Bone Storm)\n"); + std::printf(" --gen-spm-env [name]\n"); + std::printf(" Emit .wspm 3 environmental ground effects (Wintergrasp lightning / Silithus sandstorm cone / open-world blizzard zone)\n"); + std::printf(" --info-wspm [--json]\n"); + std::printf(" Print WSPM entries (id / spell / radius / duration / tick interval / fade mode / stack-flag / destroy-on-cancel / name)\n"); + std::printf(" --validate-wspm [--json]\n"); + std::printf(" Static checks: id+name+spellId+texturePath required, radius>0, edgeFadeMode 0..2, no duplicate markerIds, no duplicate spellIds (spell-cast lookup ambiguity); warns on radius>100yd, tickIntervalMs<100ms (perf risk for stackable), decalColor alpha=0 (invisible)\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 b6c42587..a7b1a034 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -128,6 +128,7 @@ constexpr FormatRow kFormats[] = { {"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"}, {"WTBD", ".wtbd", "guilds", "guild_member tabard config blob", "Tabard design / heraldry catalog (3-color)"}, + {"WSPM", ".wspm", "spellfx", "AreaTrigger.dbc + decal blob", "Spell persistent marker catalog (AoE ground decals)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_spell_markers_catalog.cpp b/tools/editor/cli_spell_markers_catalog.cpp new file mode 100644 index 00000000..2e231b3d --- /dev/null +++ b/tools/editor/cli_spell_markers_catalog.cpp @@ -0,0 +1,280 @@ +#include "cli_spell_markers_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_spell_markers.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWspmExt(std::string base) { + stripExt(base, ".wspm"); + return base; +} + +const char* edgeFadeModeName(uint8_t m) { + using S = wowee::pipeline::WoweeSpellMarkers; + switch (m) { + case S::Hard: return "hard"; + case S::SoftEdge: return "softedge"; + case S::Pulse: return "pulse"; + default: return "unknown"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeSpellMarkers& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeSpellMarkersLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wspm\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeSpellMarkers& c, + const std::string& base) { + std::printf("Wrote %s.wspm\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" markers : %zu\n", c.entries.size()); +} + +int handleGenMage(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "MageAoEMarkers"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWspmExt(base); + auto c = wowee::pipeline::WoweeSpellMarkersLoader::makeMageAoE(name); + if (!saveOrError(c, base, "gen-spm")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenRaid(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "RaidHazardMarkers"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWspmExt(base); + auto c = wowee::pipeline::WoweeSpellMarkersLoader::makeRaidHazards(name); + if (!saveOrError(c, base, "gen-spm-raid")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenEnvironment(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "EnvironmentMarkers"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWspmExt(base); + auto c = wowee::pipeline::WoweeSpellMarkersLoader::makeEnvironment(name); + if (!saveOrError(c, base, "gen-spm-env")) 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 = stripWspmExt(base); + if (!wowee::pipeline::WoweeSpellMarkersLoader::exists(base)) { + std::fprintf(stderr, "WSPM not found: %s.wspm\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSpellMarkersLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wspm"] = base + ".wspm"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"markerId", e.markerId}, + {"name", e.name}, + {"description", e.description}, + {"spellId", e.spellId}, + {"groundTexturePath", e.groundTexturePath}, + {"radius", e.radius}, + {"duration", e.duration}, + {"tickIntervalMs", e.tickIntervalMs}, + {"decalColor", e.decalColor}, + {"edgeFadeMode", e.edgeFadeMode}, + {"edgeFadeModeName", + edgeFadeModeName(e.edgeFadeMode)}, + {"stackable", e.stackable != 0}, + {"destroyOnCancel", e.destroyOnCancel != 0}, + {"tickSoundId", e.tickSoundId}, + {"iconColorRGBA", e.iconColorRGBA}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WSPM: %s.wspm\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" markers : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id spell radius dur(s) tick(ms) fade stack dest name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %5u %5.1f %5.1f %5u %-9s %s %s %s\n", + e.markerId, e.spellId, e.radius, + e.duration, e.tickIntervalMs, + edgeFadeModeName(e.edgeFadeMode), + e.stackable ? "yes" : "no ", + e.destroyOnCancel ? "yes" : "no ", + 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 = stripWspmExt(base); + if (!wowee::pipeline::WoweeSpellMarkersLoader::exists(base)) { + std::fprintf(stderr, + "validate-wspm: WSPM not found: %s.wspm\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSpellMarkersLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set idsSeen; + std::set spellIdsSeen; + 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.markerId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.markerId == 0) + errors.push_back(ctx + ": markerId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.spellId == 0) { + errors.push_back(ctx + + ": spellId is 0 — marker is not bound to " + "any spell"); + } + if (e.groundTexturePath.empty()) { + errors.push_back(ctx + + ": groundTexturePath is empty — decal " + "would render as untextured solid color"); + } + if (e.radius <= 0.0f) { + errors.push_back(ctx + ": radius " + + std::to_string(e.radius) + + " <= 0 — decal would have zero area"); + } + if (e.radius > 100.0f) { + warnings.push_back(ctx + ": radius " + + std::to_string(e.radius) + + " > 100 yards — covers more than the " + "average raid arena, verify if intentional"); + } + if (e.edgeFadeMode > 2) { + errors.push_back(ctx + ": edgeFadeMode " + + std::to_string(e.edgeFadeMode) + + " out of range (must be 0..2)"); + } + // tickIntervalMs sanity: zero is legal (one-shot + // burst), but very short intervals would tank + // performance with many simultaneous markers. + if (e.tickIntervalMs > 0 && e.tickIntervalMs < 100) { + warnings.push_back(ctx + ": tickIntervalMs " + + std::to_string(e.tickIntervalMs) + + " < 100ms — fires more than 10× per second; " + "verify performance impact for stackable " + "markers"); + } + // Decal alpha=0 = invisible — likely an error. + uint8_t alpha = (e.decalColor >> 24) & 0xFF; + if (alpha == 0) { + warnings.push_back(ctx + + ": decalColor has alpha=0 — marker would " + "render fully transparent / invisible"); + } + // Multiple markers binding the same spellId is + // ambiguous (which decal does the spell spawn?). + if (e.spellId != 0 && + !spellIdsSeen.insert(e.spellId).second) { + errors.push_back(ctx + + ": spellId " + std::to_string(e.spellId) + + " is already bound by another marker — " + "spell-cast lookup would be ambiguous"); + } + if (!idsSeen.insert(e.markerId).second) { + errors.push_back(ctx + ": duplicate markerId"); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wspm"] = base + ".wspm"; + 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-wspm: %s.wspm\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu markers, all markerIds + " + "spellIds 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 handleSpellMarkersCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-spm") == 0 && i + 1 < argc) { + outRc = handleGenMage(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-spm-raid") == 0 && i + 1 < argc) { + outRc = handleGenRaid(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-spm-env") == 0 && i + 1 < argc) { + outRc = handleGenEnvironment(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wspm") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wspm") == 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_markers_catalog.hpp b/tools/editor/cli_spell_markers_catalog.hpp new file mode 100644 index 00000000..8da3d72e --- /dev/null +++ b/tools/editor/cli_spell_markers_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleSpellMarkersCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee