From 8c36fc52743c3d43e19f5fa21d8961c55012292f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 10 May 2026 05:32:23 -0700 Subject: [PATCH] feat(pipeline): WSWP sound swap rules catalog (141st open format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel format covering a need vanilla WoW lacked entirely: priority-based sound substitution. Blizzard had no formal mechanism for swapping a stock SoundEntry for a custom replacement conditionally on zone/class/race/gender; the closest equivalents were patch-level SoundEntries.dbc edits with no condition support. Each WSWP entry binds one (originalSoundId, condition) trigger to a replacementSoundId, a priority index for tie-breaking (higher wins), and an optional gain adjustment in 0.1 dB units (range ±30 dB practical mixer limit). Three presets covering common substitution scenarios: --gen-swp-bosses 3 raid-boss zone-only swaps (Onyxia roar in Onyxia's Lair / Ragnaros emerge in Molten Core +2dB / Nefarian shout in BWL). Priority 100 — beats global rules --gen-swp-race 3 race-conditional voice swaps (BloodElf priest / Tauren shaman / Undead warlock cast voices). Priority 50 --gen-swp-ui 3 always-on UI sound swaps (level-up / quest-complete / mount-up) at priority 10 with +3dB gain (boss/race overrides win the priority fight) Validator catches: id+name+original+replacement required, conditionKind 0..4, no duplicate ruleIds, no self-replacement (orig==repl is a no-op slot), non-Always kinds require non- zero conditionValue (kind without target = matches everything, duplicating Always semantics). CRITICAL: no duplicate (originalSoundId, conditionKind, conditionValue) trigger triple — runtime would have two rules for the same trigger without a tie-breaker. Warns on priorityIndex=0 (rule never wins), |gainAdjustDb_x10| > 300 (clip risk), Always condition with non-zero conditionValue (dead data ignored at runtime), and same-priority within same originalSoundId (tie-break undefined when both rules' conditions match simultaneously). Format count 140 -> 141. CLI flag count 1463 -> 1470. --- CMakeLists.txt | 3 + include/pipeline/wowee_sound_swap.hpp | 132 +++++++++ src/pipeline/wowee_sound_swap.cpp | 247 +++++++++++++++++ tools/editor/cli_arg_required.cpp | 2 + tools/editor/cli_dispatch.cpp | 2 + tools/editor/cli_format_table.cpp | 1 + tools/editor/cli_help.cpp | 10 + tools/editor/cli_list_formats.cpp | 1 + tools/editor/cli_sound_swap_catalog.cpp | 347 ++++++++++++++++++++++++ tools/editor/cli_sound_swap_catalog.hpp | 12 + 10 files changed, 757 insertions(+) create mode 100644 include/pipeline/wowee_sound_swap.hpp create mode 100644 src/pipeline/wowee_sound_swap.cpp create mode 100644 tools/editor/cli_sound_swap_catalog.cpp create mode 100644 tools/editor/cli_sound_swap_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 604dcd99..0c40f9db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -729,6 +729,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_spell_proc_rules.cpp src/pipeline/wowee_auction_houses.cpp src/pipeline/wowee_battleground_rewards.cpp + src/pipeline/wowee_sound_swap.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1621,6 +1622,7 @@ add_executable(wowee_editor tools/editor/cli_spell_proc_rules_catalog.cpp tools/editor/cli_auction_houses_catalog.cpp tools/editor/cli_battleground_rewards_catalog.cpp + tools/editor/cli_sound_swap_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1832,6 +1834,7 @@ add_executable(wowee_editor src/pipeline/wowee_spell_proc_rules.cpp src/pipeline/wowee_auction_houses.cpp src/pipeline/wowee_battleground_rewards.cpp + src/pipeline/wowee_sound_swap.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_sound_swap.hpp b/include/pipeline/wowee_sound_swap.hpp new file mode 100644 index 00000000..20635e21 --- /dev/null +++ b/include/pipeline/wowee_sound_swap.hpp @@ -0,0 +1,132 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Sound Swap Rules catalog (.wswp) — +// novel format covering a need vanilla WoW lacked +// entirely: priority-based sound substitution +// (Blizzard had no formal mechanism for swapping a +// stock SoundEntry for a custom replacement +// conditionally on zone/class/race; the closest +// equivalents were patch-level SoundEntries.dbc +// edits with no condition support). Each WSWP +// entry binds one (originalSoundId, condition) +// trigger to a replacementSoundId, a priority +// index for tie-breaking (higher wins), and an +// optional gain adjustment in 0.1 dB units. +// +// Cross-references with previously-added formats: +// WSND: originalSoundId AND replacementSoundId +// both reference the WSND sound entry +// catalog. +// WMS: conditionKind=ZoneOnly conditionValue +// is a WMS mapId. +// +// Binary layout (little-endian): +// magic[4] = "WSWP" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// ruleId (uint32) +// nameLen + name +// originalSoundId (uint32) — WSND ref +// replacementSoundId (uint32) — WSND ref +// conditionKind (uint8) — 0=Always / +// 1=ZoneOnly / +// 2=ClassOnly / +// 3=RaceOnly / +// 4=GenderOnly +// priorityIndex (uint8) — higher wins +// when multiple +// rules match +// (1..255; 0 = +// never picked) +// gainAdjustDb_x10 (int16) — gain in 0.1 dB +// units, range +// [-300..+300] = +// -30..+30 dB +// conditionValue (uint32) — interpretation +// depends on +// conditionKind +// (mapId / classId +// / raceId / +// genderId; 0 for +// Always) +struct WoweeSoundSwap { + enum ConditionKind : uint8_t { + Always = 0, + ZoneOnly = 1, + ClassOnly = 2, + RaceOnly = 3, + GenderOnly = 4, + }; + + struct Entry { + uint32_t ruleId = 0; + std::string name; + uint32_t originalSoundId = 0; + uint32_t replacementSoundId = 0; + uint8_t conditionKind = Always; + uint8_t priorityIndex = 0; + int16_t gainAdjustDb_x10 = 0; + uint32_t conditionValue = 0; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t ruleId) const; + + // Returns all rules that target a given + // originalSoundId — used by the audio dispatch + // hot path to walk candidate replacements for an + // about-to-play sound. + std::vector findByOriginalSound(uint32_t soundId) const; +}; + +class WoweeSoundSwapLoader { +public: + static bool save(const WoweeSoundSwap& cat, + const std::string& basePath); + static WoweeSoundSwap load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-swp* variants. + // + // makeBossOverrides — 3 raid-boss sound + // swaps (Onyxia roar / + // Ragnaros emerge / + // Nefarian shout) all + // ZoneOnly to their + // respective raid + // mapIds. + // makeRaceVoices — 3 race-specific voice + // replacements + // (BloodElf priest cast + // / Tauren shaman cast / + // Undead warlock cast) + // conditionKind= + // RaceOnly. + // makeGlobalUI — 3 always-on UI sound + // replacements + // (level-up / quest- + // complete / mount-up) + // with gainAdjust=+30 + // (+3 dB to make custom + // sounds slightly + // louder than default). + static WoweeSoundSwap makeBossOverrides(const std::string& catalogName); + static WoweeSoundSwap makeRaceVoices(const std::string& catalogName); + static WoweeSoundSwap makeGlobalUI(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_sound_swap.cpp b/src/pipeline/wowee_sound_swap.cpp new file mode 100644 index 00000000..fc6ca482 --- /dev/null +++ b/src/pipeline/wowee_sound_swap.cpp @@ -0,0 +1,247 @@ +#include "pipeline/wowee_sound_swap.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'S', 'W', 'P'}; +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) != ".wswp") { + base += ".wswp"; + } + return base; +} + +} // namespace + +const WoweeSoundSwap::Entry* +WoweeSoundSwap::findById(uint32_t ruleId) const { + for (const auto& e : entries) + if (e.ruleId == ruleId) return &e; + return nullptr; +} + +std::vector +WoweeSoundSwap::findByOriginalSound(uint32_t soundId) const { + std::vector out; + for (const auto& e : entries) + if (e.originalSoundId == soundId) + out.push_back(&e); + return out; +} + +bool WoweeSoundSwapLoader::save(const WoweeSoundSwap& 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.ruleId); + writeStr(os, e.name); + writePOD(os, e.originalSoundId); + writePOD(os, e.replacementSoundId); + writePOD(os, e.conditionKind); + writePOD(os, e.priorityIndex); + writePOD(os, e.gainAdjustDb_x10); + writePOD(os, e.conditionValue); + } + return os.good(); +} + +WoweeSoundSwap WoweeSoundSwapLoader::load( + const std::string& basePath) { + WoweeSoundSwap 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.ruleId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.originalSoundId) || + !readPOD(is, e.replacementSoundId) || + !readPOD(is, e.conditionKind) || + !readPOD(is, e.priorityIndex) || + !readPOD(is, e.gainAdjustDb_x10) || + !readPOD(is, e.conditionValue)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeSoundSwapLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +namespace { + +WoweeSoundSwap::Entry makeRule( + uint32_t ruleId, const char* name, + uint32_t origSound, uint32_t replSound, + uint8_t conditionKind, uint8_t priority, + int16_t gainAdjustDb_x10, + uint32_t conditionValue) { + WoweeSoundSwap::Entry e; + e.ruleId = ruleId; e.name = name; + e.originalSoundId = origSound; + e.replacementSoundId = replSound; + e.conditionKind = conditionKind; + e.priorityIndex = priority; + e.gainAdjustDb_x10 = gainAdjustDb_x10; + e.conditionValue = conditionValue; + return e; +} + +} // namespace + +WoweeSoundSwap WoweeSoundSwapLoader::makeBossOverrides( + const std::string& catalogName) { + using S = WoweeSoundSwap; + WoweeSoundSwap c; + c.name = catalogName; + // Onyxia (mapId 249 — Onyxia's Lair). Replace + // her stock dragon roar (FDID 1234) with a + // beefier custom roar (FDID 5001). Priority 100 + // ensures it wins over global rules. + c.entries.push_back(makeRule( + 1, "Onyxia Custom Roar", + 1234 /* stock dragon roar */, + 5001 /* custom Onyxia roar */, + S::ZoneOnly, 100, 0, + 249 /* Onyxia's Lair mapId */)); + // Ragnaros emerge sound (FDID 1567) -> custom + // (5002). Molten Core mapId 409. +20 (+2dB) + // gain to make it dramatic. + c.entries.push_back(makeRule( + 2, "Ragnaros Emerge Sound", + 1567 /* stock Ragnaros emerge */, + 5002 /* custom emerge */, + S::ZoneOnly, 100, 20, + 409 /* Molten Core */)); + // Nefarian shout in BWL (mapId 469). FDID 1789 + // -> 5003. Priority 100, no gain adjust. + c.entries.push_back(makeRule( + 3, "Nefarian Shout", + 1789, 5003, + S::ZoneOnly, 100, 0, + 469 /* BWL */)); + return c; +} + +WoweeSoundSwap WoweeSoundSwapLoader::makeRaceVoices( + const std::string& catalogName) { + using S = WoweeSoundSwap; + WoweeSoundSwap c; + c.name = catalogName; + // BloodElf priest cast voice (sound 2001) -> + // custom (5101). Race id 10 = BloodElf. + // Priority 50 (less than boss overrides). + c.entries.push_back(makeRule( + 10, "BloodElf Priest Cast Voice", + 2001 /* stock cast voice */, + 5101 /* custom BE voice */, + S::RaceOnly, 50, 0, + 10 /* BloodElf raceId */)); + // Tauren shaman cast voice (sound 2002) -> + // 5102. Race id 6 = Tauren. + c.entries.push_back(makeRule( + 11, "Tauren Shaman Cast Voice", + 2002, 5102, + S::RaceOnly, 50, 0, + 6 /* Tauren raceId */)); + // Undead warlock cast voice (sound 2003) -> + // 5103. Race id 5 = Undead. + c.entries.push_back(makeRule( + 12, "Undead Warlock Cast Voice", + 2003, 5103, + S::RaceOnly, 50, 0, + 5 /* Undead raceId */)); + return c; +} + +WoweeSoundSwap WoweeSoundSwapLoader::makeGlobalUI( + const std::string& catalogName) { + using S = WoweeSoundSwap; + WoweeSoundSwap c; + c.name = catalogName; + // Level-up (sound 888) -> custom 5201, +3 dB + // gain adjust to make custom version slightly + // louder. Always condition. Lowest priority + // (10) because boss/race overrides should win. + c.entries.push_back(makeRule( + 20, "Custom Level-Up Fanfare", + 888 /* stock level-up */, + 5201, S::Always, 10, 30, + 0)); + // Quest-complete sound (sound 877) -> 5202. + c.entries.push_back(makeRule( + 21, "Custom Quest-Complete", + 877, 5202, S::Always, 10, 30, + 0)); + // Mount-up (sound 866) -> 5203. +3 dB. + c.entries.push_back(makeRule( + 22, "Custom Mount-Up", + 866, 5203, S::Always, 10, 30, + 0)); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index a667cfa7..4c47f86b 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -431,6 +431,8 @@ const char* const kArgRequired[] = { "--gen-brd-av", "--gen-brd-wsg", "--gen-brd-ab", "--info-wbrd", "--validate-wbrd", "--export-wbrd-json", "--import-wbrd-json", + "--gen-swp-bosses", "--gen-swp-race", "--gen-swp-ui", + "--info-wswp", "--validate-wswp", "--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 9e3ed084..e6724718 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -185,6 +185,7 @@ #include "cli_spell_proc_rules_catalog.hpp" #include "cli_auction_houses_catalog.hpp" #include "cli_battleground_rewards_catalog.hpp" +#include "cli_sound_swap_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -415,6 +416,7 @@ constexpr DispatchFn kDispatchTable[] = { handleSpellProcRulesCatalog, handleAuctionHousesCatalog, handleBattlegroundRewardsCatalog, + handleSoundSwapCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 62946f9b..e29b8355 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -143,6 +143,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','P','R','C'}, ".wprc", "spells", "--info-wprc", "Spell proc rules catalog"}, {{'W','A','U','H'}, ".wauh", "economy", "--info-wauh", "Auction house config catalog"}, {{'W','B','R','D'}, ".wbrd", "pvp", "--info-wbrd", "Battleground reward stages catalog"}, + {{'W','S','W','P'}, ".wswp", "audio", "--info-wswp", "Sound swap rules 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 0d69e06c..7f968ad5 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2727,6 +2727,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wbrd to a human-editable JSON sidecar (defaults to .wbrd.json; battlegroundName field is informational; battlegroundId int is authoritative)\n"); std::printf(" --import-wbrd-json [out-base]\n"); std::printf(" Import a .wbrd.json sidecar back into binary .wbrd (battlegroundName ignored; bgId int authoritative — round-trips per-bracket reward tables byte-identical)\n"); + std::printf(" --gen-swp-bosses [name]\n"); + std::printf(" Emit .wswp 3 raid-boss zone-only sound swaps (Onyxia roar in Onyxia's Lair, Ragnaros emerge in Molten Core, Nefarian shout in BWL — priority 100)\n"); + std::printf(" --gen-swp-race [name]\n"); + std::printf(" Emit .wswp 3 race-conditional voice swaps (BloodElf priest cast / Tauren shaman cast / Undead warlock cast — priority 50)\n"); + std::printf(" --gen-swp-ui [name]\n"); + std::printf(" Emit .wswp 3 always-on UI sound swaps (level-up / quest-complete / mount-up) with +3dB gain adjustment (priority 10 — boss/race overrides win)\n"); + std::printf(" --info-wswp [--json]\n"); + std::printf(" Print WSWP entries (id / origSound / replSound / conditionKind+value / priority / gain dB / name)\n"); + std::printf(" --validate-wswp [--json]\n"); + std::printf(" Static checks: id+name+originalSoundId+replacementSoundId required, conditionKind 0..4, no duplicate ruleIds, no self-replacement (orig==repl is no-op); CRITICAL: no duplicate (originalSoundId, conditionKind, conditionValue) trigger triple (runtime would have two rules for the same trigger), non-Always conditionKind requires non-zero conditionValue. Warns on priorityIndex=0 (effectively disabled), |gainAdjustDb_x10| > 300 (±30dB clip risk), Always condition with non-zero conditionValue (dead data), and same-priority within same originalSoundId (tie-break undefined when both conditions match)\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 d84d5b4c..034e74ac 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -165,6 +165,7 @@ constexpr FormatRow kFormats[] = { {"WPRC", ".wprc", "spells", "SpellProcEvents + per-spell procFlags","Spell proc rules catalog (event triggers + ICD + self-loop guard)"}, {"WAUH", ".wauh", "economy", "AuctionHouse.dbc + AuctionMgr", "Auction house config catalog (deposit/cut rates + duration tiers)"}, {"WBRD", ".wbrd", "pvp", "BattlemasterList.dbc + BattlegroundMgr","Battleground reward stages catalog (per-bracket honor + marks)"}, + {"WSWP", ".wswp", "audio", "(absent in vanilla — patch-level edits)","Sound swap rules catalog (priority + condition-gated substitution)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_sound_swap_catalog.cpp b/tools/editor/cli_sound_swap_catalog.cpp new file mode 100644 index 00000000..2f57e032 --- /dev/null +++ b/tools/editor/cli_sound_swap_catalog.cpp @@ -0,0 +1,347 @@ +#include "cli_sound_swap_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_sound_swap.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWswpExt(std::string base) { + stripExt(base, ".wswp"); + return base; +} + +const char* conditionKindName(uint8_t k) { + using S = wowee::pipeline::WoweeSoundSwap; + switch (k) { + case S::Always: return "always"; + case S::ZoneOnly: return "zoneonly"; + case S::ClassOnly: return "classonly"; + case S::RaceOnly: return "raceonly"; + case S::GenderOnly: return "genderonly"; + default: return "?"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeSoundSwap& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeSoundSwapLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wswp\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeSoundSwap& c, + const std::string& base) { + std::printf("Wrote %s.wswp\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" rules : %zu\n", c.entries.size()); +} + +int handleGenBosses(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "BossSoundOverrides"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWswpExt(base); + auto c = wowee::pipeline::WoweeSoundSwapLoader:: + makeBossOverrides(name); + if (!saveOrError(c, base, "gen-swp-bosses")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenRace(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "RaceVoiceOverrides"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWswpExt(base); + auto c = wowee::pipeline::WoweeSoundSwapLoader:: + makeRaceVoices(name); + if (!saveOrError(c, base, "gen-swp-race")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenUI(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "GlobalUISoundOverrides"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWswpExt(base); + auto c = wowee::pipeline::WoweeSoundSwapLoader:: + makeGlobalUI(name); + if (!saveOrError(c, base, "gen-swp-ui")) 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 = stripWswpExt(base); + if (!wowee::pipeline::WoweeSoundSwapLoader::exists(base)) { + std::fprintf(stderr, "WSWP not found: %s.wswp\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSoundSwapLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wswp"] = base + ".wswp"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"ruleId", e.ruleId}, + {"name", e.name}, + {"originalSoundId", e.originalSoundId}, + {"replacementSoundId", e.replacementSoundId}, + {"conditionKind", e.conditionKind}, + {"conditionKindName", + conditionKindName(e.conditionKind)}, + {"priorityIndex", e.priorityIndex}, + {"gainAdjustDb_x10", e.gainAdjustDb_x10}, + {"conditionValue", e.conditionValue}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WSWP: %s.wswp\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" rules : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id origSound replSound condition value prio gain name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %9u %9u %-12s %5u %3u %+4d %s\n", + e.ruleId, e.originalSoundId, + e.replacementSoundId, + conditionKindName(e.conditionKind), + e.conditionValue, + e.priorityIndex, + e.gainAdjustDb_x10, + e.name.c_str()); + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWswpExt(base); + if (!wowee::pipeline::WoweeSoundSwapLoader::exists(base)) { + std::fprintf(stderr, + "validate-wswp: WSWP not found: %s.wswp\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeSoundSwapLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set idsSeen; + using Triple = std::tuple; + std::set tripleSeen; + using PrioPair = std::pair; + std::set prioSeen; + 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.ruleId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.ruleId == 0) + errors.push_back(ctx + ": ruleId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.originalSoundId == 0) + errors.push_back(ctx + + ": originalSoundId is 0 — no source " + "sound to swap"); + if (e.replacementSoundId == 0) + errors.push_back(ctx + + ": replacementSoundId is 0 — no " + "replacement to play"); + if (e.conditionKind > 4) { + errors.push_back(ctx + ": conditionKind " + + std::to_string(e.conditionKind) + + " out of range (0..4)"); + } + // Self-replacement is always a bug — replacing + // a sound with itself is a no-op that wastes + // a dispatch slot. + if (e.originalSoundId != 0 && + e.originalSoundId == e.replacementSoundId) { + errors.push_back(ctx + + ": originalSoundId == replacementSoundId=" + + std::to_string(e.originalSoundId) + + " — no-op self-replacement"); + } + // priorityIndex == 0 means the rule is never + // picked when any other rule for the same + // sound matches. Could be intentional + // (disable rule) but warn. + if (e.priorityIndex == 0) { + warnings.push_back(ctx + + ": priorityIndex=0 — rule never wins " + "tie-break (effectively disabled); " + "remove or set priority > 0"); + } + // Gain range clamp: ±30 dB is the practical + // limit for client mixers; beyond risks + // clipping or inaudibility. + if (e.gainAdjustDb_x10 > 300 || + e.gainAdjustDb_x10 < -300) { + warnings.push_back(ctx + + ": gainAdjustDb_x10=" + + std::to_string(e.gainAdjustDb_x10) + + " (=" + + std::to_string(e.gainAdjustDb_x10 / 10) + + " dB) outside ±30 dB practical range " + "— mixer may clip or sound becomes " + "inaudible"); + } + // Condition-value sanity: Always condition + // should have value=0 (any other value is + // dead data). Other kinds need value != 0 + // (kind without target = matches everything, + // duplicating Always semantics). + using S = wowee::pipeline::WoweeSoundSwap; + if (e.conditionKind == S::Always && + e.conditionValue != 0) { + warnings.push_back(ctx + + ": Always condition with non-zero " + "conditionValue=" + + std::to_string(e.conditionValue) + + " — value is ignored at runtime " + "(dead data)"); + } + if (e.conditionKind != S::Always && + e.conditionValue == 0) { + errors.push_back(ctx + + ": non-Always conditionKind=" + + std::string(conditionKindName(e.conditionKind)) + + " requires non-zero conditionValue"); + } + // (originalSoundId, conditionKind, + // conditionValue) MUST be unique — two rules + // with the same trigger triple at different + // priorities are still ordered, but two with + // the SAME priority would tie. + Triple t{e.originalSoundId, e.conditionKind, + e.conditionValue}; + if (e.originalSoundId != 0 && + !tripleSeen.insert(t).second) { + errors.push_back(ctx + + ": duplicate trigger triple " + "(originalSoundId=" + + std::to_string(e.originalSoundId) + + ", conditionKind=" + + std::string(conditionKindName(e.conditionKind)) + + ", conditionValue=" + + std::to_string(e.conditionValue) + + ") — runtime would have two rules " + "for the same trigger"); + } + // Same priority within same originalSoundId + // is a tie-break ambiguity even if conditions + // differ. + PrioPair pp{e.originalSoundId, e.priorityIndex}; + if (e.originalSoundId != 0 && + e.priorityIndex != 0 && + !prioSeen.insert(pp).second) { + warnings.push_back(ctx + + ": originalSoundId=" + + std::to_string(e.originalSoundId) + + " has another rule at same " + "priorityIndex=" + + std::to_string(e.priorityIndex) + + " — tie-break order undefined when " + "both rules' conditions match"); + } + if (!idsSeen.insert(e.ruleId).second) { + errors.push_back(ctx + ": duplicate ruleId"); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wswp"] = base + ".wswp"; + 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-wswp: %s.wswp\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu rules, all ruleIds unique, " + "non-zero original+replacement sound, " + "no self-replacement, conditionKind " + "0..4, no duplicate trigger triples, " + "non-Always kinds have non-zero " + "conditionValue\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 handleSoundSwapCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-swp-bosses") == 0 && + i + 1 < argc) { + outRc = handleGenBosses(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-swp-race") == 0 && + i + 1 < argc) { + outRc = handleGenRace(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-swp-ui") == 0 && + i + 1 < argc) { + outRc = handleGenUI(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wswp") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wswp") == 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_sound_swap_catalog.hpp b/tools/editor/cli_sound_swap_catalog.hpp new file mode 100644 index 00000000..c24013f8 --- /dev/null +++ b/tools/editor/cli_sound_swap_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleSoundSwapCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee