diff --git a/CMakeLists.txt b/CMakeLists.txt index 6cb86f12..3b6ce952 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -702,6 +702,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_pet_care.cpp src/pipeline/wowee_movie_credits.cpp src/pipeline/wowee_spell_variants.cpp + src/pipeline/wowee_voiceovers.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1567,6 +1568,7 @@ add_executable(wowee_editor tools/editor/cli_pet_care_catalog.cpp tools/editor/cli_movie_credits_catalog.cpp tools/editor/cli_spell_variants_catalog.cpp + tools/editor/cli_voiceovers_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1751,6 +1753,7 @@ add_executable(wowee_editor src/pipeline/wowee_pet_care.cpp src/pipeline/wowee_movie_credits.cpp src/pipeline/wowee_spell_variants.cpp + src/pipeline/wowee_voiceovers.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_voiceovers.hpp b/include/pipeline/wowee_voiceovers.hpp new file mode 100644 index 00000000..ce336113 --- /dev/null +++ b/include/pipeline/wowee_voiceovers.hpp @@ -0,0 +1,139 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Voiceover Audio catalog (.wvox) — novel +// replacement for the implicit per-NPC voice dialog +// system vanilla WoW encoded across CreatureTextSounds +// (server-side aggro / death barks), npc_text (gossip +// audio cross-references), and per-quest dialog blobs. +// Each entry binds one NPC to one voice clip for one +// triggering event (greeting on talk, aggro on combat +// start, special-mechanic shout during boss fight, +// death scream, quest completion line, etc.). +// +// Cross-references with previously-added formats: +// WCRT: npcId references the WCRT creature catalog. +// WSND: each WVOX entry can be cross-referenced to a +// WSND entry by audioPath (the underlying sound +// resource); WVOX adds the per-NPC, per-event +// binding context that WSND alone doesn't carry. +// +// Binary layout (little-endian): +// magic[4] = "WVOX" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// voiceId (uint32) +// nameLen + name +// descLen + description +// npcId (uint32) +// eventKind (uint8) — Greeting / Aggro / +// Death / QuestStart / +// QuestProgress / +// QuestComplete / +// Goodbye / Special / +// Phase +// genderHint (uint8) — Male / Female / Both +// for randomized casts +// variantIndex (uint8) — 0..N for multiple +// lines per event +// (random pick at +// trigger time) +// pad0 (uint8) +// pathLen + audioPath — sound resource path +// transcriptLen + transcript — printable line text +// for accessibility + +// chat-bubble display +// durationMs (uint32) +// volumeDb (int8) — relative volume +// (-20 to +6 typical) +// pad1 (uint8) / pad2 (uint8) / pad3 (uint8) +// iconColorRGBA (uint32) +struct WoweeVoiceovers { + enum EventKind : uint8_t { + Greeting = 0, // initial NPC chat + Aggro = 1, // combat start + Death = 2, // NPC dies + QuestStart = 3, // accept quest + QuestProgress = 4, // mid-quest checkpoint + QuestComplete = 5, // turn-in + Goodbye = 6, // chat close + Special = 7, // boss-fight mechanic call + Phase = 8, // boss phase transition + }; + + enum GenderHint : uint8_t { + Male = 0, + Female = 1, + Both = 2, + }; + + struct Entry { + uint32_t voiceId = 0; + std::string name; + std::string description; + uint32_t npcId = 0; + uint8_t eventKind = Greeting; + uint8_t genderHint = Both; + uint8_t variantIndex = 0; + uint8_t pad0 = 0; + std::string audioPath; + std::string transcript; + uint32_t durationMs = 0; + int8_t volumeDb = 0; + uint8_t pad1 = 0; + uint8_t pad2 = 0; + uint8_t pad3 = 0; + uint32_t iconColorRGBA = 0xFFFFFFFFu; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t voiceId) const; + + // Returns all voice entries for a given (npc, event) + // pair. The trigger handler picks one randomly when + // multiple variantIndex values are available — the + // boss-aggro handler might have 3 lines and pick one + // per encounter for variety. + std::vector findForTrigger(uint32_t npcId, + uint8_t eventKind) const; +}; + +class WoweeVoiceoversLoader { +public: + static bool save(const WoweeVoiceovers& cat, + const std::string& basePath); + static WoweeVoiceovers load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-vox* variants. + // + // makeQuestgiver — 5 voice clips for one quest + // NPC (Greeting / QuestStart / + // QuestProgress / QuestComplete + // / Goodbye). + // makeBoss — 6 boss voice clips with phase + // milestones (Aggro / 75% / + // 50% / 25% / Special Mechanic + // / Death). + // makeVendor — 4 vendor voice clips (Greeting + // / Buy = Goodbye / Sell / + // Goodbye-final). + static WoweeVoiceovers makeQuestgiver(const std::string& catalogName); + static WoweeVoiceovers makeBoss(const std::string& catalogName); + static WoweeVoiceovers makeVendor(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_voiceovers.cpp b/src/pipeline/wowee_voiceovers.cpp new file mode 100644 index 00000000..4101f9c5 --- /dev/null +++ b/src/pipeline/wowee_voiceovers.cpp @@ -0,0 +1,338 @@ +#include "pipeline/wowee_voiceovers.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'V', 'O', 'X'}; +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) != ".wvox") { + base += ".wvox"; + } + 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 WoweeVoiceovers::Entry* +WoweeVoiceovers::findById(uint32_t voiceId) const { + for (const auto& e : entries) + if (e.voiceId == voiceId) return &e; + return nullptr; +} + +std::vector +WoweeVoiceovers::findForTrigger(uint32_t npcId, + uint8_t eventKind) const { + std::vector out; + for (const auto& e : entries) { + if (e.npcId == npcId && e.eventKind == eventKind) + out.push_back(&e); + } + return out; +} + +bool WoweeVoiceoversLoader::save(const WoweeVoiceovers& 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.voiceId); + writeStr(os, e.name); + writeStr(os, e.description); + writePOD(os, e.npcId); + writePOD(os, e.eventKind); + writePOD(os, e.genderHint); + writePOD(os, e.variantIndex); + writePOD(os, e.pad0); + writeStr(os, e.audioPath); + writeStr(os, e.transcript); + writePOD(os, e.durationMs); + writePOD(os, e.volumeDb); + writePOD(os, e.pad1); + writePOD(os, e.pad2); + writePOD(os, e.pad3); + writePOD(os, e.iconColorRGBA); + } + return os.good(); +} + +WoweeVoiceovers WoweeVoiceoversLoader::load( + const std::string& basePath) { + WoweeVoiceovers 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.voiceId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name) || !readStr(is, e.description)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.npcId) || + !readPOD(is, e.eventKind) || + !readPOD(is, e.genderHint) || + !readPOD(is, e.variantIndex) || + !readPOD(is, e.pad0)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.audioPath) || + !readStr(is, e.transcript)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.durationMs) || + !readPOD(is, e.volumeDb) || + !readPOD(is, e.pad1) || + !readPOD(is, e.pad2) || + !readPOD(is, e.pad3) || + !readPOD(is, e.iconColorRGBA)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeVoiceoversLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeVoiceovers WoweeVoiceoversLoader::makeQuestgiver( + const std::string& catalogName) { + using V = WoweeVoiceovers; + WoweeVoiceovers c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint8_t kind, uint8_t variant, + const char* audioPath, + const char* transcript, + uint32_t durMs, const char* desc) { + V::Entry e; + e.voiceId = id; e.name = name; e.description = desc; + e.npcId = 18486; // generic questgiver + // (placeholder) + e.eventKind = kind; + e.genderHint = V::Male; + e.variantIndex = variant; + e.audioPath = audioPath; + e.transcript = transcript; + e.durationMs = durMs; + e.volumeDb = 0; + e.iconColorRGBA = packRgba(220, 220, 100); // quest gold + c.entries.push_back(e); + }; + add(1, "Greeting", V::Greeting, 0, + "Sound\\Creature\\Questgiver\\Greeting01.ogg", + "Hail, hero. I have a task that needs doing.", + 2400, + "Initial greeting on first NPC right-click. " + "First impression voice line."); + add(2, "QuestStart", V::QuestStart, 0, + "Sound\\Creature\\Questgiver\\Accept01.ogg", + "Excellent. The blade and crown both await.", + 3100, + "Played when player clicks Accept on the quest. " + "Tone of approval."); + add(3, "QuestProgress", V::QuestProgress, 0, + "Sound\\Creature\\Questgiver\\Progress01.ogg", + "Be quick about it. Time grows short.", + 2200, + "Played when player re-talks NPC mid-quest. " + "Gentle reminder."); + add(4, "QuestComplete", V::QuestComplete, 0, + "Sound\\Creature\\Questgiver\\Reward01.ogg", + "Well done! Take this as a token of my gratitude.", + 3800, + "Played at quest turn-in. Most emotive line — " + "celebration register."); + add(5, "Goodbye", V::Goodbye, 0, + "Sound\\Creature\\Questgiver\\Goodbye01.ogg", + "Safe travels, friend.", + 1500, + "Played when chat window closes. Short, " + "polite sign-off."); + return c; +} + +WoweeVoiceovers WoweeVoiceoversLoader::makeBoss( + const std::string& catalogName) { + using V = WoweeVoiceovers; + WoweeVoiceovers c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint8_t kind, uint8_t variant, + const char* audioPath, + const char* transcript, + uint32_t durMs, int8_t volume, + const char* desc) { + V::Entry e; + e.voiceId = id; e.name = name; e.description = desc; + e.npcId = 36597; // The Lich King (placeholder) + e.eventKind = kind; + e.genderHint = V::Male; + e.variantIndex = variant; + e.audioPath = audioPath; + e.transcript = transcript; + e.durationMs = durMs; + e.volumeDb = volume; + e.iconColorRGBA = packRgba(220, 60, 60); // boss red + c.entries.push_back(e); + }; + add(100, "BossAggro", V::Aggro, 0, + "Sound\\Creature\\LichKing\\Aggro01.ogg", + "So, the Light's vengeance has come.", + 3500, +3, + "Boss aggro line played on combat start. +3dB " + "louder than ambient for emphasis."); + add(101, "Boss75Pct", V::Phase, 0, + "Sound\\Creature\\LichKing\\Phase75.ogg", + "You are not the only ones with arrows in " + "your quivers!", + 4200, +3, + "Phase 1 -> Phase 2 transition at 75%% boss " + "health."); + add(102, "Boss50Pct", V::Phase, 1, + "Sound\\Creature\\LichKing\\Phase50.ogg", + "I will freeze you from the inside out!", + 3800, +3, + "Phase 2 -> Phase 3 at 50%% health. Frostmourne " + "phase begins."); + add(103, "Boss25Pct", V::Phase, 2, + "Sound\\Creature\\LichKing\\Phase25.ogg", + "I'll keep you alive to witness the end.", + 3200, +3, + "Phase 3 -> final phase at 25%% health. Most " + "dangerous mechanics activate."); + add(104, "BossMechanicCall", V::Special, 0, + "Sound\\Creature\\LichKing\\Defile01.ogg", + "Apocalypse!", + 2000, +5, + "Special mechanic call (Defile cast warning). " + "+5dB above ambient — must be audible over " + "raid noise."); + add(105, "BossDeath", V::Death, 0, + "Sound\\Creature\\LichKing\\Death01.ogg", + "No... it cannot be...", + 4500, +2, + "Death line — emotional conclusion."); + return c; +} + +WoweeVoiceovers WoweeVoiceoversLoader::makeVendor( + const std::string& catalogName) { + using V = WoweeVoiceovers; + WoweeVoiceovers c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint8_t kind, uint8_t variant, + uint8_t gender, + const char* audioPath, + const char* transcript, + uint32_t durMs, const char* desc) { + V::Entry e; + e.voiceId = id; e.name = name; e.description = desc; + e.npcId = 11498; // generic vendor + e.eventKind = kind; + e.genderHint = gender; + e.variantIndex = variant; + e.audioPath = audioPath; + e.transcript = transcript; + e.durationMs = durMs; + e.volumeDb = 0; + e.iconColorRGBA = packRgba(140, 200, 255); // vendor blue + c.entries.push_back(e); + }; + add(200, "VendorGreeting", V::Greeting, 0, V::Female, + "Sound\\Creature\\Vendor\\Greeting01.ogg", + "Looking to buy or sell?", + 1900, + "Played when player opens the vendor window. " + "Female-cast voice line."); + // Buy and Sell both use eventKind=Special since + // there's no dedicated buy/sell enum value; distinct + // variantIndex (0 = buy, 1 = sell) keeps them + // disambiguated for the trigger handler. + add(201, "VendorBuy", V::Special, 0, V::Female, + "Sound\\Creature\\Vendor\\Buy01.ogg", + "A fine choice. Will there be anything else?", + 2400, + "Played when player buys an item. variantIndex=0 " + "for buy; sell is variantIndex=1 to avoid the " + "validator's per-(npc,event,variant) collision."); + add(202, "VendorSell", V::Special, 1, V::Female, + "Sound\\Creature\\Vendor\\Sell01.ogg", + "I'll take this off your hands.", + 2100, + "Played when player sells an item back to " + "the vendor."); + add(203, "VendorGoodbye", V::Goodbye, 0, V::Female, + "Sound\\Creature\\Vendor\\Goodbye01.ogg", + "Come back soon!", + 1300, + "Played when vendor window closes."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index f5aeaa57..eb825688 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -350,6 +350,8 @@ const char* const kArgRequired[] = { "--gen-spv", "--gen-spv-talent", "--gen-spv-racial", "--info-wspv", "--validate-wspv", "--export-wspv-json", "--import-wspv-json", + "--gen-vox", "--gen-vox-boss", "--gen-vox-vendor", + "--info-wvox", "--validate-wvox", "--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 d9e0b570..c898b536 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -158,6 +158,7 @@ #include "cli_pet_care_catalog.hpp" #include "cli_movie_credits_catalog.hpp" #include "cli_spell_variants_catalog.hpp" +#include "cli_voiceovers_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -361,6 +362,7 @@ constexpr DispatchFn kDispatchTable[] = { handlePetCareCatalog, handleMovieCreditsCatalog, handleSpellVariantsCatalog, + handleVoiceoversCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 36f02427..493df265 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -116,6 +116,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','P','C','R'}, ".wpcr", "pets", "--info-wpcr", "Pet care + action catalog"}, {{'W','M','V','C'}, ".wmvc", "cinematic", "--info-wmvc", "Movie credits roll catalog"}, {{'W','S','P','V'}, ".wspv", "spells", "--info-wspv", "Spell variant catalog"}, + {{'W','V','O','X'}, ".wvox", "audio", "--info-wvox", "Voiceover audio 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 4e6dd6a5..261e2193 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2349,6 +2349,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wspv to a human-editable JSON sidecar (defaults to .wspv.json; emits conditionKind as both int AND name string; conditionValue stays as raw uint32 — its semantics depend on conditionKind so no general-purpose pretty-printing)\n"); std::printf(" --import-wspv-json [out-base]\n"); std::printf(" Import a .wspv.json sidecar back into binary .wspv (conditionKind int OR \"stance\"/\"form\"/\"talent\"/\"race\"/\"equippedweapon\"/\"auraactive\")\n"); + std::printf(" --gen-vox [name]\n"); + std::printf(" Emit .wvox 5 questgiver voice clips (Greeting / QuestStart / QuestProgress / QuestComplete / Goodbye)\n"); + std::printf(" --gen-vox-boss [name]\n"); + std::printf(" Emit .wvox 6 boss voice clips with phase milestones (Aggro / 75%% Phase / 50%% Phase / 25%% Phase / Special Mechanic / Death) — Lich King example\n"); + std::printf(" --gen-vox-vendor [name]\n"); + std::printf(" Emit .wvox 4 vendor voice clips (Greeting / Buy / Sell / Goodbye)\n"); + std::printf(" --info-wvox [--json]\n"); + std::printf(" Print WVOX entries (id / npcId / eventKind / gender / variant / duration ms / volume dB / name) plus per-entry transcript\n"); + std::printf(" --validate-wvox [--json]\n"); + std::printf(" Static checks: id+name+npcId required, eventKind 0..8, genderHint 0..2, audioPath non-empty, no duplicate voiceIds, no two clips at same (npcId, eventKind, variantIndex) triple (random pick at trigger time would be ambiguous); warns on durationMs=0 (subtitle sync impossible), volumeDb outside [-20,+6] (clip risk), empty transcript (TTS+chat-bubble blank)\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 cca02165..904ec622 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -138,6 +138,7 @@ constexpr FormatRow kFormats[] = { {"WPCR", ".wpcr", "pets", "Spell.dbc pet ops + npc_text stable","Pet care + action catalog (Hunter / Warlock / stable mgmt)"}, {"WMVC", ".wmvc", "cinematic", "embedded cinematic credit-roll blob","Movie credits roll catalog (per-cinematic)"}, {"WSPV", ".wspv", "spells", "implicit Spell.dbc context overrides","Spell variant catalog (stance/talent/racial substitution)"}, + {"WVOX", ".wvox", "audio", "CreatureTextSounds + per-quest voice","Voiceover audio catalog (per-NPC, per-event clips)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_voiceovers_catalog.cpp b/tools/editor/cli_voiceovers_catalog.cpp new file mode 100644 index 00000000..ab9f8106 --- /dev/null +++ b/tools/editor/cli_voiceovers_catalog.cpp @@ -0,0 +1,312 @@ +#include "cli_voiceovers_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_voiceovers.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWvoxExt(std::string base) { + stripExt(base, ".wvox"); + return base; +} + +const char* eventKindName(uint8_t k) { + using V = wowee::pipeline::WoweeVoiceovers; + switch (k) { + case V::Greeting: return "greeting"; + case V::Aggro: return "aggro"; + case V::Death: return "death"; + case V::QuestStart: return "queststart"; + case V::QuestProgress: return "questprogress"; + case V::QuestComplete: return "questcomplete"; + case V::Goodbye: return "goodbye"; + case V::Special: return "special"; + case V::Phase: return "phase"; + default: return "unknown"; + } +} + +const char* genderHintName(uint8_t g) { + using V = wowee::pipeline::WoweeVoiceovers; + switch (g) { + case V::Male: return "male"; + case V::Female: return "female"; + case V::Both: return "both"; + default: return "unknown"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeVoiceovers& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeVoiceoversLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wvox\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeVoiceovers& c, + const std::string& base) { + std::printf("Wrote %s.wvox\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" voices : %zu\n", c.entries.size()); +} + +int handleGenQuest(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "QuestgiverVoices"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWvoxExt(base); + auto c = wowee::pipeline::WoweeVoiceoversLoader::makeQuestgiver(name); + if (!saveOrError(c, base, "gen-vox")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenBoss(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "LichKingVoices"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWvoxExt(base); + auto c = wowee::pipeline::WoweeVoiceoversLoader::makeBoss(name); + if (!saveOrError(c, base, "gen-vox-boss")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenVendor(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "VendorVoices"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWvoxExt(base); + auto c = wowee::pipeline::WoweeVoiceoversLoader::makeVendor(name); + if (!saveOrError(c, base, "gen-vox-vendor")) 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 = stripWvoxExt(base); + if (!wowee::pipeline::WoweeVoiceoversLoader::exists(base)) { + std::fprintf(stderr, "WVOX not found: %s.wvox\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeVoiceoversLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wvox"] = base + ".wvox"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"voiceId", e.voiceId}, + {"name", e.name}, + {"description", e.description}, + {"npcId", e.npcId}, + {"eventKind", e.eventKind}, + {"eventKindName", eventKindName(e.eventKind)}, + {"genderHint", e.genderHint}, + {"genderHintName", genderHintName(e.genderHint)}, + {"variantIndex", e.variantIndex}, + {"audioPath", e.audioPath}, + {"transcript", e.transcript}, + {"durationMs", e.durationMs}, + {"volumeDb", e.volumeDb}, + {"iconColorRGBA", e.iconColorRGBA}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WVOX: %s.wvox\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" voices : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id npc event gender var dur(ms) dB name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %5u %-13s %-6s %2u %5u %+3d %s\n", + e.voiceId, e.npcId, + eventKindName(e.eventKind), + genderHintName(e.genderHint), + e.variantIndex, e.durationMs, + e.volumeDb, e.name.c_str()); + if (!e.transcript.empty()) { + std::printf(" > \"%s\"\n", + e.transcript.c_str()); + } + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWvoxExt(base); + if (!wowee::pipeline::WoweeVoiceoversLoader::exists(base)) { + std::fprintf(stderr, + "validate-wvox: WVOX not found: %s.wvox\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeVoiceoversLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set idsSeen; + // Per-(npcId, eventKind, variantIndex) triple + // uniqueness — two voice clips with all three + // matching would be ambiguous (which one plays?). + std::set tripleSeen; + auto tripleKey = [](uint32_t npc, uint8_t event, + uint8_t variant) { + return (static_cast(npc) << 32) | + (static_cast(event) << 8) | + variant; + }; + 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.voiceId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.voiceId == 0) + errors.push_back(ctx + ": voiceId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.npcId == 0) { + errors.push_back(ctx + + ": npcId is 0 — voice clip is unbound to " + "any creature"); + } + if (e.eventKind > 8) { + errors.push_back(ctx + ": eventKind " + + std::to_string(e.eventKind) + + " out of range (must be 0..8)"); + } + if (e.genderHint > 2) { + errors.push_back(ctx + ": genderHint " + + std::to_string(e.genderHint) + + " out of range (must be 0..2)"); + } + if (e.audioPath.empty()) { + errors.push_back(ctx + + ": audioPath is empty — voice clip would " + "play no audio"); + } + if (e.durationMs == 0 && !e.audioPath.empty()) { + warnings.push_back(ctx + + ": durationMs=0 but audioPath set — " + "trigger handler can't subtitle-sync " + "without duration; consider populating " + "from the audio file's actual length"); + } + if (e.volumeDb < -20 || e.volumeDb > 6) { + warnings.push_back(ctx + ": volumeDb " + + std::to_string(e.volumeDb) + + " outside [-20, +6] typical range — " + "extreme values may clip or be inaudible"); + } + if (e.transcript.empty()) { + warnings.push_back(ctx + + ": transcript is empty — accessibility " + "TTS engines + chat-bubble subtitles " + "have no text to display"); + } + // Triple uniqueness: same NPC + event + variant + // would pick non-deterministically. + if (e.npcId != 0) { + uint64_t key = tripleKey(e.npcId, e.eventKind, + e.variantIndex); + if (!tripleSeen.insert(key).second) { + errors.push_back(ctx + + ": (npcId=" + std::to_string(e.npcId) + + ", eventKind=" + + std::string(eventKindName(e.eventKind)) + + ", variantIndex=" + + std::to_string(e.variantIndex) + + ") triple already bound by another " + "voice clip — random pick at trigger " + "time would be ambiguous"); + } + } + if (!idsSeen.insert(e.voiceId).second) { + errors.push_back(ctx + ": duplicate voiceId"); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wvox"] = base + ".wvox"; + 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-wvox: %s.wvox\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu voice clips, all voiceIds + " + "(npc,event,variant) triples unique\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 handleVoiceoversCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-vox") == 0 && i + 1 < argc) { + outRc = handleGenQuest(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-vox-boss") == 0 && i + 1 < argc) { + outRc = handleGenBoss(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-vox-vendor") == 0 && + i + 1 < argc) { + outRc = handleGenVendor(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wvox") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wvox") == 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_voiceovers_catalog.hpp b/tools/editor/cli_voiceovers_catalog.hpp new file mode 100644 index 00000000..3ac3b75a --- /dev/null +++ b/tools/editor/cli_voiceovers_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleVoiceoversCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee