diff --git a/CMakeLists.txt b/CMakeLists.txt index be8b41b6..c31c3367 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -658,6 +658,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_quest_sorts.cpp src/pipeline/wowee_spell_ranges.cpp src/pipeline/wowee_spell_cast_times.cpp + src/pipeline/wowee_spell_durations.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1471,6 +1472,7 @@ add_executable(wowee_editor tools/editor/cli_quest_sorts_catalog.cpp tools/editor/cli_spell_ranges_catalog.cpp tools/editor/cli_spell_cast_times_catalog.cpp + tools/editor/cli_spell_durations_catalog.cpp tools/editor/cli_quest_objective.cpp tools/editor/cli_quest_reward.cpp tools/editor/cli_clone.cpp @@ -1607,6 +1609,7 @@ add_executable(wowee_editor src/pipeline/wowee_quest_sorts.cpp src/pipeline/wowee_spell_ranges.cpp src/pipeline/wowee_spell_cast_times.cpp + src/pipeline/wowee_spell_durations.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_spell_durations.hpp b/include/pipeline/wowee_spell_durations.hpp new file mode 100644 index 00000000..434a7c39 --- /dev/null +++ b/include/pipeline/wowee_spell_durations.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Spell Duration Index catalog (.wsdr) — novel +// replacement for Blizzard's SpellDuration.dbc plus the +// per-spell duration fields in Spell.dbc. Defines the +// categorical duration buckets that auras / DoTs / HoTs / +// buffs reference (5s / 30s / 5min / 1hr / UntilCancelled / +// UntilDeath). +// +// Completes the WSRG (range) + WSCT (cast time) + WSDR +// (duration) triplet — together these three small catalogs +// let the spell engine resolve every Frostbolt's range, +// cast time, and chill-debuff duration with three table +// lookups instead of duplicating per-rank fields across +// thousands of spells. +// +// Duration can scale with caster level via perLevelMs (a +// rank-1 Renew at 9s grows to 12s at lvl 60), then is +// clamped to maxDurationMs (e.g. world buffs cap at +// 4 hours). A negative baseDurationMs of -1 by convention +// means "until cancelled" (reads as Permanent kind in the +// engine HUD). +// +// Cross-references with previously-added formats: +// None — this catalog is consumed directly by the spell +// engine. WSPL spell entries reference durationId. +// +// Binary layout (little-endian): +// magic[4] = "WSDR" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// durationId (uint32) +// nameLen + name +// descLen + description +// durationKind (uint8) / pad[3] +// baseDurationMs (int32) +// perLevelMs (int32) +// maxDurationMs (int32) +// iconColorRGBA (uint32) +struct WoweeSpellDuration { + enum DurationKind : uint8_t { + Instant = 0, // 0ms — fires once, no aura + Timed = 1, // standard timed buff/debuff + TickBased = 2, // DoT / HoT (tick interval set elsewhere) + UntilCancelled = 3, // permanent until cancelled + UntilDeath = 4, // permanent until target dies + }; + + struct Entry { + uint32_t durationId = 0; + std::string name; + std::string description; + uint8_t durationKind = Timed; + int32_t baseDurationMs = 0; + int32_t perLevelMs = 0; + int32_t maxDurationMs = 0; + uint32_t iconColorRGBA = 0xFFFFFFFFu; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t durationId) const; + + // Resolve to the actual duration in ms at the given + // caster level. Clamps to maxDurationMs when set + // (>0). Returns -1 for kinds with negative base + // (UntilCancelled / UntilDeath) to signal "no timer". + int32_t resolveAtLevel(uint32_t durationId, + uint32_t casterLevel) const; + + static const char* durationKindName(uint8_t k); +}; + +class WoweeSpellDurationLoader { +public: + static bool save(const WoweeSpellDuration& cat, + const std::string& basePath); + static WoweeSpellDuration load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-sdr* variants. + // + // makeStarter — 5 baseline buckets (Instant 0, + // Short 5s, Medium 30s, Long 5min, + // Hour 1hr) spanning the most common + // duration tiers from instant fires to + // hour-long world buffs. + // makeBuffs — 4 long-duration buffs (PartyBuff 30m, + // RaidBuff 60m, WorldBuff 4hr, + // UntilDeath -1). UntilDeath uses the + // UntilDeath kind with a sentinel + // baseDurationMs of -1. + // makeDot — 4 DoT/HoT buckets (4-tick 12s, + // 5-tick 15s, 6-tick 18s, 8-tick 24s) + // using TickBased kind. Tick interval + // is implied at 3s/tick. + static WoweeSpellDuration makeStarter(const std::string& catalogName); + static WoweeSpellDuration makeBuffs(const std::string& catalogName); + static WoweeSpellDuration makeDot(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_spell_durations.cpp b/src/pipeline/wowee_spell_durations.cpp new file mode 100644 index 00000000..7a8ef417 --- /dev/null +++ b/src/pipeline/wowee_spell_durations.cpp @@ -0,0 +1,252 @@ +#include "pipeline/wowee_spell_durations.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'S', 'D', 'R'}; +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) != ".wsdr") { + base += ".wsdr"; + } + 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 WoweeSpellDuration::Entry* +WoweeSpellDuration::findById(uint32_t durationId) const { + for (const auto& e : entries) + if (e.durationId == durationId) return &e; + return nullptr; +} + +int32_t WoweeSpellDuration::resolveAtLevel(uint32_t durationId, + uint32_t casterLevel) const { + const Entry* e = findById(durationId); + if (!e) return 0; + // Sentinel: a negative base (typically -1) means the + // engine should treat this as "no timer" — UntilCancelled + // or UntilDeath. + if (e->baseDurationMs < 0) return -1; + int64_t ms = static_cast(e->baseDurationMs) + + static_cast(e->perLevelMs) * + static_cast(casterLevel); + if (e->maxDurationMs > 0 && ms > e->maxDurationMs) + ms = e->maxDurationMs; + if (ms < 0) ms = 0; + return static_cast(ms); +} + +const char* WoweeSpellDuration::durationKindName(uint8_t k) { + switch (k) { + case Instant: return "instant"; + case Timed: return "timed"; + case TickBased: return "tick"; + case UntilCancelled: return "until-cancelled"; + case UntilDeath: return "until-death"; + default: return "unknown"; + } +} + +bool WoweeSpellDurationLoader::save(const WoweeSpellDuration& 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.durationId); + writeStr(os, e.name); + writeStr(os, e.description); + writePOD(os, e.durationKind); + uint8_t pad3[3] = {0, 0, 0}; + os.write(reinterpret_cast(pad3), 3); + writePOD(os, e.baseDurationMs); + writePOD(os, e.perLevelMs); + writePOD(os, e.maxDurationMs); + writePOD(os, e.iconColorRGBA); + } + return os.good(); +} + +WoweeSpellDuration WoweeSpellDurationLoader::load( + const std::string& basePath) { + WoweeSpellDuration 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.durationId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name) || !readStr(is, e.description)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.durationKind)) { + out.entries.clear(); return out; + } + uint8_t pad3[3]; + is.read(reinterpret_cast(pad3), 3); + if (is.gcount() != 3) { out.entries.clear(); return out; } + if (!readPOD(is, e.baseDurationMs) || + !readPOD(is, e.perLevelMs) || + !readPOD(is, e.maxDurationMs) || + !readPOD(is, e.iconColorRGBA)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeSpellDurationLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeSpellDuration WoweeSpellDurationLoader::makeStarter( + const std::string& catalogName) { + WoweeSpellDuration c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, uint8_t kind, + int32_t baseMs, int32_t maxMs, + uint8_t r, uint8_t g, uint8_t b, + const char* desc) { + WoweeSpellDuration::Entry e; + e.durationId = id; e.name = name; e.description = desc; + e.durationKind = kind; + e.baseDurationMs = baseMs; + e.maxDurationMs = maxMs; + e.iconColorRGBA = packRgba(r, g, b); + c.entries.push_back(e); + }; + add(1, "Instant", WoweeSpellDuration::Instant, 0, 0, + 100, 240, 100, "Instant — fires once, no aura applied."); + add(2, "Short", WoweeSpellDuration::Timed, 5000, 0, + 140, 240, 140, "Short — 5s timed effect (snare / brief debuff)."); + add(3, "Medium", WoweeSpellDuration::Timed, 30000, 0, + 180, 240, 180, "Medium — 30s timed buff/debuff (most procs)."); + add(4, "Long", WoweeSpellDuration::Timed, 300000, 0, + 220, 240, 100, "Long — 5min timed buff (most class buffs)."); + add(5, "OneHour", WoweeSpellDuration::Timed, 3600000, 3600000, + 240, 220, 100, "OneHour — 60min capped buff (food / scroll)."); + return c; +} + +WoweeSpellDuration WoweeSpellDurationLoader::makeBuffs( + const std::string& catalogName) { + WoweeSpellDuration c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, uint8_t kind, + int32_t baseMs, int32_t maxMs, + const char* desc) { + WoweeSpellDuration::Entry e; + e.durationId = id; e.name = name; e.description = desc; + e.durationKind = kind; + e.baseDurationMs = baseMs; + e.maxDurationMs = maxMs; + e.iconColorRGBA = packRgba(100, 200, 240); // light blue + c.entries.push_back(e); + }; + add(100, "PartyBuff", WoweeSpellDuration::Timed, + 1800000, 1800000, "Party buff — 30 min (Mark of the Wild)."); + add(101, "RaidBuff", WoweeSpellDuration::Timed, + 3600000, 3600000, "Raid buff — 60 min (Power Word: " + "Fortitude)."); + add(102, "WorldBuff", WoweeSpellDuration::Timed, + 14400000, 14400000, "World buff — 4 hr (Onyxia / " + "Rallying Cry)."); + add(103, "UntilDeath", WoweeSpellDuration::UntilDeath, + -1, 0, "Permanent until target dies " + "(Soulstone resurrection)."); + return c; +} + +WoweeSpellDuration WoweeSpellDurationLoader::makeDot( + const std::string& catalogName) { + WoweeSpellDuration c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, int32_t baseMs, + int32_t perLevelMs, int32_t maxMs, + const char* desc) { + WoweeSpellDuration::Entry e; + e.durationId = id; e.name = name; e.description = desc; + e.durationKind = WoweeSpellDuration::TickBased; + e.baseDurationMs = baseMs; + e.perLevelMs = perLevelMs; + e.maxDurationMs = maxMs; + e.iconColorRGBA = packRgba(240, 100, 100); // red for DoT + c.entries.push_back(e); + }; + // Tick interval is canonically 3s; baseDuration = ticks * 3000. + add(200, "DoT4Tick", 12000, 100, 18000, + "DoT — 4 ticks @ 3s (12s base, +0.1s/lvl, cap 18s)."); + add(201, "DoT5Tick", 15000, 150, 24000, + "DoT — 5 ticks @ 3s (15s base, +0.15s/lvl, cap 24s)."); + add(202, "DoT6Tick", 18000, 200, 30000, + "DoT — 6 ticks @ 3s (18s base, +0.2s/lvl, cap 30s)."); + add(203, "DoT8Tick", 24000, 250, 36000, + "DoT — 8 ticks @ 3s (24s base, +0.25s/lvl, cap 36s)."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 2a5e6cd8..23b77a69 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -212,6 +212,8 @@ const char* const kArgRequired[] = { "--gen-sct", "--gen-sct-channel", "--gen-sct-ramp", "--info-wsct", "--validate-wsct", "--export-wsct-json", "--import-wsct-json", + "--gen-sdr", "--gen-sdr-buffs", "--gen-sdr-dot", + "--info-wsdr", "--validate-wsdr", "--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 59cf6aef..111fe44b 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -106,6 +106,7 @@ #include "cli_quest_sorts_catalog.hpp" #include "cli_spell_ranges_catalog.hpp" #include "cli_spell_cast_times_catalog.hpp" +#include "cli_spell_durations_catalog.hpp" #include "cli_quest_objective.hpp" #include "cli_quest_reward.hpp" #include "cli_clone.hpp" @@ -253,6 +254,7 @@ constexpr DispatchFn kDispatchTable[] = { handleQuestSortsCatalog, handleSpellRangesCatalog, handleSpellCastTimesCatalog, + handleSpellDurationsCatalog, handleQuestObjective, handleQuestReward, handleClone, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 41577a7d..c3105227 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -72,6 +72,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','Q','S','O'}, ".wqso", "quests", "--info-wqso", "Quest sort / category catalog"}, {{'W','S','R','G'}, ".wsrg", "spells", "--info-wsrg", "Spell range bucket catalog"}, {{'W','S','C','T'}, ".wsct", "spells", "--info-wsct", "Spell cast time bucket catalog"}, + {{'W','S','D','R'}, ".wsdr", "spells", "--info-wsdr", "Spell duration bucket 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 f4b24066..5a5c3f03 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -1715,6 +1715,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wsct to a human-editable JSON sidecar (defaults to .wsct.json)\n"); std::printf(" --import-wsct-json [out-base]\n"); std::printf(" Import a .wsct.json sidecar back into binary .wsct (accepts castKind int OR castKindName string)\n"); + std::printf(" --gen-sdr [name]\n"); + std::printf(" Emit .wsdr starter: 5 baseline duration buckets (Instant 0 / Short 5s / Medium 30s / Long 5min / OneHour 60min)\n"); + std::printf(" --gen-sdr-buffs [name]\n"); + std::printf(" Emit .wsdr 4 long-duration buffs (PartyBuff 30m / RaidBuff 60m / WorldBuff 4hr / UntilDeath -1)\n"); + std::printf(" --gen-sdr-dot [name]\n"); + std::printf(" Emit .wsdr 4 DoT/HoT buckets (4-tick 12s / 5-tick 15s / 6-tick 18s / 8-tick 24s)\n"); + std::printf(" --info-wsdr [--json]\n"); + std::printf(" Print WSDR entries (id / kind / baseMs / perLevelMs / maxMs / iconColor / name)\n"); + std::printf(" --validate-wsdr [--json]\n"); + std::printf(" Static checks: id+name required, durationKind 0..4, base>0 for Timed/TickBased, base<0 for permanent kinds, max>=base, no duplicate ids\n"); std::printf(" --gen-weather-temperate [zoneName]\n"); std::printf(" Emit .wow weather schedule: clear-dominant + occasional rain + fog (forest / grassland)\n"); std::printf(" --gen-weather-arctic [zoneName]\n"); diff --git a/tools/editor/cli_list_formats.cpp b/tools/editor/cli_list_formats.cpp index 1ec885d1..4b765ddb 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -94,6 +94,7 @@ constexpr FormatRow kFormats[] = { {"WQSO", ".wqso", "quests", "QuestSort.dbc + QuestInfo cats", "Quest sort / category catalog"}, {"WSRG", ".wsrg", "spells", "SpellRange.dbc + per-spell range", "Spell range bucket catalog"}, {"WSCT", ".wsct", "spells", "SpellCastTimes.dbc + cast scaling","Spell cast time bucket catalog"}, + {"WSDR", ".wsdr", "spells", "SpellDuration.dbc + per-spell dur","Spell duration bucket catalog"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_spell_durations_catalog.cpp b/tools/editor/cli_spell_durations_catalog.cpp new file mode 100644 index 00000000..07935c45 --- /dev/null +++ b/tools/editor/cli_spell_durations_catalog.cpp @@ -0,0 +1,255 @@ +#include "cli_spell_durations_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_spell_durations.hpp" +#include + +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWsdrExt(std::string base) { + stripExt(base, ".wsdr"); + return base; +} + +bool saveOrError(const wowee::pipeline::WoweeSpellDuration& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeSpellDurationLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wsdr\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeSpellDuration& c, + const std::string& base) { + std::printf("Wrote %s.wsdr\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" buckets : %zu\n", c.entries.size()); +} + +int handleGenStarter(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "StarterDurations"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWsdrExt(base); + auto c = wowee::pipeline::WoweeSpellDurationLoader::makeStarter(name); + if (!saveOrError(c, base, "gen-sdr")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenBuffs(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "LongDurationBuffs"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWsdrExt(base); + auto c = wowee::pipeline::WoweeSpellDurationLoader::makeBuffs(name); + if (!saveOrError(c, base, "gen-sdr-buffs")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenDot(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "DoTHoTDurations"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWsdrExt(base); + auto c = wowee::pipeline::WoweeSpellDurationLoader::makeDot(name); + if (!saveOrError(c, base, "gen-sdr-dot")) 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 = stripWsdrExt(base); + if (!wowee::pipeline::WoweeSpellDurationLoader::exists(base)) { + std::fprintf(stderr, "WSDR not found: %s.wsdr\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSpellDurationLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wsdr"] = base + ".wsdr"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"durationId", e.durationId}, + {"name", e.name}, + {"description", e.description}, + {"durationKind", e.durationKind}, + {"durationKindName", wowee::pipeline::WoweeSpellDuration::durationKindName(e.durationKind)}, + {"baseDurationMs", e.baseDurationMs}, + {"perLevelMs", e.perLevelMs}, + {"maxDurationMs", e.maxDurationMs}, + {"iconColorRGBA", e.iconColorRGBA}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WSDR: %s.wsdr\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" buckets : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id kind baseMs perLvl maxMs color name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %-15s %8d %8d %9d 0x%08x %s\n", + e.durationId, + wowee::pipeline::WoweeSpellDuration::durationKindName(e.durationKind), + e.baseDurationMs, e.perLevelMs, + e.maxDurationMs, + e.iconColorRGBA, 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 = stripWsdrExt(base); + if (!wowee::pipeline::WoweeSpellDurationLoader::exists(base)) { + std::fprintf(stderr, + "validate-wsdr: WSDR not found: %s.wsdr\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSpellDurationLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::vector idsSeen; + for (size_t k = 0; k < c.entries.size(); ++k) { + const auto& e = c.entries[k]; + std::string ctx = "entry " + std::to_string(k) + + " (id=" + std::to_string(e.durationId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.durationId == 0) + errors.push_back(ctx + ": durationId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.durationKind > wowee::pipeline::WoweeSpellDuration::UntilDeath) { + errors.push_back(ctx + ": durationKind " + + std::to_string(e.durationKind) + " not in 0..4"); + } + if (e.maxDurationMs < 0) + errors.push_back(ctx + ": maxDurationMs < 0"); + if (e.perLevelMs < 0) + warnings.push_back(ctx + + ": perLevelMs < 0 — duration shrinks with " + "level, double-check this is intentional"); + // Instant kind should have base == 0. + if (e.durationKind == wowee::pipeline::WoweeSpellDuration::Instant && + e.baseDurationMs != 0) { + warnings.push_back(ctx + + ": Instant kind with baseDurationMs=" + + std::to_string(e.baseDurationMs) + + " — engine will track it as a timed aura"); + } + // UntilCancelled / UntilDeath should signal "no + // timer" via baseDurationMs<0; otherwise the engine + // would tick down to expiry. + if ((e.durationKind == wowee::pipeline::WoweeSpellDuration::UntilCancelled || + e.durationKind == wowee::pipeline::WoweeSpellDuration::UntilDeath) && + e.baseDurationMs >= 0) { + warnings.push_back(ctx + + ": permanent kind with non-negative " + "baseDurationMs — engine treats this as timed; " + "set baseDurationMs=-1 to flag as no-timer"); + } + // Timed/TickBased should have base > 0. + if ((e.durationKind == wowee::pipeline::WoweeSpellDuration::Timed || + e.durationKind == wowee::pipeline::WoweeSpellDuration::TickBased) && + e.baseDurationMs <= 0) { + errors.push_back(ctx + + ": Timed/TickBased kind requires " + "baseDurationMs > 0"); + } + // maxDurationMs 0 && e.baseDurationMs > e.maxDurationMs) { + errors.push_back(ctx + ": baseDurationMs " + + std::to_string(e.baseDurationMs) + + " > maxDurationMs " + + std::to_string(e.maxDurationMs)); + } + for (uint32_t prev : idsSeen) { + if (prev == e.durationId) { + errors.push_back(ctx + ": duplicate durationId"); + break; + } + } + idsSeen.push_back(e.durationId); + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wsdr"] = base + ".wsdr"; + 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-wsdr: %s.wsdr\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu buckets, all durationIds 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 handleSpellDurationsCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-sdr") == 0 && i + 1 < argc) { + outRc = handleGenStarter(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-sdr-buffs") == 0 && i + 1 < argc) { + outRc = handleGenBuffs(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-sdr-dot") == 0 && i + 1 < argc) { + outRc = handleGenDot(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wsdr") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wsdr") == 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_durations_catalog.hpp b/tools/editor/cli_spell_durations_catalog.hpp new file mode 100644 index 00000000..b9a1a4cf --- /dev/null +++ b/tools/editor/cli_spell_durations_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleSpellDurationsCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee