feat(editor): add WSDR (Spell Duration Index) — completes WSRG/WSCT/WSDR triplet

Open replacement for SpellDuration.dbc plus per-spell duration
fields in Spell.dbc. Defines the categorical duration buckets
that auras / DoTs / HoTs / buffs reference (5s / 30s / 5min / 1hr
/ UntilCancelled / UntilDeath).

Together with WSRG (range) and WSCT (cast time), this completes a
small triplet of spell-metadata catalogs: instead of every
Frostbolt rank embedding its own range, cast time, and
chill-debuff duration as duplicate fields, each spell holds three
small integer ids that resolve through these three tables. The
engine retunes thousands of spells at once by editing one bucket.

Duration scales with caster level via perLevelMs (a rank-1 Renew
at 9s grows to 12s at lvl 60), then is clamped to maxDurationMs.
Negative baseDurationMs is the canonical sentinel for "no timer"
(UntilCancelled / UntilDeath); resolveAtLevel returns -1 for
those so HUD code can render the indefinite-duration glyph.

Three preset emitters: --gen-sdr (5 baseline tiers from instant
to one-hour), --gen-sdr-buffs (4 long-duration buffs including
UntilDeath), --gen-sdr-dot (4 tick-based DoT/HoT buckets at 3s
ticks). Validation enforces base>0 for Timed/TickBased, base<0
for permanent kinds, max>=base, durationKind 0..4, no duplicate
ids, and warns on Instant+nonzero base.

Wired through the cross-format table; WSDR appears automatically
in all 9 cross-format utilities. Format count 69 -> 70; CLI flag
count 899 -> 904.
This commit is contained in:
Kelsi 2026-05-09 21:41:55 -07:00
parent 479e96a68a
commit 98f899cf7c
10 changed files with 653 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1,115 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
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<Entry> 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

View file

@ -0,0 +1,252 @@
#include "pipeline/wowee_spell_durations.hpp"
#include <cstdio>
#include <cstring>
#include <fstream>
namespace wowee {
namespace pipeline {
namespace {
constexpr char kMagic[4] = {'W', 'S', 'D', 'R'};
constexpr uint32_t kVersion = 1;
template <typename T>
void writePOD(std::ofstream& os, const T& v) {
os.write(reinterpret_cast<const char*>(&v), sizeof(T));
}
template <typename T>
bool readPOD(std::ifstream& is, T& v) {
is.read(reinterpret_cast<char*>(&v), sizeof(T));
return is.gcount() == static_cast<std::streamsize>(sizeof(T));
}
void writeStr(std::ofstream& os, const std::string& s) {
uint32_t n = static_cast<uint32_t>(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<std::streamsize>(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<uint32_t>(a) << 24) |
(static_cast<uint32_t>(b) << 16) |
(static_cast<uint32_t>(g) << 8) |
static_cast<uint32_t>(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<int64_t>(e->baseDurationMs) +
static_cast<int64_t>(e->perLevelMs) *
static_cast<int64_t>(casterLevel);
if (e->maxDurationMs > 0 && ms > e->maxDurationMs)
ms = e->maxDurationMs;
if (ms < 0) ms = 0;
return static_cast<int32_t>(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<uint32_t>(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<const char*>(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<char*>(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

View file

@ -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",

View file

@ -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,

View file

@ -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"},

View file

@ -1715,6 +1715,16 @@ void printUsage(const char* argv0) {
std::printf(" Export binary .wsct to a human-editable JSON sidecar (defaults to <base>.wsct.json)\n");
std::printf(" --import-wsct-json <json-path> [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 <wsdr-base> [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 <wsdr-base> [name]\n");
std::printf(" Emit .wsdr 4 long-duration buffs (PartyBuff 30m / RaidBuff 60m / WorldBuff 4hr / UntilDeath -1)\n");
std::printf(" --gen-sdr-dot <wsdr-base> [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 <wsdr-base> [--json]\n");
std::printf(" Print WSDR entries (id / kind / baseMs / perLevelMs / maxMs / iconColor / name)\n");
std::printf(" --validate-wsdr <wsdr-base> [--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 <wow-base> [zoneName]\n");
std::printf(" Emit .wow weather schedule: clear-dominant + occasional rain + fog (forest / grassland)\n");
std::printf(" --gen-weather-arctic <wow-base> [zoneName]\n");

View file

@ -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

View file

@ -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 <nlohmann/json.hpp>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
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<std::string> errors;
std::vector<std::string> warnings;
if (c.entries.empty()) {
warnings.push_back("catalog has zero entries");
}
std::vector<uint32_t> 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<base is contradictory.
if (e.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

View file

@ -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