feat(pipeline): add WCHC (Wowee Character Classes/Races) format

Novel open replacement for Blizzard's CharClasses.dbc +
CharRaces.dbc + CharStartOutfit.dbc trio. The 27th open
format added to the editor — completes the foundational
character-creation surface.

One file holds three flat arrays:
  • classes — playable classes (Warrior / Mage / etc.) with
              power type (mana/rage/focus/energy/runic),
              base HP+power scaling, faction availability
  • races   — playable races with faction (Alliance/Horde/
              Neutral), starting map+zone, default language
              spell, base stats, racial mount spell
  • outfits — starting gear loadout per (class, race, gender)
              triple, listing item IDs and display slots

Cross-references with previously-added formats:
  WCHC.race.startingMapId        -> WMS.map.mapId
  WCHC.race.startingZoneAreaId   -> WMS.area.areaId
  WCHC.race.defaultLanguageSpellId -> WSPL.entry.spellId
  WCHC.race.mountSpellId         -> WSPL.entry.spellId
  WCHC.outfit.items.itemId       -> WIT.entry.itemId

The starter preset's outfits use real WIT itemIds (1=Worn
Shortsword, 2=Linen Vest, 3=Healing Potion) so the demo
content stack is consistent: a freshly created Human Warrior
in WCHC starts with WIT items 1/2/3, drops them on death
into a WLOT-tracked corpse loot, and can be respawned via
WSPN, etc.

Format:
  • magic "WCHC", version 1, little-endian
  • classes[]: classId / name / icon / powerType / display /
    baseHP+perLevel / basePower+perLevel / factionAvailability
  • races[]: raceId / name / icon / factionId / male+female
    displayId / 5 base stats / startingMap+zone /
    defaultLanguage+mount spell IDs
  • outfits[]: classId+raceId+gender + items[]
    (each: itemId + displaySlot)

Enums:
  • PowerType (6): Mana / Rage / Focus / Energy / RunicPower / Runes
  • RaceFaction (3): Alliance / Horde / Neutral
  • Gender: Male / Female
  • FactionAvailability bitmask: AvailableAlliance, AvailableHorde

API: WoweeCharsLoader::save / load / exists +
WoweeChars::findClass / findRace / findOutfit (by class+race+gender).

CLI added (5 flags, 585 documented total now):
  --gen-chars / --gen-chars-alliance / --gen-chars-allraces
  --info-wchc / --validate-wchc

Validator catches: ids unique, baseHealth=0 (instant-death
character), factionAvailability=0 (no faction can pick),
empty names, factionId out of range, outfit references to
non-existent class/race ids (cross-format resolution),
gender > 1, outfit items with itemId=0, outfit with no
items (warning — naked character).
This commit is contained in:
Kelsi 2026-05-09 16:47:04 -07:00
parent 019104536f
commit e66601c208
8 changed files with 926 additions and 0 deletions

View file

