diff --git a/CMakeLists.txt b/CMakeLists.txt index 21d02a86..be8b41b6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -657,6 +657,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_unit_movement.cpp src/pipeline/wowee_quest_sorts.cpp src/pipeline/wowee_spell_ranges.cpp + src/pipeline/wowee_spell_cast_times.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1469,6 +1470,7 @@ add_executable(wowee_editor tools/editor/cli_unit_movement_catalog.cpp 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_quest_objective.cpp tools/editor/cli_quest_reward.cpp tools/editor/cli_clone.cpp @@ -1604,6 +1606,7 @@ add_executable(wowee_editor src/pipeline/wowee_unit_movement.cpp src/pipeline/wowee_quest_sorts.cpp src/pipeline/wowee_spell_ranges.cpp + src/pipeline/wowee_spell_cast_times.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_spell_cast_times.hpp b/include/pipeline/wowee_spell_cast_times.hpp new file mode 100644 index 00000000..6def709f --- /dev/null +++ b/include/pipeline/wowee_spell_cast_times.hpp @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Spell Cast Time Index catalog (.wsct) — novel +// replacement for Blizzard's SpellCastTimes.dbc plus the +// per-spell castTime fields in Spell.dbc. Defines the +// categorical cast-time buckets that spells reference +// (instant / short / medium / long), so thousands of spells +// can share the same timing metadata instead of each +// embedding their own ms count. +// +// Cast time can scale with character level — Frostbolt at +// rank 1 might cast in 1500ms, but the rank-11 version +// references a bucket where baseCastMs and perLevelMs +// combine to give 2500ms by lvl 60. Haste is then applied +// on top of the bucket result, clamped to [minCastMs, +// maxCastMs]. +// +// Companion catalog to WSRG (Spell Range Index) — together +// they let the spell engine look up "Frostbolt's range +// bucket = id 3 (Spell, 0-30y)" and "Frostbolt's cast time +// bucket = id 5 (LongCast, 3000ms base)" with two table +// reads instead of carrying duplicate per-spell data for +// every rank of every spell. +// +// Cross-references with previously-added formats: +// None — this catalog is consumed directly by the spell +// engine. WSPL spell entries reference castTimeId. +// +// Binary layout (little-endian): +// magic[4] = "WSCT" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// castTimeId (uint32) +// nameLen + name +// descLen + description +// castKind (uint8) / pad[3] +// baseCastMs (int32) +// perLevelMs (int32) +// minCastMs (int32) +// maxCastMs (int32) +// iconColorRGBA (uint32) +struct WoweeSpellCastTime { + enum CastKind : uint8_t { + Instant = 0, // 0ms — fires on key release + Cast = 1, // standard cast, can be haste-shortened + Channel = 2, // channels for full duration + DelayedCast = 3, // queued / next-server-tick cast + ChargeCast = 4, // hold-to-power spell (Heroic Strike) + }; + + struct Entry { + uint32_t castTimeId = 0; + std::string name; + std::string description; + uint8_t castKind = Cast; + int32_t baseCastMs = 0; + int32_t perLevelMs = 0; + int32_t minCastMs = 0; + int32_t maxCastMs = 0; + uint32_t iconColorRGBA = 0xFFFFFFFFu; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t castTimeId) const; + + // Resolve to the actual ms a spell will cast at the + // given character level, before haste is applied. + // Clamps to [minCastMs, maxCastMs] when those are set. + int32_t resolveAtLevel(uint32_t castTimeId, + uint32_t characterLevel) const; + + static const char* castKindName(uint8_t k); +}; + +class WoweeSpellCastTimeLoader { +public: + static bool save(const WoweeSpellCastTime& cat, + const std::string& basePath); + static WoweeSpellCastTime load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-sct* variants. + // + // makeStarter — 4 baseline buckets (Instant 0ms, + // FastCast 1000ms, MediumCast 1500ms, + // LongCast 3000ms) covering the + // most common cast time tiers. + // makeChannel — 3 channeled-spell buckets (TickEvery1s + // 1000ms, TickEvery2s 2000ms, + // TickEvery3s 3000ms) for AoE channels + // like Arcane Missiles / Drain Life. + // makeRamp — 4 level-scaled buckets where + // perLevelMs > 0 — cast time grows + // with character level, simulating + // higher-rank spells with longer cast + // times. + static WoweeSpellCastTime makeStarter(const std::string& catalogName); + static WoweeSpellCastTime makeChannel(const std::string& catalogName); + static WoweeSpellCastTime makeRamp(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_spell_cast_times.cpp b/src/pipeline/wowee_spell_cast_times.cpp new file mode 100644 index 00000000..c2d0a988 --- /dev/null +++ b/src/pipeline/wowee_spell_cast_times.cpp @@ -0,0 +1,257 @@ +#include "pipeline/wowee_spell_cast_times.hpp" + +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'S', 'C', 'T'}; +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) != ".wsct") { + base += ".wsct"; + } + 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 WoweeSpellCastTime::Entry* +WoweeSpellCastTime::findById(uint32_t castTimeId) const { + for (const auto& e : entries) + if (e.castTimeId == castTimeId) return &e; + return nullptr; +} + +int32_t WoweeSpellCastTime::resolveAtLevel(uint32_t castTimeId, + uint32_t characterLevel) const { + const Entry* e = findById(castTimeId); + if (!e) return 0; + int64_t ms = static_cast(e->baseCastMs) + + static_cast(e->perLevelMs) * + static_cast(characterLevel); + if (e->minCastMs != 0 || e->maxCastMs != 0) { + // Clamp only when bounds are non-trivial — minCastMs= + // maxCastMs=0 means "no clamp configured" rather than + // "must be exactly zero". + if (ms < e->minCastMs) ms = e->minCastMs; + if (e->maxCastMs > 0 && ms > e->maxCastMs) ms = e->maxCastMs; + } + if (ms < 0) ms = 0; + return static_cast(ms); +} + +const char* WoweeSpellCastTime::castKindName(uint8_t k) { + switch (k) { + case Instant: return "instant"; + case Cast: return "cast"; + case Channel: return "channel"; + case DelayedCast: return "delayed"; + case ChargeCast: return "charge"; + default: return "unknown"; + } +} + +bool WoweeSpellCastTimeLoader::save(const WoweeSpellCastTime& 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.castTimeId); + writeStr(os, e.name); + writeStr(os, e.description); + writePOD(os, e.castKind); + uint8_t pad3[3] = {0, 0, 0}; + os.write(reinterpret_cast(pad3), 3); + writePOD(os, e.baseCastMs); + writePOD(os, e.perLevelMs); + writePOD(os, e.minCastMs); + writePOD(os, e.maxCastMs); + writePOD(os, e.iconColorRGBA); + } + return os.good(); +} + +WoweeSpellCastTime WoweeSpellCastTimeLoader::load( + const std::string& basePath) { + WoweeSpellCastTime 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.castTimeId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name) || !readStr(is, e.description)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.castKind)) { + 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.baseCastMs) || + !readPOD(is, e.perLevelMs) || + !readPOD(is, e.minCastMs) || + !readPOD(is, e.maxCastMs) || + !readPOD(is, e.iconColorRGBA)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeSpellCastTimeLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeSpellCastTime WoweeSpellCastTimeLoader::makeStarter( + const std::string& catalogName) { + WoweeSpellCastTime c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, uint8_t kind, + int32_t baseMs, uint8_t r, uint8_t g, uint8_t b, + const char* desc) { + WoweeSpellCastTime::Entry e; + e.castTimeId = id; e.name = name; e.description = desc; + e.castKind = kind; + e.baseCastMs = baseMs; + // Starter buckets do not scale with level and don't + // clamp — leave perLevel=0, min=0, max=0. + e.iconColorRGBA = packRgba(r, g, b); + c.entries.push_back(e); + }; + add(1, "Instant", WoweeSpellCastTime::Instant, 0, + 100, 240, 100, "Instant — fires on cast (0ms)."); + add(2, "FastCast", WoweeSpellCastTime::Cast, 1000, + 180, 240, 100, "Fast cast — 1.0s base."); + add(3, "MediumCast", WoweeSpellCastTime::Cast, 1500, + 240, 240, 100, "Medium cast — 1.5s base (Frostbolt rank 1)."); + add(4, "LongCast", WoweeSpellCastTime::Cast, 3000, + 240, 180, 100, "Long cast — 3.0s base (Pyroblast)."); + return c; +} + +WoweeSpellCastTime WoweeSpellCastTimeLoader::makeChannel( + const std::string& catalogName) { + WoweeSpellCastTime c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, int32_t baseMs, + const char* desc) { + WoweeSpellCastTime::Entry e; + e.castTimeId = id; e.name = name; e.description = desc; + e.castKind = WoweeSpellCastTime::Channel; + e.baseCastMs = baseMs; + // Channels are normally not haste-clamped; min/max + // stay 0 and the engine treats baseCastMs as the + // total channel duration. + e.iconColorRGBA = packRgba(180, 100, 240); // purple + c.entries.push_back(e); + }; + add(100, "TickEvery1s", 3000, + "Channel — 3s total, ticks every 1s (Drain Life)."); + add(101, "TickEvery2s", 6000, + "Channel — 6s total, ticks every 2s (Mind Flay)."); + add(102, "TickEvery3s", 9000, + "Channel — 9s total, ticks every 3s (Tranquility)."); + return c; +} + +WoweeSpellCastTime WoweeSpellCastTimeLoader::makeRamp( + const std::string& catalogName) { + WoweeSpellCastTime c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, int32_t baseMs, + int32_t perLevelMs, int32_t minMs, int32_t maxMs, + const char* desc) { + WoweeSpellCastTime::Entry e; + e.castTimeId = id; e.name = name; e.description = desc; + e.castKind = WoweeSpellCastTime::Cast; + e.baseCastMs = baseMs; + e.perLevelMs = perLevelMs; + e.minCastMs = minMs; + e.maxCastMs = maxMs; + e.iconColorRGBA = packRgba(240, 100, 180); // pink + c.entries.push_back(e); + }; + // baseCastMs is the level-1 value; +perLevelMs per + // character level, clamped to [minMs, maxMs] for haste + // and end-game scaling. + add(200, "ScalingShort", 500, 10, 500, 2000, + "Level-scaled short cast: 0.5s + 10ms/lvl (clamps " + "0.5..2.0s)."); + add(201, "ScalingMedium", 1000, 20, 1000, 3000, + "Level-scaled medium cast: 1.0s + 20ms/lvl (clamps " + "1.0..3.0s)."); + add(202, "ScalingLong", 2000, 30, 2000, 5000, + "Level-scaled long cast: 2.0s + 30ms/lvl (clamps " + "2.0..5.0s)."); + add(203, "ScalingHuge", 3000, 50, 3000, 10000, + "Level-scaled huge cast: 3.0s + 50ms/lvl (clamps " + "3.0..10.0s)."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 732f4d90..b0bbce82 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -209,6 +209,8 @@ const char* const kArgRequired[] = { "--gen-srg", "--gen-srg-ranged", "--gen-srg-friendly", "--info-wsrg", "--validate-wsrg", "--export-wsrg-json", "--import-wsrg-json", + "--gen-sct", "--gen-sct-channel", "--gen-sct-ramp", + "--info-wsct", "--validate-wsct", "--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 e767e2db..59cf6aef 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -105,6 +105,7 @@ #include "cli_unit_movement_catalog.hpp" #include "cli_quest_sorts_catalog.hpp" #include "cli_spell_ranges_catalog.hpp" +#include "cli_spell_cast_times_catalog.hpp" #include "cli_quest_objective.hpp" #include "cli_quest_reward.hpp" #include "cli_clone.hpp" @@ -251,6 +252,7 @@ constexpr DispatchFn kDispatchTable[] = { handleUnitMovementCatalog, handleQuestSortsCatalog, handleSpellRangesCatalog, + handleSpellCastTimesCatalog, handleQuestObjective, handleQuestReward, handleClone, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 4c627b21..41577a7d 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -71,6 +71,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','U','M','V'}, ".wumv", "stats", "--info-wumv", "Unit movement type catalog"}, {{'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','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 938da62d..197a01a4 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -1701,6 +1701,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wsrg to a human-editable JSON sidecar (defaults to .wsrg.json)\n"); std::printf(" --import-wsrg-json [out-base]\n"); std::printf(" Import a .wsrg.json sidecar back into binary .wsrg (accepts rangeKind int OR rangeKindName string)\n"); + std::printf(" --gen-sct [name]\n"); + std::printf(" Emit .wsct starter: 4 baseline cast time buckets (Instant 0ms / FastCast 1s / MediumCast 1.5s / LongCast 3s)\n"); + std::printf(" --gen-sct-channel [name]\n"); + std::printf(" Emit .wsct 3 channeled-spell buckets (TickEvery1s 3s total / TickEvery2s 6s / TickEvery3s 9s)\n"); + std::printf(" --gen-sct-ramp [name]\n"); + std::printf(" Emit .wsct 4 level-scaled buckets (perLevelMs > 0; baseMs grows with level, clamped to min/max)\n"); + std::printf(" --info-wsct [--json]\n"); + std::printf(" Print WSCT entries (id / kind / baseMs / perLevelMs / minMs / maxMs / iconColor / name)\n"); + std::printf(" --validate-wsct [--json]\n"); + std::printf(" Static checks: id+name required, castKind 0..4, baseMs>=0, min<=max, no duplicate ids; warns on Instant+nonzero base, errors on Channel+0 base\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 7399ec00..1ec885d1 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -93,6 +93,7 @@ constexpr FormatRow kFormats[] = { {"WUMV", ".wumv", "stats", "UnitMovement.dbc + speed mods", "Unit movement type / speed catalog"}, {"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"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_spell_cast_times_catalog.cpp b/tools/editor/cli_spell_cast_times_catalog.cpp new file mode 100644 index 00000000..7c1c6638 --- /dev/null +++ b/tools/editor/cli_spell_cast_times_catalog.cpp @@ -0,0 +1,245 @@ +#include "cli_spell_cast_times_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_spell_cast_times.hpp" +#include + +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWsctExt(std::string base) { + stripExt(base, ".wsct"); + return base; +} + +bool saveOrError(const wowee::pipeline::WoweeSpellCastTime& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeSpellCastTimeLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wsct\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeSpellCastTime& c, + const std::string& base) { + std::printf("Wrote %s.wsct\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 = "StarterCastTimes"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWsctExt(base); + auto c = wowee::pipeline::WoweeSpellCastTimeLoader::makeStarter(name); + if (!saveOrError(c, base, "gen-sct")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenChannel(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "ChannelCastTimes"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWsctExt(base); + auto c = wowee::pipeline::WoweeSpellCastTimeLoader::makeChannel(name); + if (!saveOrError(c, base, "gen-sct-channel")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenRamp(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "LevelScaledCastTimes"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWsctExt(base); + auto c = wowee::pipeline::WoweeSpellCastTimeLoader::makeRamp(name); + if (!saveOrError(c, base, "gen-sct-ramp")) 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 = stripWsctExt(base); + if (!wowee::pipeline::WoweeSpellCastTimeLoader::exists(base)) { + std::fprintf(stderr, "WSCT not found: %s.wsct\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSpellCastTimeLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wsct"] = base + ".wsct"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"castTimeId", e.castTimeId}, + {"name", e.name}, + {"description", e.description}, + {"castKind", e.castKind}, + {"castKindName", wowee::pipeline::WoweeSpellCastTime::castKindName(e.castKind)}, + {"baseCastMs", e.baseCastMs}, + {"perLevelMs", e.perLevelMs}, + {"minCastMs", e.minCastMs}, + {"maxCastMs", e.maxCastMs}, + {"iconColorRGBA", e.iconColorRGBA}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WSCT: %s.wsct\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 minMs maxMs color name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %-8s %6d %6d %6d %7d 0x%08x %s\n", + e.castTimeId, + wowee::pipeline::WoweeSpellCastTime::castKindName(e.castKind), + e.baseCastMs, e.perLevelMs, + e.minCastMs, e.maxCastMs, + 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 = stripWsctExt(base); + if (!wowee::pipeline::WoweeSpellCastTimeLoader::exists(base)) { + std::fprintf(stderr, + "validate-wsct: WSCT not found: %s.wsct\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSpellCastTimeLoader::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.castTimeId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.castTimeId == 0) + errors.push_back(ctx + ": castTimeId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.castKind > wowee::pipeline::WoweeSpellCastTime::ChargeCast) { + errors.push_back(ctx + ": castKind " + + std::to_string(e.castKind) + " not in 0..4"); + } + if (e.baseCastMs < 0) + errors.push_back(ctx + ": baseCastMs < 0"); + if (e.perLevelMs < 0) + warnings.push_back(ctx + + ": perLevelMs < 0 — cast time shrinks with " + "level, double-check this is intentional"); + if (e.maxCastMs > 0 && e.minCastMs > e.maxCastMs) { + errors.push_back(ctx + ": minCastMs " + + std::to_string(e.minCastMs) + + " > maxCastMs " + std::to_string(e.maxCastMs)); + } + // Instant kind should have base == 0 — otherwise the + // engine would still display a cast bar. + if (e.castKind == wowee::pipeline::WoweeSpellCastTime::Instant && + e.baseCastMs != 0) { + warnings.push_back(ctx + + ": Instant kind with baseCastMs=" + + std::to_string(e.baseCastMs) + + " — engine will draw a cast bar (use Cast " + "kind if that's intended)"); + } + // Channel kind should have base > 0 — otherwise it + // would tick once and immediately end. + if (e.castKind == wowee::pipeline::WoweeSpellCastTime::Channel && + e.baseCastMs <= 0) { + errors.push_back(ctx + + ": Channel kind requires baseCastMs > 0 " + "(channel duration)"); + } + for (uint32_t prev : idsSeen) { + if (prev == e.castTimeId) { + errors.push_back(ctx + ": duplicate castTimeId"); + break; + } + } + idsSeen.push_back(e.castTimeId); + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wsct"] = base + ".wsct"; + 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-wsct: %s.wsct\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu buckets, all castTimeIds unique, all min<=max\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 handleSpellCastTimesCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-sct") == 0 && i + 1 < argc) { + outRc = handleGenStarter(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-sct-channel") == 0 && i + 1 < argc) { + outRc = handleGenChannel(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-sct-ramp") == 0 && i + 1 < argc) { + outRc = handleGenRamp(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wsct") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wsct") == 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_cast_times_catalog.hpp b/tools/editor/cli_spell_cast_times_catalog.hpp new file mode 100644 index 00000000..9ce97cf0 --- /dev/null +++ b/tools/editor/cli_spell_cast_times_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleSpellCastTimesCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee