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

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