@ -614,6 +614,7 @@ set(WOWEE_SOURCES
src/pipeline/wowee_taxi.cpp
src/pipeline/wowee_talents.cpp
src/pipeline/wowee_maps.cpp
src/pipeline/wowee_chars.cpp
src/pipeline/custom_zone_discovery.cpp
src/pipeline/dbc_layout.cpp
@ -1374,6 +1375,7 @@ add_executable(wowee_editor
tools/editor/cli_taxi_catalog.cpp
tools/editor/cli_talents_catalog.cpp
tools/editor/cli_maps_catalog.cpp
tools/editor/cli_chars_catalog.cpp
tools/editor/cli_quest_objective.cpp
tools/editor/cli_quest_reward.cpp
tools/editor/cli_clone.cpp
@ -1466,6 +1468,7 @@ add_executable(wowee_editor
src/pipeline/wowee_taxi.cpp
src/pipeline/wowee_talents.cpp
src/pipeline/wowee_maps.cpp
src/pipeline/wowee_chars.cpp
src/pipeline/custom_zone_discovery.cpp
src/pipeline/terrain_mesh.cpp

View file

@ -0,0 +1,147 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace wowee {
namespace pipeline {
// Wowee Open Character Classes/Races catalog (.wchc) —
// novel replacement for Blizzard's CharClasses.dbc +
// CharRaces.dbc + CharStartOutfit.dbc trio. The 27th open
// format added to the editor.
//
// Defines every player class, race, and the starting outfit
// (gear loadout) for each class+race+gender combination.
// One file holds three flat arrays: classes / races /
// outfits.
//
// Cross-references with previously-added formats:
// WCHC.race.startingMapId → WMS.map.mapId
// WCHC.race.startingZoneAreaId → WMS.area.areaId
// WCHC.race.defaultLanguageSpellId → WSPL.entry.spellId
// WCHC.race.mountSpellId → WSPL.entry.spellId
// WCHC.outfit.items.itemId → WIT.entry.itemId
// WCHC.class.canTrainProfessions → WSKL.entry.skillId
// (bitset by category)
//
// Binary layout (little-endian):
// magic[4] = "WCHC"
// version (uint32) = current 1
// nameLen + name (catalog label)
// classCount (uint32) + classes[]
// raceCount (uint32) + races[]
// outfitCount (uint32) + outfits[]
struct WoweeChars {
enum PowerType : uint8_t {
Mana = 0,
Rage = 1,
Focus = 2,
Energy = 3,
RunicPower = 4,
Runes = 6, // DK only
};
enum FactionAvailability : uint8_t {
AvailableAlliance = 0x01,
AvailableHorde = 0x02,
};
enum RaceFaction : uint8_t {
Alliance = 0,
Horde = 1,
Neutral = 2, // Pandaren start neutral
};
enum Gender : uint8_t {
Male = 0,
Female = 1,
};
struct Class {
uint32_t classId = 0;
std::string name;
std::string iconPath;
uint8_t powerType = Mana;
uint8_t displayPower = Mana; // can differ from powerType (druid)
uint32_t baseHealth = 50;
uint16_t baseHealthPerLevel = 12;
uint32_t basePower = 100;
uint16_t basePowerPerLevel = 5;
uint8_t factionAvailability =
AvailableAlliance | AvailableHorde;
};
struct Race {
uint32_t raceId = 0;
std::string name;
std::string iconPath;
uint8_t factionId = Alliance;
uint32_t maleDisplayId = 0;
uint32_t femaleDisplayId = 0;
uint16_t baseStrength = 20;
uint16_t baseAgility = 20;
uint16_t baseStamina = 20;
uint16_t baseIntellect = 20;
uint16_t baseSpirit = 20;
uint32_t startingMapId = 0;
uint32_t startingZoneAreaId = 0;
uint32_t defaultLanguageSpellId = 0; // 0 = none
uint32_t mountSpellId = 0; // racial mount spell
};
struct OutfitItem {
uint32_t itemId = 0;
uint8_t displaySlot = 0; // matches WIT.inventoryType
};
struct Outfit {
uint32_t classId = 0;
uint32_t raceId = 0;
uint8_t gender = Male;
std::vector<OutfitItem> items;
};
std::string name;
std::vector<Class> classes;
std::vector<Race> races;
std::vector<Outfit> outfits;
bool isValid() const { return !classes.empty() || !races.empty(); }
const Class* findClass(uint32_t classId) const;
const Race* findRace(uint32_t raceId) const;
// First outfit matching the (class, race, gender) triple, or nullptr.
const Outfit* findOutfit(uint32_t classId, uint32_t raceId,
uint8_t gender) const;
static const char* powerTypeName(uint8_t p);
static const char* raceFactionName(uint8_t f);
};
class WoweeCharsLoader {
public:
static bool save(const WoweeChars& cat,
const std::string& basePath);
static WoweeChars load(const std::string& basePath);
static bool exists(const std::string& basePath);
// Preset emitters used by --gen-chars* variants.
//
// makeStarter — 2 classes (Warrior + Mage) + 2 races
// (Human Alliance + Orc Horde) + 4 outfits
// (2 classes × 2 races, male-only).
// makeAlliance — full Alliance faction: 4 classes + 4
// races + 8 outfits.
// makeAllRaces — 8 classic playable races (Human / Dwarf
// / NightElf / Gnome on Alliance side;
// Orc / Undead / Tauren / Troll on Horde)
// plus 9 classes (no DK).
static WoweeChars makeStarter(const std::string& catalogName);
static WoweeChars makeAlliance(const std::string& catalogName);
static WoweeChars makeAllRaces(const std::string& catalogName);
};
} // namespace pipeline
} // namespace wowee

View file

@ -0,0 +1,418 @@
#include "pipeline/wowee_chars.hpp"
#include <cstdio>
#include <cstring>
#include <fstream>
namespace wowee {
namespace pipeline {
namespace {
constexpr char kMagic[4] = {'W', 'C', 'H', 'C'};
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) != ".wchc") {
base += ".wchc";
}
return base;
}
} // namespace
const WoweeChars::Class* WoweeChars::findClass(uint32_t classId) const {
for (const auto& c : classes) if (c.classId == classId) return &c;
return nullptr;
}
const WoweeChars::Race* WoweeChars::findRace(uint32_t raceId) const {
for (const auto& r : races) if (r.raceId == raceId) return &r;
return nullptr;
}
const WoweeChars::Outfit* WoweeChars::findOutfit(uint32_t classId,
uint32_t raceId,
uint8_t gender) const {
for (const auto& o : outfits) {
if (o.classId == classId && o.raceId == raceId &&
o.gender == gender) return &o;
}
return nullptr;
}
const char* WoweeChars::powerTypeName(uint8_t p) {
switch (p) {
case Mana: return "mana";
case Rage: return "rage";
case Focus: return "focus";
case Energy: return "energy";
case RunicPower: return "runic-power";
case Runes: return "runes";
default: return "unknown";
}
}
const char* WoweeChars::raceFactionName(uint8_t f) {
switch (f) {
case Alliance: return "alliance";
case Horde: return "horde";
case Neutral: return "neutral";
default: return "unknown";
}
}
bool WoweeCharsLoader::save(const WoweeChars& 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 classCount = static_cast<uint32_t>(cat.classes.size());
writePOD(os, classCount);
for (const auto& c : cat.classes) {
writePOD(os, c.classId);
writeStr(os, c.name);
writeStr(os, c.iconPath);
writePOD(os, c.powerType);
writePOD(os, c.displayPower);
writePOD(os, c.factionAvailability);
uint8_t pad1 = 0;
writePOD(os, pad1);
writePOD(os, c.baseHealth);
writePOD(os, c.baseHealthPerLevel);
writePOD(os, c.basePower);
writePOD(os, c.basePowerPerLevel);
}
uint32_t raceCount = static_cast<uint32_t>(cat.races.size());
writePOD(os, raceCount);
for (const auto& r : cat.races) {
writePOD(os, r.raceId);
writeStr(os, r.name);
writeStr(os, r.iconPath);
writePOD(os, r.factionId);
uint8_t pad3[3] = {0, 0, 0};
os.write(reinterpret_cast<const char*>(pad3), 3);
writePOD(os, r.maleDisplayId);
writePOD(os, r.femaleDisplayId);
writePOD(os, r.baseStrength);
writePOD(os, r.baseAgility);
writePOD(os, r.baseStamina);
writePOD(os, r.baseIntellect);
writePOD(os, r.baseSpirit);
os.write(reinterpret_cast<const char*>(pad3), 2);
writePOD(os, r.startingMapId);
writePOD(os, r.startingZoneAreaId);
writePOD(os, r.defaultLanguageSpellId);
writePOD(os, r.mountSpellId);
}
uint32_t outfitCount = static_cast<uint32_t>(cat.outfits.size());
writePOD(os, outfitCount);
for (const auto& o : cat.outfits) {
writePOD(os, o.classId);
writePOD(os, o.raceId);
writePOD(os, o.gender);
uint8_t itemCount = static_cast<uint8_t>(
o.items.size() > 255 ? 255 : o.items.size());
writePOD(os, itemCount);
uint8_t pad2[2] = {0, 0};
os.write(reinterpret_cast<const char*>(pad2), 2);
for (uint8_t k = 0; k < itemCount; ++k) {
const auto& it = o.items[k];
writePOD(os, it.itemId);
writePOD(os, it.displaySlot);
uint8_t ipad[3] = {0, 0, 0};
os.write(reinterpret_cast<const char*>(ipad), 3);
}
}
return os.good();
}
WoweeChars WoweeCharsLoader::load(const std::string& basePath) {
WoweeChars 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 classCount = 0;
if (!readPOD(is, classCount)) return out;
if (classCount > (1u << 20)) return out;
out.classes.resize(classCount);
for (auto& c : out.classes) {
if (!readPOD(is, c.classId)) { out.classes.clear(); return out; }
if (!readStr(is, c.name) || !readStr(is, c.iconPath)) {
out.classes.clear(); return out;
}
if (!readPOD(is, c.powerType) ||
!readPOD(is, c.displayPower) ||
!readPOD(is, c.factionAvailability)) {
out.classes.clear(); return out;
}
uint8_t pad1 = 0;
if (!readPOD(is, pad1)) {
out.classes.clear(); return out;
}
if (!readPOD(is, c.baseHealth) ||
!readPOD(is, c.baseHealthPerLevel) ||
!readPOD(is, c.basePower) ||
!readPOD(is, c.basePowerPerLevel)) {
out.classes.clear(); return out;
}
}
uint32_t raceCount = 0;
if (!readPOD(is, raceCount)) {
out.classes.clear(); return out;
}
if (raceCount > (1u << 20)) {
out.classes.clear(); return out;
}
out.races.resize(raceCount);
for (auto& r : out.races) {
if (!readPOD(is, r.raceId)) {
out.classes.clear(); out.races.clear(); return out;
}
if (!readStr(is, r.name) || !readStr(is, r.iconPath)) {
out.classes.clear(); out.races.clear(); return out;
}
if (!readPOD(is, r.factionId)) {
out.classes.clear(); out.races.clear(); return out;
}
uint8_t pad3[3];
is.read(reinterpret_cast<char*>(pad3), 3);
if (is.gcount() != 3) {
out.classes.clear(); out.races.clear(); return out;
}
if (!readPOD(is, r.maleDisplayId) ||
!readPOD(is, r.femaleDisplayId) ||
!readPOD(is, r.baseStrength) ||
!readPOD(is, r.baseAgility) ||
!readPOD(is, r.baseStamina) ||
!readPOD(is, r.baseIntellect) ||
!readPOD(is, r.baseSpirit)) {
out.classes.clear(); out.races.clear(); return out;
}
is.read(reinterpret_cast<char*>(pad3), 2);
if (is.gcount() != 2) {
out.classes.clear(); out.races.clear(); return out;
}
if (!readPOD(is, r.startingMapId) ||
!readPOD(is, r.startingZoneAreaId) ||
!readPOD(is, r.defaultLanguageSpellId) ||
!readPOD(is, r.mountSpellId)) {
out.classes.clear(); out.races.clear(); return out;
}
}
uint32_t outfitCount = 0;
if (!readPOD(is, outfitCount)) {
out.classes.clear(); out.races.clear(); return out;
}
if (outfitCount > (1u << 20)) {
out.classes.clear(); out.races.clear(); return out;
}
out.outfits.resize(outfitCount);
for (auto& o : out.outfits) {
if (!readPOD(is, o.classId) ||
!readPOD(is, o.raceId) ||
!readPOD(is, o.gender)) {
out.classes.clear(); out.races.clear();
out.outfits.clear(); return out;
}
uint8_t itemCount = 0;
if (!readPOD(is, itemCount)) {
out.classes.clear(); out.races.clear();
out.outfits.clear(); return out;
}
uint8_t pad2[2];
is.read(reinterpret_cast<char*>(pad2), 2);
if (is.gcount() != 2) {
out.classes.clear(); out.races.clear();
out.outfits.clear(); return out;
}
o.items.resize(itemCount);
for (uint8_t k = 0; k < itemCount; ++k) {
auto& it = o.items[k];
if (!readPOD(is, it.itemId) ||
!readPOD(is, it.displaySlot)) {
out.classes.clear(); out.races.clear();
out.outfits.clear(); return out;
}
uint8_t ipad[3];
is.read(reinterpret_cast<char*>(ipad), 3);
if (is.gcount() != 3) {
out.classes.clear(); out.races.clear();
out.outfits.clear(); return out;
}
}
}
return out;
}
bool WoweeCharsLoader::exists(const std::string& basePath) {
std::ifstream is(normalizePath(basePath), std::ios::binary);
return is.good();
}
WoweeChars WoweeCharsLoader::makeStarter(const std::string& catalogName) {
WoweeChars c;
c.name = catalogName;
{
WoweeChars::Class cls;
cls.classId = 1; cls.name = "Warrior";
cls.powerType = WoweeChars::Rage; cls.displayPower = WoweeChars::Rage;
cls.baseHealth = 60; cls.baseHealthPerLevel = 18;
cls.basePower = 100; cls.basePowerPerLevel = 0;
c.classes.push_back(cls);
}
{
WoweeChars::Class cls;
cls.classId = 8; cls.name = "Mage";
cls.powerType = WoweeChars::Mana; cls.displayPower = WoweeChars::Mana;
cls.baseHealth = 35; cls.baseHealthPerLevel = 10;
cls.basePower = 100; cls.basePowerPerLevel = 30;
c.classes.push_back(cls);
}
{
WoweeChars::Race r;
r.raceId = 1; r.name = "Human";
r.factionId = WoweeChars::Alliance;
r.maleDisplayId = 49; r.femaleDisplayId = 50;
r.startingMapId = 0; r.startingZoneAreaId = 12; // Elwynn Forest
r.defaultLanguageSpellId = 668; // Common
c.races.push_back(r);
}
{
WoweeChars::Race r;
r.raceId = 2; r.name = "Orc";
r.factionId = WoweeChars::Horde;
r.maleDisplayId = 51; r.femaleDisplayId = 52;
r.startingMapId = 1; r.startingZoneAreaId = 215; // Mulgore-ish
r.defaultLanguageSpellId = 669; // Orcish
c.races.push_back(r);
}
auto addOutfit = [&](uint32_t classId, uint32_t raceId,
std::vector<WoweeChars::OutfitItem> items) {
WoweeChars::Outfit o;
o.classId = classId; o.raceId = raceId; o.gender = WoweeChars::Male;
o.items = std::move(items);
c.outfits.push_back(o);
};
// Each outfit uses WIT itemIds (1 = Worn Shortsword,
// 2 = Linen Vest, 3 = Healing Potion).
addOutfit(1, 1, {{1, 13}, {2, 5}, {3, 0}}); // Human Warrior
addOutfit(1, 2, {{1, 13}, {2, 5}, {3, 0}}); // Orc Warrior
addOutfit(8, 1, {{2, 5}, {3, 0}}); // Human Mage
addOutfit(8, 2, {{2, 5}, {3, 0}}); // Orc Mage
return c;
}
WoweeChars WoweeCharsLoader::makeAlliance(const std::string& catalogName) {
WoweeChars c;
c.name = catalogName;
auto addClass = [&](uint32_t id, const char* name, uint8_t power,
uint32_t baseHp, uint16_t hpPerLvl,
uint32_t basePwr, uint16_t pwrPerLvl) {
WoweeChars::Class cls;
cls.classId = id; cls.name = name;
cls.powerType = power; cls.displayPower = power;
cls.baseHealth = baseHp; cls.baseHealthPerLevel = hpPerLvl;
cls.basePower = basePwr; cls.basePowerPerLevel = pwrPerLvl;
cls.factionAvailability = WoweeChars::AvailableAlliance;
c.classes.push_back(cls);
};
addClass(1, "Warrior", WoweeChars::Rage, 60, 18, 100, 0);
addClass(2, "Paladin", WoweeChars::Mana, 55, 16, 100, 30);
addClass(4, "Rogue", WoweeChars::Energy, 45, 14, 100, 0);
addClass(8, "Mage", WoweeChars::Mana, 35, 10, 100, 30);
auto addRace = [&](uint32_t id, const char* name, uint32_t mapId,
uint32_t zoneId, uint32_t langSpell) {
WoweeChars::Race r;
r.raceId = id; r.name = name;
r.factionId = WoweeChars::Alliance;
r.startingMapId = mapId; r.startingZoneAreaId = zoneId;
r.defaultLanguageSpellId = langSpell;
c.races.push_back(r);
};
addRace(1, "Human", 0, 12, 668); // Elwynn / Common
addRace(3, "Dwarf", 0, 1, 672); // Dun Morogh / Dwarvish
addRace(4, "NightElf", 1, 141, 671); // Teldrassil / Darnassian
addRace(7, "Gnome", 0, 1, 7340); // Dun Morogh / Gnomish
return c;
}
WoweeChars WoweeCharsLoader::makeAllRaces(const std::string& catalogName) {
WoweeChars c;
c.name = catalogName;
auto addClass = [&](uint32_t id, const char* name, uint8_t power) {
WoweeChars::Class cls;
cls.classId = id; cls.name = name;
cls.powerType = power; cls.displayPower = power;
cls.baseHealth = 50; cls.baseHealthPerLevel = 12;
cls.basePower = 100; cls.basePowerPerLevel = 5;
c.classes.push_back(cls);
};
addClass(1, "Warrior", WoweeChars::Rage);
addClass(2, "Paladin", WoweeChars::Mana);
addClass(3, "Hunter", WoweeChars::Mana);
addClass(4, "Rogue", WoweeChars::Energy);
addClass(5, "Priest", WoweeChars::Mana);
addClass(7, "Shaman", WoweeChars::Mana);
addClass(8, "Mage", WoweeChars::Mana);
addClass(9, "Warlock", WoweeChars::Mana);
addClass(11, "Druid", WoweeChars::Mana);
auto addRace = [&](uint32_t id, const char* name, uint8_t faction) {
WoweeChars::Race r;
r.raceId = id; r.name = name; r.factionId = faction;
c.races.push_back(r);
};
// Alliance.
addRace(1, "Human", WoweeChars::Alliance);
addRace(3, "Dwarf", WoweeChars::Alliance);
addRace(4, "NightElf", WoweeChars::Alliance);
addRace(7, "Gnome", WoweeChars::Alliance);
// Horde.
addRace(2, "Orc", WoweeChars::Horde);
addRace(5, "Undead", WoweeChars::Horde);
addRace(6, "Tauren", WoweeChars::Horde);
addRace(8, "Troll", WoweeChars::Horde);
return c;
}
} // namespace pipeline
} // namespace wowee

View file

@ -76,6 +76,8 @@ const char* const kArgRequired[] = {
"--export-wtal-json", "--import-wtal-json",
"--gen-maps", "--gen-maps-classic", "--gen-maps-bgarena",
"--info-wms", "--validate-wms",
"--gen-chars", "--gen-chars-alliance", "--gen-chars-allraces",
"--info-wchc", "--validate-wchc",
"--gen-weather-temperate", "--gen-weather-arctic",
"--gen-weather-desert", "--gen-weather-stormy",
"--gen-zone-atmosphere",

View file

@ -0,0 +1,333 @@
#include "cli_chars_catalog.hpp"
#include "cli_arg_parse.hpp"
#include "cli_box_emitter.hpp"
#include "pipeline/wowee_chars.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 stripWchcExt(std::string base) {
stripExt(base, ".wchc");
return base;
}
bool saveOrError(const wowee::pipeline::WoweeChars& c,
const std::string& base, const char* cmd) {
if (!wowee::pipeline::WoweeCharsLoader::save(c, base)) {
std::fprintf(stderr, "%s: failed to save %s.wchc\n",
cmd, base.c_str());
return false;
}
return true;
}
void printGenSummary(const wowee::pipeline::WoweeChars& c,
const std::string& base) {
std::printf("Wrote %s.wchc\n", base.c_str());
std::printf(" catalog : %s\n", c.name.c_str());
std::printf(" classes : %zu races : %zu outfits : %zu\n",
c.classes.size(), c.races.size(), c.outfits.size());
}
int handleGenStarter(int& i, int argc, char** argv) {
std::string base = argv[++i];
std::string name = "StarterChars";
if (parseOptArg(i, argc, argv)) name = argv[++i];
base = stripWchcExt(base);
auto c = wowee::pipeline::WoweeCharsLoader::makeStarter(name);
if (!saveOrError(c, base, "gen-chars")) return 1;
printGenSummary(c, base);
return 0;
}
int handleGenAlliance(int& i, int argc, char** argv) {
std::string base = argv[++i];
std::string name = "AllianceChars";
if (parseOptArg(i, argc, argv)) name = argv[++i];
base = stripWchcExt(base);
auto c = wowee::pipeline::WoweeCharsLoader::makeAlliance(name);
if (!saveOrError(c, base, "gen-chars-alliance")) return 1;
printGenSummary(c, base);
return 0;
}
int handleGenAllRaces(int& i, int argc, char** argv) {
std::string base = argv[++i];
std::string name = "AllRacesChars";
if (parseOptArg(i, argc, argv)) name = argv[++i];
base = stripWchcExt(base);
auto c = wowee::pipeline::WoweeCharsLoader::makeAllRaces(name);
if (!saveOrError(c, base, "gen-chars-allraces")) 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 = stripWchcExt(base);
if (!wowee::pipeline::WoweeCharsLoader::exists(base)) {
std::fprintf(stderr, "WCHC not found: %s.wchc\n", base.c_str());
return 1;
}
auto c = wowee::pipeline::WoweeCharsLoader::load(base);
if (jsonOut) {
nlohmann::json j;
j["wchc"] = base + ".wchc";
j["name"] = c.name;
j["classCount"] = c.classes.size();
j["raceCount"] = c.races.size();
j["outfitCount"] = c.outfits.size();
nlohmann::json ca = nlohmann::json::array();
for (const auto& cls : c.classes) {
ca.push_back({
{"classId", cls.classId},
{"name", cls.name},
{"iconPath", cls.iconPath},
{"powerType", cls.powerType},
{"powerTypeName", wowee::pipeline::WoweeChars::powerTypeName(cls.powerType)},
{"displayPower", cls.displayPower},
{"baseHealth", cls.baseHealth},
{"baseHealthPerLevel", cls.baseHealthPerLevel},
{"basePower", cls.basePower},
{"basePowerPerLevel", cls.basePowerPerLevel},
{"factionAvailability", cls.factionAvailability},
});
}
j["classes"] = ca;
nlohmann::json ra = nlohmann::json::array();
for (const auto& r : c.races) {
ra.push_back({
{"raceId", r.raceId},
{"name", r.name},
{"iconPath", r.iconPath},
{"factionId", r.factionId},
{"factionName", wowee::pipeline::WoweeChars::raceFactionName(r.factionId)},
{"maleDisplayId", r.maleDisplayId},
{"femaleDisplayId", r.femaleDisplayId},
{"baseStrength", r.baseStrength},
{"baseAgility", r.baseAgility},
{"baseStamina", r.baseStamina},
{"baseIntellect", r.baseIntellect},
{"baseSpirit", r.baseSpirit},
{"startingMapId", r.startingMapId},
{"startingZoneAreaId", r.startingZoneAreaId},
{"defaultLanguageSpellId", r.defaultLanguageSpellId},
{"mountSpellId", r.mountSpellId},
});
}
j["races"] = ra;
nlohmann::json oa = nlohmann::json::array();
for (const auto& o : c.outfits) {
nlohmann::json items = nlohmann::json::array();
for (const auto& it : o.items) {
items.push_back({
{"itemId", it.itemId},
{"displaySlot", it.displaySlot},
});
}
oa.push_back({
{"classId", o.classId},
{"raceId", o.raceId},
{"gender", o.gender},
{"items", items},
});
}
j["outfits"] = oa;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("WCHC: %s.wchc\n", base.c_str());
std::printf(" catalog : %s\n", c.name.c_str());
std::printf(" classes : %zu races : %zu outfits : %zu\n",
c.classes.size(), c.races.size(), c.outfits.size());
if (!c.classes.empty()) {
std::printf("\n Classes:\n");
std::printf(" id power baseHP /lvl name\n");
for (const auto& cls : c.classes) {
std::printf(" %2u %-11s %4u %3u %s\n",
cls.classId,
wowee::pipeline::WoweeChars::powerTypeName(cls.powerType),
cls.baseHealth, cls.baseHealthPerLevel,
cls.name.c_str());
}
}
if (!c.races.empty()) {
std::printf("\n Races:\n");
std::printf(" id faction map zone name\n");
for (const auto& r : c.races) {
std::printf(" %2u %-9s %3u %5u %s\n",
r.raceId,
wowee::pipeline::WoweeChars::raceFactionName(r.factionId),
r.startingMapId, r.startingZoneAreaId,
r.name.c_str());
}
}
if (!c.outfits.empty()) {
std::printf("\n Outfits:\n");
for (const auto& o : c.outfits) {
std::printf(" class=%-2u race=%-2u gender=%u items: ",
o.classId, o.raceId, o.gender);
for (size_t k = 0; k < o.items.size(); ++k) {
std::printf("%s%u@slot%u",
k > 0 ? ", " : "",
o.items[k].itemId, o.items[k].displaySlot);
}
std::printf("\n");
}
}
return 0;
}
int handleValidate(int& i, int argc, char** argv) {
std::string base = argv[++i];
bool jsonOut = consumeJsonFlag(i, argc, argv);
base = stripWchcExt(base);
if (!wowee::pipeline::WoweeCharsLoader::exists(base)) {
std::fprintf(stderr,
"validate-wchc: WCHC not found: %s.wchc\n", base.c_str());
return 1;
}
auto c = wowee::pipeline::WoweeCharsLoader::load(base);
std::vector<std::string> errors;
std::vector<std::string> warnings;
if (c.classes.empty() && c.races.empty()) {
warnings.push_back("catalog has zero classes and zero races");
}
std::vector<uint32_t> classIdsSeen;
for (size_t k = 0; k < c.classes.size(); ++k) {
const auto& cls = c.classes[k];
std::string ctx = "class " + std::to_string(k) +
" (id=" + std::to_string(cls.classId);
if (!cls.name.empty()) ctx += " " + cls.name;
ctx += ")";
if (cls.classId == 0) errors.push_back(ctx + ": classId is 0");
if (cls.name.empty()) errors.push_back(ctx + ": name is empty");
if (cls.baseHealth == 0) {
errors.push_back(ctx + ": baseHealth is 0 (character dies on creation)");
}
if (cls.factionAvailability == 0) {
errors.push_back(ctx +
": factionAvailability=0 (no faction can pick this class)");
}
for (uint32_t prev : classIdsSeen) {
if (prev == cls.classId) {
errors.push_back(ctx + ": duplicate classId");
break;
}
}
classIdsSeen.push_back(cls.classId);
}
std::vector<uint32_t> raceIdsSeen;
for (size_t k = 0; k < c.races.size(); ++k) {
const auto& r = c.races[k];
std::string ctx = "race " + std::to_string(k) +
" (id=" + std::to_string(r.raceId);
if (!r.name.empty()) ctx += " " + r.name;
ctx += ")";
if (r.raceId == 0) errors.push_back(ctx + ": raceId is 0");
if (r.name.empty()) errors.push_back(ctx + ": name is empty");
if (r.factionId > wowee::pipeline::WoweeChars::Neutral) {
errors.push_back(ctx + ": factionId " +
std::to_string(r.factionId) + " not in 0..2");
}
for (uint32_t prev : raceIdsSeen) {
if (prev == r.raceId) {
errors.push_back(ctx + ": duplicate raceId");
break;
}
}
raceIdsSeen.push_back(r.raceId);
}
// Outfit cross-references must hit real classes / races.
for (size_t k = 0; k < c.outfits.size(); ++k) {
const auto& o = c.outfits[k];
std::string ctx = "outfit " + std::to_string(k) +
" (class=" + std::to_string(o.classId) +
" race=" + std::to_string(o.raceId) + ")";
if (!c.classes.empty() && !c.findClass(o.classId)) {
errors.push_back(ctx + ": classId does not exist in this catalog");
}
if (!c.races.empty() && !c.findRace(o.raceId)) {
errors.push_back(ctx + ": raceId does not exist in this catalog");
}
if (o.gender > wowee::pipeline::WoweeChars::Female) {
errors.push_back(ctx + ": gender " +
std::to_string(o.gender) + " not 0 or 1");
}
if (o.items.empty()) {
warnings.push_back(ctx +
": no items (player starts naked / unarmed)");
}
for (size_t ii = 0; ii < o.items.size(); ++ii) {
if (o.items[ii].itemId == 0) {
errors.push_back(ctx + " item " + std::to_string(ii) +
": itemId is 0");
}
}
}
bool ok = errors.empty();
if (jsonOut) {
nlohmann::json j;
j["wchc"] = base + ".wchc";
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-wchc: %s.wchc\n", base.c_str());
if (ok && warnings.empty()) {
std::printf(" OK — %zu classes, %zu races, %zu outfits, all IDs unique\n",
c.classes.size(), c.races.size(), c.outfits.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 handleCharsCatalog(int& i, int argc, char** argv, int& outRc) {
if (std::strcmp(argv[i], "--gen-chars") == 0 && i + 1 < argc) {
outRc = handleGenStarter(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--gen-chars-alliance") == 0 && i + 1 < argc) {
outRc = handleGenAlliance(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--gen-chars-allraces") == 0 && i + 1 < argc) {
outRc = handleGenAllRaces(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--info-wchc") == 0 && i + 1 < argc) {
outRc = handleInfo(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--validate-wchc") == 0 && i + 1 < argc) {
outRc = handleValidate(i, argc, argv); return true;
}
return false;
}
} // namespace cli
} // namespace editor
} // namespace wowee

View file

@ -0,0 +1,11 @@
#pragma once
namespace wowee {
namespace editor {
namespace cli {
bool handleCharsCatalog(int& i, int argc, char** argv, int& outRc);
} // namespace cli
} // namespace editor
} // namespace wowee

View file

@ -54,6 +54,7 @@
#include "cli_taxi_catalog.hpp"
#include "cli_talents_catalog.hpp"
#include "cli_maps_catalog.hpp"
#include "cli_chars_catalog.hpp"
#include "cli_quest_objective.hpp"
#include "cli_quest_reward.hpp"
#include "cli_clone.hpp"
@ -149,6 +150,7 @@ constexpr DispatchFn kDispatchTable[] = {
handleTaxiCatalog,
handleTalentsCatalog,
handleMapsCatalog,
handleCharsCatalog,
handleQuestObjective,
handleQuestReward,
handleClone,

View file

@ -1075,6 +1075,16 @@ void printUsage(const char* argv0) {
std::printf(" Print WMS maps (id / type / expansion / max players) + areas (id / map / parent / level / faction / xp)\n");
std::printf(" --validate-wms <wms-base> [--json]\n");
std::printf(" Static checks: ids unique, areas reference real maps, parent areas exist + same map, BG/Arena needs maxPlayers\n");
std::printf(" --gen-chars <wchc-base> [name]\n");
std::printf(" Emit .wchc starter: 2 classes (Warrior + Mage) + 2 races (Human + Orc) + 4 outfits with WIT cross-refs\n");
std::printf(" --gen-chars-alliance <wchc-base> [name]\n");
std::printf(" Emit .wchc Alliance set: 4 classes (Warrior/Paladin/Rogue/Mage) + 4 races (Human/Dwarf/NightElf/Gnome)\n");
std::printf(" --gen-chars-allraces <wchc-base> [name]\n");
std::printf(" Emit .wchc all 8 classic races (4 Alliance + 4 Horde) + 9 classes (no DK)\n");
std::printf(" --info-wchc <wchc-base> [--json]\n");
std::printf(" Print WCHC classes (id / power / hp scaling) + races (faction / starting zone) + outfit item lists\n");
std::printf(" --validate-wchc <wchc-base> [--json]\n");
std::printf(" Static checks: class+race ids unique, baseHealth>0, faction availability set, outfit refs resolve\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");