mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-11 19:43:52 +00:00
feat(editor): add WVOX (Voiceover Audio) — 114th open format
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 with metadata covering audio path, duration, volume, gender hint for randomized casts, variant index for multiple lines per event, and a transcript field for accessibility (TTS engines + chat- bubble subtitles). Nine eventKind values cover the full NPC dialog surface: Greeting / Aggro / Death / QuestStart / QuestProgress / QuestComplete / Goodbye / Special / Phase. The Phase kind specifically supports boss-fight percentage milestones (75%/50%/25% transitions) where multiple Phase entries with distinct variantIndex disambiguate the boss-encounter scripting. Three preset emitters: makeQuestgiver (5-clip canonical quest dialog flow), makeBoss (6-clip Lich King fight with phase milestones at 75/50/25%, special mechanic call at +5dB for raid audibility, death line), makeVendor (4-clip vendor interaction). Validator's most novel check is per-(npcId, eventKind, variantIndex) triple uniqueness — two clips with all three matching would be ambiguous when the trigger handler picks one randomly. The vendor preset originally bound both Buy and Sell to (Special, 0) which the validator caught and flagged before commit; fix uses variantIndex 0 for Buy and 1 for Sell so the trigger handler can distinguish. Validator also warns on durationMs=0 with non-empty audioPath (subtitle sync impossible), volumeDb outside [-20,+6] (clip risk), and empty transcript (TTS + chat-bubble subtitle would be blank). Format count 113 -> 114. CLI flag count 1220 -> 1225.
This commit is contained in:
parent
a5a16cae52
commit
78555a79b0
10 changed files with 820 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
139
include/pipeline/wowee_voiceovers.hpp
Normal file
139
include/pipeline/wowee_voiceovers.hpp
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<Entry> 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<const Entry*> 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
|
||||
338
src/pipeline/wowee_voiceovers.cpp
Normal file
338
src/pipeline/wowee_voiceovers.cpp
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
#include "pipeline/wowee_voiceovers.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
|
||||
namespace wowee {
|
||||
namespace pipeline {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kMagic[4] = {'W', 'V', 'O', 'X'};
|
||||
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) != ".wvox") {
|
||||
base += ".wvox";
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
uint32_t packRgba(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 0xFF) {
|
||||
return (static_cast<uint32_t>(a) << 24) |
|
||||
(static_cast<uint32_t>(b) << 16) |
|
||||
(static_cast<uint32_t>(g) << 8) |
|
||||
static_cast<uint32_t>(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<const WoweeVoiceovers::Entry*>
|
||||
WoweeVoiceovers::findForTrigger(uint32_t npcId,
|
||||
uint8_t eventKind) const {
|
||||
std::vector<const Entry*> 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<uint32_t>(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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -2349,6 +2349,16 @@ void printUsage(const char* argv0) {
|
|||
std::printf(" Export binary .wspv to a human-editable JSON sidecar (defaults to <base>.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 <json-path> [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 <wvox-base> [name]\n");
|
||||
std::printf(" Emit .wvox 5 questgiver voice clips (Greeting / QuestStart / QuestProgress / QuestComplete / Goodbye)\n");
|
||||
std::printf(" --gen-vox-boss <wvox-base> [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 <wvox-base> [name]\n");
|
||||
std::printf(" Emit .wvox 4 vendor voice clips (Greeting / Buy / Sell / Goodbye)\n");
|
||||
std::printf(" --info-wvox <wvox-base> [--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 <wvox-base> [--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 <wXXX-file> <id> [--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 <directory> <id> [--magic <WXXX>] [--json]\n");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
312
tools/editor/cli_voiceovers_catalog.cpp
Normal file
312
tools/editor/cli_voiceovers_catalog.cpp
Normal file
|
|
@ -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 <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string> errors;
|
||||
std::vector<std::string> warnings;
|
||||
if (c.entries.empty()) {
|
||||
warnings.push_back("catalog has zero entries");
|
||||
}
|
||||
std::set<uint32_t> idsSeen;
|
||||
// Per-(npcId, eventKind, variantIndex) triple
|
||||
// uniqueness — two voice clips with all three
|
||||
// matching would be ambiguous (which one plays?).
|
||||
std::set<uint64_t> tripleSeen;
|
||||
auto tripleKey = [](uint32_t npc, uint8_t event,
|
||||
uint8_t variant) {
|
||||
return (static_cast<uint64_t>(npc) << 32) |
|
||||
(static_cast<uint64_t>(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
|
||||
12
tools/editor/cli_voiceovers_catalog.hpp
Normal file
12
tools/editor/cli_voiceovers_catalog.hpp
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue