From 41156f4a957c76ede4fb8bf06e8bfa96511ee536 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 16:55:31 -0700 Subject: [PATCH] feat(editor): add WCHC JSON round-trip authoring workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the WCHC open-format loop with --export-wchc-json / --import-wchc-json, mirroring the JSON pairs added for every other novel binary format. All 21 binary formats added since WOL now have full JSON round-trip authoring. Three top-level arrays mirror the binary layout: • classes[] — id / name / icon / powerType (dual int + name) / displayPower / baseHP+power scaling / factionAvailability bitmask • races[] — id / name / icon / factionId (dual int + name) / male+female displayId / 5 base stats / starting map+zone / language+mount spell IDs • outfits[] — classId+raceId+gender (dual int + name) + items array (each: itemId + displaySlot) Verified byte-identical round-trip on the starter preset (2 classes / 2 races / 4 outfits with full WIT itemId cross-references preserved through the JSON layer). Adds 2 flags (594 documented total now). --- tools/editor/cli_arg_required.cpp | 1 + tools/editor/cli_chars_catalog.cpp | 233 +++++++++++++++++++++++++++++ tools/editor/cli_help.cpp | 4 + 3 files changed, 238 insertions(+) diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 532779cd..aecb2a39 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -79,6 +79,7 @@ const char* const kArgRequired[] = { "--export-wms-json", "--import-wms-json", "--gen-chars", "--gen-chars-alliance", "--gen-chars-allraces", "--info-wchc", "--validate-wchc", + "--export-wchc-json", "--import-wchc-json", "--gen-tokens", "--gen-tokens-pvp", "--gen-tokens-seasonal", "--info-wtkn", "--validate-wtkn", "--gen-weather-temperate", "--gen-weather-arctic", diff --git a/tools/editor/cli_chars_catalog.cpp b/tools/editor/cli_chars_catalog.cpp index 533f4208..57306e88 100644 --- a/tools/editor/cli_chars_catalog.cpp +++ b/tools/editor/cli_chars_catalog.cpp @@ -191,6 +191,233 @@ int handleInfo(int& i, int argc, char** argv) { return 0; } +int handleExportJson(int& i, int argc, char** argv) { + // Mirrors the JSON pairs added for every other novel + // open format. Three top-level arrays (classes / races / + // outfits) mirroring the binary layout. Enum-typed fields + // (powerType, factionId) emit dual int + name forms. + std::string base = argv[++i]; + std::string outPath; + if (parseOptArg(i, argc, argv)) outPath = argv[++i]; + base = stripWchcExt(base); + if (outPath.empty()) outPath = base + ".wchc.json"; + if (!wowee::pipeline::WoweeCharsLoader::exists(base)) { + std::fprintf(stderr, + "export-wchc-json: WCHC not found: %s.wchc\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeCharsLoader::load(base); + nlohmann::json j; + j["name"] = c.name; + 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}, + {"genderName", + o.gender == wowee::pipeline::WoweeChars::Female ? "female" : "male"}, + {"items", items}, + }); + } + j["outfits"] = oa; + std::ofstream out(outPath); + if (!out) { + std::fprintf(stderr, + "export-wchc-json: cannot write %s\n", outPath.c_str()); + return 1; + } + out << j.dump(2) << "\n"; + out.close(); + std::printf("Wrote %s\n", outPath.c_str()); + std::printf(" source : %s.wchc\n", base.c_str()); + std::printf(" classes : %zu races : %zu outfits : %zu\n", + c.classes.size(), c.races.size(), c.outfits.size()); + return 0; +} + +int handleImportJson(int& i, int argc, char** argv) { + std::string jsonPath = argv[++i]; + std::string outBase; + if (parseOptArg(i, argc, argv)) outBase = argv[++i]; + if (outBase.empty()) { + outBase = jsonPath; + std::string suffix = ".wchc.json"; + if (outBase.size() > suffix.size() && + outBase.substr(outBase.size() - suffix.size()) == suffix) { + outBase = outBase.substr(0, outBase.size() - suffix.size()); + } else if (outBase.size() > 5 && + outBase.substr(outBase.size() - 5) == ".json") { + outBase = outBase.substr(0, outBase.size() - 5); + } + } + outBase = stripWchcExt(outBase); + std::ifstream in(jsonPath); + if (!in) { + std::fprintf(stderr, + "import-wchc-json: cannot read %s\n", jsonPath.c_str()); + return 1; + } + nlohmann::json j; + try { in >> j; } + catch (const std::exception& e) { + std::fprintf(stderr, + "import-wchc-json: bad JSON in %s: %s\n", + jsonPath.c_str(), e.what()); + return 1; + } + auto powerFromName = [](const std::string& s) -> uint8_t { + if (s == "mana") return wowee::pipeline::WoweeChars::Mana; + if (s == "rage") return wowee::pipeline::WoweeChars::Rage; + if (s == "focus") return wowee::pipeline::WoweeChars::Focus; + if (s == "energy") return wowee::pipeline::WoweeChars::Energy; + if (s == "runic-power") return wowee::pipeline::WoweeChars::RunicPower; + if (s == "runes") return wowee::pipeline::WoweeChars::Runes; + return wowee::pipeline::WoweeChars::Mana; + }; + auto factionFromName = [](const std::string& s) -> uint8_t { + if (s == "alliance") return wowee::pipeline::WoweeChars::Alliance; + if (s == "horde") return wowee::pipeline::WoweeChars::Horde; + if (s == "neutral") return wowee::pipeline::WoweeChars::Neutral; + return wowee::pipeline::WoweeChars::Alliance; + }; + auto genderFromName = [](const std::string& s) -> uint8_t { + if (s == "female") return wowee::pipeline::WoweeChars::Female; + return wowee::pipeline::WoweeChars::Male; + }; + wowee::pipeline::WoweeChars c; + c.name = j.value("name", std::string{}); + if (j.contains("classes") && j["classes"].is_array()) { + for (const auto& jc : j["classes"]) { + wowee::pipeline::WoweeChars::Class cls; + cls.classId = jc.value("classId", 0u); + cls.name = jc.value("name", std::string{}); + cls.iconPath = jc.value("iconPath", std::string{}); + if (jc.contains("powerType") && jc["powerType"].is_number_integer()) { + cls.powerType = static_cast(jc["powerType"].get()); + } else if (jc.contains("powerTypeName") && jc["powerTypeName"].is_string()) { + cls.powerType = powerFromName(jc["powerTypeName"].get()); + } + cls.displayPower = static_cast( + jc.value("displayPower", static_cast(cls.powerType))); + cls.baseHealth = jc.value("baseHealth", 50u); + cls.baseHealthPerLevel = static_cast( + jc.value("baseHealthPerLevel", 12)); + cls.basePower = jc.value("basePower", 100u); + cls.basePowerPerLevel = static_cast( + jc.value("basePowerPerLevel", 5)); + cls.factionAvailability = static_cast( + jc.value("factionAvailability", + wowee::pipeline::WoweeChars::AvailableAlliance | + wowee::pipeline::WoweeChars::AvailableHorde)); + c.classes.push_back(cls); + } + } + if (j.contains("races") && j["races"].is_array()) { + for (const auto& jr : j["races"]) { + wowee::pipeline::WoweeChars::Race r; + r.raceId = jr.value("raceId", 0u); + r.name = jr.value("name", std::string{}); + r.iconPath = jr.value("iconPath", std::string{}); + if (jr.contains("factionId") && jr["factionId"].is_number_integer()) { + r.factionId = static_cast(jr["factionId"].get()); + } else if (jr.contains("factionName") && jr["factionName"].is_string()) { + r.factionId = factionFromName(jr["factionName"].get()); + } + r.maleDisplayId = jr.value("maleDisplayId", 0u); + r.femaleDisplayId = jr.value("femaleDisplayId", 0u); + r.baseStrength = static_cast(jr.value("baseStrength", 20)); + r.baseAgility = static_cast(jr.value("baseAgility", 20)); + r.baseStamina = static_cast(jr.value("baseStamina", 20)); + r.baseIntellect = static_cast(jr.value("baseIntellect", 20)); + r.baseSpirit = static_cast(jr.value("baseSpirit", 20)); + r.startingMapId = jr.value("startingMapId", 0u); + r.startingZoneAreaId = jr.value("startingZoneAreaId", 0u); + r.defaultLanguageSpellId = jr.value("defaultLanguageSpellId", 0u); + r.mountSpellId = jr.value("mountSpellId", 0u); + c.races.push_back(r); + } + } + if (j.contains("outfits") && j["outfits"].is_array()) { + for (const auto& jo : j["outfits"]) { + wowee::pipeline::WoweeChars::Outfit o; + o.classId = jo.value("classId", 0u); + o.raceId = jo.value("raceId", 0u); + if (jo.contains("gender") && jo["gender"].is_number_integer()) { + o.gender = static_cast(jo["gender"].get()); + } else if (jo.contains("genderName") && jo["genderName"].is_string()) { + o.gender = genderFromName(jo["genderName"].get()); + } + if (jo.contains("items") && jo["items"].is_array()) { + for (const auto& ji : jo["items"]) { + wowee::pipeline::WoweeChars::OutfitItem it; + it.itemId = ji.value("itemId", 0u); + it.displaySlot = static_cast( + ji.value("displaySlot", 0)); + o.items.push_back(it); + } + } + c.outfits.push_back(std::move(o)); + } + } + if (!wowee::pipeline::WoweeCharsLoader::save(c, outBase)) { + std::fprintf(stderr, + "import-wchc-json: failed to save %s.wchc\n", outBase.c_str()); + return 1; + } + std::printf("Wrote %s.wchc\n", outBase.c_str()); + std::printf(" source : %s\n", jsonPath.c_str()); + std::printf(" classes : %zu races : %zu outfits : %zu\n", + c.classes.size(), c.races.size(), c.outfits.size()); + return 0; +} + int handleValidate(int& i, int argc, char** argv) { std::string base = argv[++i]; bool jsonOut = consumeJsonFlag(i, argc, argv); @@ -325,6 +552,12 @@ bool handleCharsCatalog(int& i, int argc, char** argv, int& outRc) { if (std::strcmp(argv[i], "--validate-wchc") == 0 && i + 1 < argc) { outRc = handleValidate(i, argc, argv); return true; } + if (std::strcmp(argv[i], "--export-wchc-json") == 0 && i + 1 < argc) { + outRc = handleExportJson(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--import-wchc-json") == 0 && i + 1 < argc) { + outRc = handleImportJson(i, argc, argv); return true; + } return false; } diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index d41e7b32..ba7d09fc 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -1089,6 +1089,10 @@ void printUsage(const char* argv0) { std::printf(" Print WCHC classes (id / power / hp scaling) + races (faction / starting zone) + outfit item lists\n"); std::printf(" --validate-wchc [--json]\n"); std::printf(" Static checks: class+race ids unique, baseHealth>0, faction availability set, outfit refs resolve\n"); + std::printf(" --export-wchc-json [out.json]\n"); + std::printf(" Export binary .wchc to a human-editable JSON sidecar (defaults to .wchc.json)\n"); + std::printf(" --import-wchc-json [out-base]\n"); + std::printf(" Import a .wchc.json sidecar back into binary .wchc (accepts power/faction/gender int OR name forms)\n"); std::printf(" --gen-tokens [name]\n"); std::printf(" Emit .wtkn starter: 3 tokens (Honor / Marks / Stormwind Guard) covering Pvp + Reputation categories\n"); std::printf(" --gen-tokens-pvp [name]\n");