From b66e41df87ca7b751537ed03da89b11c0129adfe Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 10 May 2026 04:08:41 -0700 Subject: [PATCH] feat(pipeline): WCST combat stats baseline catalog (130th open format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel replacement for the per-class per-level base-stat scaling table that vanilla WoW scattered across CharBaseInfo.dbc + CharStartOutfit.dbc + GtChanceTo*.dbc + the hard-coded HP/mana- per-level constants in the server's StatSystem. Each WCST entry binds one (classId, level) pair to base health, mana, armor, and the five primary stats (Str/Agi/Sta/Int/Spi). Sparse design: presets emit ~6 sample levels per class with the runtime stat-interpolator computing intermediate levels. Three presets: --gen-cst-warrior Warrior (classId=1) sparse sample at L1/ 10/20/30/40/60. baseMana=0 across all entries (Warrior uses Rage) --gen-cst-mage Mage (classId=8) same 6 levels with mana growth tracking Intellect --gen-cst-starting All 9 vanilla classes at level 1 — shows per-class flat starting differences (Warrior/Paladin high Str; Hunter/Rogue high Agi; Mage/Priest/Warlock high Int; Shaman/Druid balanced) Validator catches: id+classId+level required, classId 1..11, level 1..60, zero baseHealth (player would die instantly), duplicate statIds, duplicate (classId,level) pairs (runtime stat-lookup tie). Warns on classId 6/10 (DK/Monk gap unused in vanilla), Warrior/Rogue baseMana > 0 (these classes use Rage/Energy not mana), and per-class monotonicity violations across all 8 stats — sorts by level, walks adjacent pairs, flags any stat that regresses as level increases (typo guard). Format count 129 -> 130. CLI flag count 1364 -> 1371. --- CMakeLists.txt | 3 + include/pipeline/wowee_combat_stats.hpp | 119 ++++++++ src/pipeline/wowee_combat_stats.cpp | 246 ++++++++++++++++ tools/editor/cli_arg_required.cpp | 2 + tools/editor/cli_combat_stats_catalog.cpp | 326 ++++++++++++++++++++++ tools/editor/cli_combat_stats_catalog.hpp | 12 + 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 + 10 files changed, 722 insertions(+) create mode 100644 include/pipeline/wowee_combat_stats.hpp create mode 100644 src/pipeline/wowee_combat_stats.cpp create mode 100644 tools/editor/cli_combat_stats_catalog.cpp create mode 100644 tools/editor/cli_combat_stats_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ac2cd7d..b7730255 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -718,6 +718,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_player_movement_anim.cpp src/pipeline/wowee_transit_schedule.cpp src/pipeline/wowee_mage_portals.cpp + src/pipeline/wowee_combat_stats.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1599,6 +1600,7 @@ add_executable(wowee_editor tools/editor/cli_player_movement_anim_catalog.cpp tools/editor/cli_transit_schedule_catalog.cpp tools/editor/cli_mage_portals_catalog.cpp + tools/editor/cli_combat_stats_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1799,6 +1801,7 @@ add_executable(wowee_editor src/pipeline/wowee_player_movement_anim.cpp src/pipeline/wowee_transit_schedule.cpp src/pipeline/wowee_mage_portals.cpp + src/pipeline/wowee_combat_stats.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_combat_stats.hpp b/include/pipeline/wowee_combat_stats.hpp new file mode 100644 index 00000000..36c6e6ee --- /dev/null +++ b/include/pipeline/wowee_combat_stats.hpp @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Combat Stats Baseline catalog (.wcst) +// — novel replacement for the per-class per-level +// base-stat scaling table that vanilla WoW +// scattered across CharBaseInfo.dbc + +// CharStartOutfit.dbc + GtChanceTo*.dbc + the +// hard-coded HP/mana-per-level constants in the +// server's StatSystem. Each WCST entry binds one +// (classId, level) pair to its base health, mana, +// armor, and the five primary stats (Str/Agi/Sta/ +// Int/Spi). Entries are sparse — typical preset +// emits ~6 sample levels per class, with the +// runtime interpolating between them for +// intermediate levels. +// +// Cross-references with previously-added formats: +// WCDB: classId references the playable-class +// catalog (1..11 in vanilla, with 6 + 10 +// unused). +// WSPK: the spell pack catalog gates spellbook +// tabs by classId — same id space. +// +// Binary layout (little-endian): +// magic[4] = "WCST" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// statId (uint32) — surrogate primary +// key +// classId (uint8) — 1..11 vanilla class +// level (uint8) — 1..60 vanilla cap +// pad0 (uint16) +// baseHealth (uint32) +// baseMana (uint32) — 0 if class doesn't +// use mana (Warrior, +// Rogue) +// baseStrength (uint16) +// baseAgility (uint16) +// baseStamina (uint16) +// baseIntellect (uint16) +// baseSpirit (uint16) +// pad1 (uint16) +// baseArmor (uint32) +struct WoweeCombatStats { + struct Entry { + uint32_t statId = 0; + uint8_t classId = 0; + uint8_t level = 0; + uint16_t pad0 = 0; + uint32_t baseHealth = 0; + uint32_t baseMana = 0; + uint16_t baseStrength = 0; + uint16_t baseAgility = 0; + uint16_t baseStamina = 0; + uint16_t baseIntellect = 0; + uint16_t baseSpirit = 0; + uint16_t pad1 = 0; + uint32_t baseArmor = 0; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t statId) const; + + // Returns the binding for an exact (classId, level) + // pair — used by the level-up handler to commit + // base-stat changes when a player dings. + const Entry* find(uint8_t classId, uint8_t level) const; + + // Returns all entries for a class, sorted by level. + // Used by the runtime stat-interpolator to find the + // bracketing two entries for an intermediate level. + std::vector findByClass(uint8_t classId) const; +}; + +class WoweeCombatStatsLoader { +public: + static bool save(const WoweeCombatStats& cat, + const std::string& basePath); + static WoweeCombatStats load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-cst* variants. + // + // makeWarriorStats — Warrior (classId=1) sparse + // sample at levels 1, 10, 20, + // 30, 40, 60. baseMana=0 for + // all entries (Warrior uses + // Rage, not mana). + // makeMageStats — Mage (classId=8) sparse + // sample at the same 6 + // levels. baseMana grows + // with Intellect. + // makeStartingLevels — All 9 vanilla classes + // (Warrior/Paladin/Hunter/ + // Rogue/Priest/Shaman/Mage/ + // Warlock/Druid) at level 1 + // only — illustrates per- + // class flat starting-stat + // differences. + static WoweeCombatStats makeWarriorStats(const std::string& catalogName); + static WoweeCombatStats makeMageStats(const std::string& catalogName); + static WoweeCombatStats makeStartingLevels(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_combat_stats.cpp b/src/pipeline/wowee_combat_stats.cpp new file mode 100644 index 00000000..e9eff1dd --- /dev/null +++ b/src/pipeline/wowee_combat_stats.cpp @@ -0,0 +1,246 @@ +#include "pipeline/wowee_combat_stats.hpp" + +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'C', 'S', '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) != ".wcst") { + base += ".wcst"; + } + return base; +} + +} // namespace + +const WoweeCombatStats::Entry* +WoweeCombatStats::findById(uint32_t statId) const { + for (const auto& e : entries) + if (e.statId == statId) return &e; + return nullptr; +} + +const WoweeCombatStats::Entry* +WoweeCombatStats::find(uint8_t classId, uint8_t level) const { + for (const auto& e : entries) + if (e.classId == classId && e.level == level) + return &e; + return nullptr; +} + +std::vector +WoweeCombatStats::findByClass(uint8_t classId) const { + std::vector out; + for (const auto& e : entries) + if (e.classId == classId) out.push_back(&e); + std::sort(out.begin(), out.end(), + [](const Entry* a, const Entry* b) { + return a->level < b->level; + }); + return out; +} + +bool WoweeCombatStatsLoader::save(const WoweeCombatStats& 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.statId); + writePOD(os, e.classId); + writePOD(os, e.level); + writePOD(os, e.pad0); + writePOD(os, e.baseHealth); + writePOD(os, e.baseMana); + writePOD(os, e.baseStrength); + writePOD(os, e.baseAgility); + writePOD(os, e.baseStamina); + writePOD(os, e.baseIntellect); + writePOD(os, e.baseSpirit); + writePOD(os, e.pad1); + writePOD(os, e.baseArmor); + } + return os.good(); +} + +WoweeCombatStats WoweeCombatStatsLoader::load( + const std::string& basePath) { + WoweeCombatStats 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.statId) || + !readPOD(is, e.classId) || + !readPOD(is, e.level) || + !readPOD(is, e.pad0) || + !readPOD(is, e.baseHealth) || + !readPOD(is, e.baseMana) || + !readPOD(is, e.baseStrength) || + !readPOD(is, e.baseAgility) || + !readPOD(is, e.baseStamina) || + !readPOD(is, e.baseIntellect) || + !readPOD(is, e.baseSpirit) || + !readPOD(is, e.pad1) || + !readPOD(is, e.baseArmor)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeCombatStatsLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +namespace { + +struct StatRow { + uint32_t statId; + uint8_t classId; + uint8_t level; + uint32_t hp; + uint32_t mana; + uint16_t str; + uint16_t agi; + uint16_t sta; + uint16_t intel; + uint16_t spi; + uint32_t armor; +}; + +WoweeCombatStats fromRows(const std::string& catalogName, + const std::vector& rows) { + WoweeCombatStats c; + c.name = catalogName; + for (const auto& r : rows) { + WoweeCombatStats::Entry e; + e.statId = r.statId; + e.classId = r.classId; + e.level = r.level; + e.baseHealth = r.hp; + e.baseMana = r.mana; + e.baseStrength = r.str; + e.baseAgility = r.agi; + e.baseStamina = r.sta; + e.baseIntellect = r.intel; + e.baseSpirit = r.spi; + e.baseArmor = r.armor; + c.entries.push_back(e); + } + return c; +} + +} // namespace + +WoweeCombatStats WoweeCombatStatsLoader::makeWarriorStats( + const std::string& catalogName) { + // Warrior (classId=1) sparse sample. Numbers + // approximate vanilla 1.12 base stats — Warrior + // uses Rage so baseMana=0 across all levels. + // Stats grow steadily; armor scales with Agility. + return fromRows(catalogName, { + {101, 1, 1, 60, 0, 23, 20, 23, 20, 20, 60}, + {102, 1, 10, 180, 0, 30, 26, 31, 22, 22, 130}, + {103, 1, 20, 400, 0, 40, 34, 42, 24, 24, 220}, + {104, 1, 30, 720, 0, 52, 44, 56, 26, 26, 330}, + {105, 1, 40, 1140, 0, 66, 56, 72, 28, 28, 460}, + {106, 1, 60, 2200, 0, 95, 80,105, 32, 32, 760}, + }); +} + +WoweeCombatStats WoweeCombatStatsLoader::makeMageStats( + const std::string& catalogName) { + // Mage (classId=8) sparse sample. baseMana grows + // with Intellect — Mage is the canonical mana- + // user. Lower base HP, higher Int/Spi than + // warrior at every level. + return fromRows(catalogName, { + {801, 8, 1, 50, 100, 20, 20, 20, 23, 23, 40}, + {802, 8, 10, 140, 340, 22, 22, 24, 32, 30, 90}, + {803, 8, 20, 320, 720, 25, 25, 30, 44, 40, 160}, + {804, 8, 30, 580, 1180, 28, 28, 38, 58, 52, 240}, + {805, 8, 40, 920, 1740, 32, 32, 48, 74, 66, 340}, + {806, 8, 60, 1780, 3120, 40, 40, 70,108, 95, 580}, + }); +} + +WoweeCombatStats WoweeCombatStatsLoader::makeStartingLevels( + const std::string& catalogName) { + // All 9 vanilla classes at level 1. classId 6 + // (Death Knight) and 10 (Monk) are unused in + // vanilla — skipped. Numbers reflect the per- + // class racial-base-stat skew (Warrior/Paladin + // high Str, Hunter/Rogue high Agi, Mage/Priest/ + // Warlock high Int, Shaman/Druid balanced). + return fromRows(catalogName, { + // statId class lvl hp mana str agi sta int spi armor + {1001, 1, 1, 60, 0, 23, 20, 23, 20, 20, 60}, + {1002, 2, 1, 60, 100, 22, 20, 22, 20, 21, 60}, // Paladin + {1003, 3, 1, 50, 0, 21, 23, 20, 20, 21, 50}, // Hunter + {1004, 4, 1, 55, 0, 20, 23, 21, 20, 20, 55}, // Rogue + {1005, 5, 1, 50, 120, 20, 20, 20, 22, 24, 40}, // Priest + {1007, 7, 1, 55, 100, 22, 21, 22, 21, 22, 50}, // Shaman + {1008, 8, 1, 50, 100, 20, 20, 20, 23, 23, 40}, // Mage + {1009, 9, 1, 50, 100, 20, 20, 21, 23, 22, 40}, // Warlock + {1011, 11, 1, 55, 100, 21, 21, 22, 22, 22, 50}, // Druid + }); +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 3ef1283e..6710b041 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -398,6 +398,8 @@ const char* const kArgRequired[] = { "--gen-prt-alliance", "--gen-prt-horde", "--gen-prt-teleports", "--info-wprt", "--validate-wprt", "--export-wprt-json", "--import-wprt-json", + "--gen-cst-warrior", "--gen-cst-mage", "--gen-cst-starting", + "--info-wcst", "--validate-wcst", "--gen-weather-temperate", "--gen-weather-arctic", "--gen-weather-desert", "--gen-weather-stormy", "--gen-zone-atmosphere", diff --git a/tools/editor/cli_combat_stats_catalog.cpp b/tools/editor/cli_combat_stats_catalog.cpp new file mode 100644 index 00000000..3343685e --- /dev/null +++ b/tools/editor/cli_combat_stats_catalog.cpp @@ -0,0 +1,326 @@ +#include "cli_combat_stats_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_combat_stats.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWcstExt(std::string base) { + stripExt(base, ".wcst"); + return base; +} + +const char* classIdName(uint8_t c) { + switch (c) { + case 1: return "Warrior"; + case 2: return "Paladin"; + case 3: return "Hunter"; + case 4: return "Rogue"; + case 5: return "Priest"; + case 7: return "Shaman"; + case 8: return "Mage"; + case 9: return "Warlock"; + case 11: return "Druid"; + default: return "?"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeCombatStats& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeCombatStatsLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wcst\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeCombatStats& c, + const std::string& base) { + std::printf("Wrote %s.wcst\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" entries : %zu\n", c.entries.size()); +} + +int handleGenWarrior(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "WarriorBaseStats"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWcstExt(base); + auto c = wowee::pipeline::WoweeCombatStatsLoader:: + makeWarriorStats(name); + if (!saveOrError(c, base, "gen-cst-warrior")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenMage(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "MageBaseStats"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWcstExt(base); + auto c = wowee::pipeline::WoweeCombatStatsLoader:: + makeMageStats(name); + if (!saveOrError(c, base, "gen-cst-mage")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenStarting(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "StartingLevelStats"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWcstExt(base); + auto c = wowee::pipeline::WoweeCombatStatsLoader:: + makeStartingLevels(name); + if (!saveOrError(c, base, "gen-cst-starting")) 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 = stripWcstExt(base); + if (!wowee::pipeline::WoweeCombatStatsLoader::exists(base)) { + std::fprintf(stderr, "WCST not found: %s.wcst\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeCombatStatsLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wcst"] = base + ".wcst"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"statId", e.statId}, + {"classId", e.classId}, + {"className", classIdName(e.classId)}, + {"level", e.level}, + {"baseHealth", e.baseHealth}, + {"baseMana", e.baseMana}, + {"baseStrength", e.baseStrength}, + {"baseAgility", e.baseAgility}, + {"baseStamina", e.baseStamina}, + {"baseIntellect", e.baseIntellect}, + {"baseSpirit", e.baseSpirit}, + {"baseArmor", e.baseArmor}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WCST: %s.wcst\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" entries : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id class lvl hp mana str agi sta int spi armor\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %-8s %3u %5u %5u %3u %3u %3u %3u %3u %5u\n", + e.statId, classIdName(e.classId), + e.level, e.baseHealth, e.baseMana, + e.baseStrength, e.baseAgility, + e.baseStamina, e.baseIntellect, + e.baseSpirit, e.baseArmor); + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWcstExt(base); + if (!wowee::pipeline::WoweeCombatStatsLoader::exists(base)) { + std::fprintf(stderr, + "validate-wcst: WCST not found: %s.wcst\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeCombatStatsLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set idsSeen; + using Pair = std::pair; + std::set classLevelPairs; + for (size_t k = 0; k < c.entries.size(); ++k) { + const auto& e = c.entries[k]; + std::string ctx = "entry " + std::to_string(k) + + " (statId=" + std::to_string(e.statId) + + " " + classIdName(e.classId) + + " L" + std::to_string(e.level) + ")"; + if (e.statId == 0) + errors.push_back(ctx + ": statId is 0"); + if (e.classId == 0 || e.classId > 11) { + errors.push_back(ctx + ": classId " + + std::to_string(e.classId) + + " out of vanilla range (1..11)"); + } + if (e.classId == 6 || e.classId == 10) { + warnings.push_back(ctx + ": classId " + + std::to_string(e.classId) + + " is unused in vanilla (DK/Monk gap)"); + } + if (e.level == 0 || e.level > 60) { + errors.push_back(ctx + ": level " + + std::to_string(e.level) + + " out of vanilla range (1..60)"); + } + if (e.baseHealth == 0) + errors.push_back(ctx + + ": baseHealth is 0 (player would die " + "instantly)"); + // Warrior(1) and Rogue(4) use Rage/Energy + // respectively — baseMana > 0 for these + // classes is wrong. + if ((e.classId == 1 || e.classId == 4) && + e.baseMana > 0) { + warnings.push_back(ctx + + ": baseMana=" + + std::to_string(e.baseMana) + + " on Warrior/Rogue — these classes use " + "Rage/Energy, not mana. Likely typo"); + } + // (classId, level) MUST be unique — runtime + // dispatch by this pair would tie. + Pair p{e.classId, e.level}; + if (!classLevelPairs.insert(p).second) { + errors.push_back(ctx + + ": duplicate (classId=" + + std::to_string(e.classId) + + ", level=" + std::to_string(e.level) + + ") — runtime stat-lookup tie"); + } + if (!idsSeen.insert(e.statId).second) { + errors.push_back(ctx + ": duplicate statId"); + } + } + // Monotonicity: per-class, when sorted by level, + // no stat (HP/mana/Str/Agi/Sta/Int/Spi/armor) + // should regress (decrease) as level increases. + // Regression suggests a typo in the data table. + std::map> byClass; + for (const auto& e : c.entries) byClass[e.classId].push_back(&e); + for (auto& [classId, vec] : byClass) { + std::sort(vec.begin(), vec.end(), + [](const wowee::pipeline::WoweeCombatStats:: + Entry* a, + const wowee::pipeline::WoweeCombatStats:: + Entry* b) { + return a->level < b->level; + }); + for (size_t k = 1; k < vec.size(); ++k) { + const auto* prev = vec[k-1]; + const auto* cur = vec[k]; + auto chk = [&](const char* statName, + uint64_t prevV, uint64_t curV) { + if (curV < prevV) { + warnings.push_back( + std::string("monotonicity: ") + + classIdName(classId) + + " " + statName + + " regresses from " + + std::to_string(prevV) + + " (L" + std::to_string(prev->level) + + ") to " + + std::to_string(curV) + + " (L" + std::to_string(cur->level) + + ") — likely typo"); + } + }; + chk("baseHealth", prev->baseHealth, cur->baseHealth); + chk("baseMana", prev->baseMana, cur->baseMana); + chk("baseStrength", prev->baseStrength, cur->baseStrength); + chk("baseAgility", prev->baseAgility, cur->baseAgility); + chk("baseStamina", prev->baseStamina, cur->baseStamina); + chk("baseIntellect",prev->baseIntellect,cur->baseIntellect); + chk("baseSpirit", prev->baseSpirit, cur->baseSpirit); + chk("baseArmor", prev->baseArmor, cur->baseArmor); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wcst"] = base + ".wcst"; + 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-wcst: %s.wcst\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu entries, all statIds + " + "(classId,level) unique, classId 1..11, " + "level 1..60, no zero baseHealth, no " + "Warrior/Rogue mana, all stats " + "monotonic over level\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 handleCombatStatsCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-cst-warrior") == 0 && + i + 1 < argc) { + outRc = handleGenWarrior(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-cst-mage") == 0 && + i + 1 < argc) { + outRc = handleGenMage(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-cst-starting") == 0 && + i + 1 < argc) { + outRc = handleGenStarting(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wcst") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wcst") == 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_combat_stats_catalog.hpp b/tools/editor/cli_combat_stats_catalog.hpp new file mode 100644 index 00000000..5fa0e2fe --- /dev/null +++ b/tools/editor/cli_combat_stats_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleCombatStatsCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index 6826c5b7..ee00ed3e 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -174,6 +174,7 @@ #include "cli_player_movement_anim_catalog.hpp" #include "cli_transit_schedule_catalog.hpp" #include "cli_mage_portals_catalog.hpp" +#include "cli_combat_stats_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -393,6 +394,7 @@ constexpr DispatchFn kDispatchTable[] = { handlePlayerMovementAnimCatalog, handleTransitScheduleCatalog, handleMagePortalsCatalog, + handleCombatStatsCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index e0f60697..035774b2 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -132,6 +132,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','P','H','M'}, ".wphm", "anim", "--info-wphm", "Player movement-to-animation map"}, {{'W','T','S','C'}, ".wtsc", "transit", "--info-wtsc", "Transit schedule catalog"}, {{'W','P','R','T'}, ".wprt", "portals", "--info-wprt", "Mage portal destinations catalog"}, + {{'W','C','S','T'}, ".wcst", "stats", "--info-wcst", "Combat stats baseline 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 9f635863..3dcfb416 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2573,6 +2573,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wprt to a human-editable JSON sidecar (defaults to .wprt.json; emits factionAccess and portalKind as int + name string; floats preserved bit-for-bit)\n"); std::printf(" --import-wprt-json [out-base]\n"); std::printf(" Import a .wprt.json sidecar back into binary .wprt (factionAccess int OR \"both\"/\"alliance\"/\"horde\"/\"neutral\"; portalKind int OR \"teleport\"/\"portal\")\n"); + std::printf(" --gen-cst-warrior [name]\n"); + std::printf(" Emit .wcst Warrior (classId=1) sparse base-stats sample at levels 1/10/20/30/40/60. baseMana=0 (Warrior uses Rage)\n"); + std::printf(" --gen-cst-mage [name]\n"); + std::printf(" Emit .wcst Mage (classId=8) sparse base-stats sample at the same 6 levels with mana growth tracking Intellect\n"); + std::printf(" --gen-cst-starting [name]\n"); + std::printf(" Emit .wcst all 9 vanilla classes at level 1 — illustrates per-class flat starting-stat differences (Warrior/Paladin high Str, Hunter/Rogue high Agi, Mage/Priest/Warlock high Int, Shaman/Druid balanced)\n"); + std::printf(" --info-wcst [--json]\n"); + std::printf(" Print WCST entries (statId / class / level / hp / mana / Str/Agi/Sta/Int/Spi / armor)\n"); + std::printf(" --validate-wcst [--json]\n"); + std::printf(" Static checks: statId+classId+level required, classId 1..11, level 1..60, no zero baseHealth (player would die instantly), no duplicate statIds, no duplicate (classId,level) pairs (runtime stat-lookup tie); warns on classId 6/10 (DK/Monk gap unused in vanilla), Warrior/Rogue baseMana > 0 (these classes use Rage/Energy not mana — likely typo), and per-class monotonicity violations across all 8 stats (any stat regressing as level increases)\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 332dfa7e..c82a88c7 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -154,6 +154,7 @@ constexpr FormatRow kFormats[] = { {"WPHM", ".wphm", "anim", "implicit M2 movementState->anim map","Player movement-to-animation map (per race/gender/state)"}, {"WTSC", ".wtsc", "transit", "TaxiNodes + zeppelin GO scripts", "Transit schedule catalog (taxi/zeppelin/boat scheduled departures)"}, {"WPRT", ".wprt", "portals", "SpellEffect TELEPORT_UNITS + AreaTrigger","Mage portal destinations catalog (spellId -> coords binding)"}, + {"WCST", ".wcst", "stats", "CharBaseInfo + GtChanceTo*.dbc + StatSystem","Combat stats baseline catalog (per-class per-level base stats)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine