diff --git a/CMakeLists.txt b/CMakeLists.txt index 8106acba..f5557af9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -715,6 +715,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_global_channels.cpp src/pipeline/wowee_addon_manifest.cpp src/pipeline/wowee_spell_pack.cpp + src/pipeline/wowee_player_movement_anim.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1593,6 +1594,7 @@ add_executable(wowee_editor tools/editor/cli_global_channels_catalog.cpp tools/editor/cli_addon_manifest_catalog.cpp tools/editor/cli_spell_pack_catalog.cpp + tools/editor/cli_player_movement_anim_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1790,6 +1792,7 @@ add_executable(wowee_editor src/pipeline/wowee_global_channels.cpp src/pipeline/wowee_addon_manifest.cpp src/pipeline/wowee_spell_pack.cpp + src/pipeline/wowee_player_movement_anim.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_player_movement_anim.hpp b/include/pipeline/wowee_player_movement_anim.hpp new file mode 100644 index 00000000..aae3f82f --- /dev/null +++ b/include/pipeline/wowee_player_movement_anim.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Player Movement-to-Animation Map (.wphm) +// — novel replacement for the implicit +// movementState->animation mapping vanilla WoW baked +// into per-race M2 model files. Each WPHM entry binds +// one (raceId, genderId, movementState) tuple to a +// base animation sequence ID + an optional variant +// (e.g. wounded run, drunk walk) and a transition +// blend duration. +// +// Cross-references with previously-added formats: +// WCDB: raceId references the playable-race +// catalog (currently 1..10 in vanilla: +// Human=1, Orc=2 ... Troll=8, Goblin=9, +// BloodElf=10). +// None to M2 binary directly — animation index +// numbers come from the standard WoW M2 animation +// table (Stand=0, Walk=4, Run=5, Death=1...). +// +// Binary layout (little-endian): +// magic[4] = "WPHM" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// mapId (uint32) — surrogate primary key +// for cross-format +// --catalog-find lookups +// raceId (uint8) — 1..10 vanilla race +// genderId (uint8) — 0=male, 1=female +// movementState (uint8) — 0=Idle / 1=Walk / 2=Run +// / 3=Swim / 4=Fly / +// 5=Sit / 6=Mount / +// 7=Death +// pad0 (uint8) +// baseAnimId (uint32) — M2 anim sequence id +// variantAnimId (uint32) — alternate sequence +// (0 = no variant) +// transitionMs (uint16) — blend duration to enter +// pad1 (uint16) +struct WoweePlayerMovementAnim { + enum MovementState : uint8_t { + StateIdle = 0, + StateWalk = 1, + StateRun = 2, + StateSwim = 3, + StateFly = 4, + StateSit = 5, + StateMount = 6, + StateDeath = 7, + }; + + struct Entry { + uint32_t mapId = 0; + uint8_t raceId = 0; + uint8_t genderId = 0; + uint8_t movementState = StateIdle; + uint8_t pad0 = 0; + uint32_t baseAnimId = 0; + uint32_t variantAnimId = 0; + uint16_t transitionMs = 0; + uint16_t pad1 = 0; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t mapId) const; + + // Returns the binding for a specific (race, gender, + // state) — the canonical lookup the renderer uses + // each frame to decide which animation sequence to + // play. + const Entry* find(uint8_t raceId, uint8_t genderId, + uint8_t state) const; + + // Returns all bindings for a race+gender combo. + // Used by character-preview UIs to show the full + // state machine for one model. + std::vector findByRaceGender(uint8_t raceId, + uint8_t genderId) const; +}; + +class WoweePlayerMovementAnimLoader { +public: + static bool save(const WoweePlayerMovementAnim& cat, + const std::string& basePath); + static WoweePlayerMovementAnim load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-phm* variants. + // + // makeHumanMovement — full 8-state machine for + // Human male + female = + // 16 entries. Walk-while- + // drunk variant for State + // Walk only. + // makeOrcMovement — same shape for Orc male + // + female. Orc Run uses + // a more aggressive + // variant for war-stance + // flavor. + // makeUndeadMovement — Undead male + female, + // with canonical + // "shambling-when-wounded" + // variantAnimId on Run + // state (low-health + // renderer override). + static WoweePlayerMovementAnim makeHumanMovement(const std::string& catalogName); + static WoweePlayerMovementAnim makeOrcMovement(const std::string& catalogName); + static WoweePlayerMovementAnim makeUndeadMovement(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_player_movement_anim.cpp b/src/pipeline/wowee_player_movement_anim.cpp new file mode 100644 index 00000000..738ba30b --- /dev/null +++ b/src/pipeline/wowee_player_movement_anim.cpp @@ -0,0 +1,295 @@ +#include "pipeline/wowee_player_movement_anim.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'P', 'H', 'M'}; +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) != ".wphm") { + base += ".wphm"; + } + return base; +} + +} // namespace + +const WoweePlayerMovementAnim::Entry* +WoweePlayerMovementAnim::findById(uint32_t mapId) const { + for (const auto& e : entries) + if (e.mapId == mapId) return &e; + return nullptr; +} + +const WoweePlayerMovementAnim::Entry* +WoweePlayerMovementAnim::find(uint8_t raceId, + uint8_t genderId, + uint8_t state) const { + for (const auto& e : entries) { + if (e.raceId == raceId && + e.genderId == genderId && + e.movementState == state) return &e; + } + return nullptr; +} + +std::vector +WoweePlayerMovementAnim::findByRaceGender(uint8_t raceId, + uint8_t genderId) const { + std::vector out; + for (const auto& e : entries) { + if (e.raceId == raceId && e.genderId == genderId) + out.push_back(&e); + } + return out; +} + +bool WoweePlayerMovementAnimLoader::save( + const WoweePlayerMovementAnim& 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.mapId); + writePOD(os, e.raceId); + writePOD(os, e.genderId); + writePOD(os, e.movementState); + writePOD(os, e.pad0); + writePOD(os, e.baseAnimId); + writePOD(os, e.variantAnimId); + writePOD(os, e.transitionMs); + writePOD(os, e.pad1); + } + return os.good(); +} + +WoweePlayerMovementAnim WoweePlayerMovementAnimLoader::load( + const std::string& basePath) { + WoweePlayerMovementAnim 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.mapId) || + !readPOD(is, e.raceId) || + !readPOD(is, e.genderId) || + !readPOD(is, e.movementState) || + !readPOD(is, e.pad0) || + !readPOD(is, e.baseAnimId) || + !readPOD(is, e.variantAnimId) || + !readPOD(is, e.transitionMs) || + !readPOD(is, e.pad1)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweePlayerMovementAnimLoader::exists( + const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +namespace { + +// Helper to build an 8-state machine for one +// (raceId, genderId) pair. Canonical M2 anim ids: +// Stand=0, Death=1, Walk=4, Run=5, Swim=12, +// Fly=68 (vanilla had no flying except taxi), +// Sit=20, Mount=91. +struct StateRow { + uint8_t state; + uint32_t baseAnim; + uint32_t variantAnim; + uint16_t transitionMs; +}; + +WoweePlayerMovementAnim::Entry makeEntry(uint32_t mapId, + uint8_t race, + uint8_t gender, + const StateRow& r) { + WoweePlayerMovementAnim::Entry e; + e.mapId = mapId; + e.raceId = race; + e.genderId = gender; + e.movementState = r.state; + e.baseAnimId = r.baseAnim; + e.variantAnimId = r.variantAnim; + e.transitionMs = r.transitionMs; + return e; +} + +void appendRaceGender(WoweePlayerMovementAnim& c, + uint32_t baseId, uint8_t race, + uint8_t gender, + const std::vector& rows) { + using P = WoweePlayerMovementAnim; + for (size_t i = 0; i < rows.size(); ++i) { + c.entries.push_back(makeEntry( + baseId + static_cast(i), + race, gender, rows[i])); + } + (void)P::StateIdle; +} + +} // namespace + +WoweePlayerMovementAnim +WoweePlayerMovementAnimLoader::makeHumanMovement( + const std::string& catalogName) { + using P = WoweePlayerMovementAnim; + WoweePlayerMovementAnim c; + c.name = catalogName; + // Human Male: variantAnim on Walk = drunk-walk + // sequence (anim id 39 in the canonical M2 table). + // Other states have no variant. + appendRaceGender(c, 1000, 1 /* Human */, 0 /* M */, { + {P::StateIdle, 0, 0, 200}, + {P::StateWalk, 4, 39, 250}, + {P::StateRun, 5, 0, 200}, + {P::StateSwim, 12, 0, 350}, + {P::StateFly, 68, 0, 400}, + {P::StateSit, 20, 0, 300}, + {P::StateMount,91, 0, 400}, + {P::StateDeath, 1, 0, 100}, + }); + // Human Female: identical state shape but anim + // base ids differ (M2 sex models have separate + // anim tables) — using same numeric ids here as + // placeholder; in production these would be the + // female-model-specific anim indices. + appendRaceGender(c, 1100, 1, 1, { + {P::StateIdle, 0, 0, 200}, + {P::StateWalk, 4, 39, 250}, + {P::StateRun, 5, 0, 200}, + {P::StateSwim, 12, 0, 350}, + {P::StateFly, 68, 0, 400}, + {P::StateSit, 20, 0, 300}, + {P::StateMount,91, 0, 400}, + {P::StateDeath, 1, 0, 100}, + }); + return c; +} + +WoweePlayerMovementAnim +WoweePlayerMovementAnimLoader::makeOrcMovement( + const std::string& catalogName) { + using P = WoweePlayerMovementAnim; + WoweePlayerMovementAnim c; + c.name = catalogName; + // Orc Run uses a more aggressive variant (anim 17 + // = AttackRun) for war-stance flavor. + appendRaceGender(c, 2000, 2 /* Orc */, 0, { + {P::StateIdle, 0, 0, 200}, + {P::StateWalk, 4, 0, 250}, + {P::StateRun, 5, 17, 200}, + {P::StateSwim, 12, 0, 350}, + {P::StateFly, 68, 0, 400}, + {P::StateSit, 20, 0, 300}, + {P::StateMount,91, 0, 400}, + {P::StateDeath, 1, 0, 100}, + }); + appendRaceGender(c, 2100, 2, 1, { + {P::StateIdle, 0, 0, 200}, + {P::StateWalk, 4, 0, 250}, + {P::StateRun, 5, 17, 200}, + {P::StateSwim, 12, 0, 350}, + {P::StateFly, 68, 0, 400}, + {P::StateSit, 20, 0, 300}, + {P::StateMount,91, 0, 400}, + {P::StateDeath, 1, 0, 100}, + }); + return c; +} + +WoweePlayerMovementAnim +WoweePlayerMovementAnimLoader::makeUndeadMovement( + const std::string& catalogName) { + using P = WoweePlayerMovementAnim; + WoweePlayerMovementAnim c; + c.name = catalogName; + // Undead Run uses a "shambling" variant anim (38) + // as the wounded-low-health renderer override. + // Walk uses a stiffer cadence variant (40). + appendRaceGender(c, 5000, 5 /* Undead */, 0, { + {P::StateIdle, 0, 0, 200}, + {P::StateWalk, 4, 40, 250}, + {P::StateRun, 5, 38, 200}, + {P::StateSwim, 12, 0, 400}, // slower blend + // — undead aren't + // graceful in + // water + {P::StateFly, 68, 0, 400}, + {P::StateSit, 20, 0, 300}, + {P::StateMount,91, 0, 400}, + {P::StateDeath, 1, 0, 100}, + }); + appendRaceGender(c, 5100, 5, 1, { + {P::StateIdle, 0, 0, 200}, + {P::StateWalk, 4, 40, 250}, + {P::StateRun, 5, 38, 200}, + {P::StateSwim, 12, 0, 400}, + {P::StateFly, 68, 0, 400}, + {P::StateSit, 20, 0, 300}, + {P::StateMount,91, 0, 400}, + {P::StateDeath, 1, 0, 100}, + }); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index dde80d2e..31c28a8b 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -389,6 +389,8 @@ const char* const kArgRequired[] = { "--gen-spk-warrior", "--gen-spk-mage", "--gen-spk-rogue", "--info-wspk", "--validate-wspk", "--export-wspk-json", "--import-wspk-json", + "--gen-phm-human", "--gen-phm-orc", "--gen-phm-undead", + "--info-wphm", "--validate-wphm", "--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 b1b92bf6..2519a862 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -171,6 +171,7 @@ #include "cli_global_channels_catalog.hpp" #include "cli_addon_manifest_catalog.hpp" #include "cli_spell_pack_catalog.hpp" +#include "cli_player_movement_anim_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -387,6 +388,7 @@ constexpr DispatchFn kDispatchTable[] = { handleGlobalChannelsCatalog, handleAddonManifestCatalog, handleSpellPackCatalog, + handlePlayerMovementAnimCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 146ff7ba..19831451 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -129,6 +129,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','G','C','H'}, ".wgch", "chat", "--info-wgch", "Global chat channel catalog"}, {{'W','M','O','D'}, ".wmod", "addons", "--info-wmod", "Addon manifest catalog"}, {{'W','S','P','K'}, ".wspk", "spells", "--info-wspk", "Spell pack catalog"}, + {{'W','P','H','M'}, ".wphm", "anim", "--info-wphm", "Player movement-to-animation map"}, {{'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 2077c6e2..ac4dab20 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2531,6 +2531,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wspk to a human-editable JSON sidecar (defaults to .wspk.json; emits spellIds as JSON int array preserving display order)\n"); std::printf(" --import-wspk-json [out-base]\n"); std::printf(" Import a .wspk.json sidecar back into binary .wspk (spellIds array preserves order — round-trips per-tab spell lists byte-identical)\n"); + std::printf(" --gen-phm-human [name]\n"); + std::printf(" Emit .wphm Human Male+Female 8-state machine (16 entries) with drunk-walk variant on Walk state\n"); + std::printf(" --gen-phm-orc [name]\n"); + std::printf(" Emit .wphm Orc Male+Female 8-state machine (16 entries) with AttackRun variant on Run for war-stance flavor\n"); + std::printf(" --gen-phm-undead [name]\n"); + std::printf(" Emit .wphm Undead Male+Female with shambling variant on Run (low-health renderer override) and slower swim transition (undead are awkward in water)\n"); + std::printf(" --info-wphm [--json]\n"); + std::printf(" Print WPHM bindings (id / race / gender / movementState / baseAnimId / variantAnimId / transitionMs)\n"); + std::printf(" --validate-wphm [--json]\n"); + std::printf(" Static checks: id required, raceId 1..10, genderId 0..1, movementState 0..7, no duplicate mapIds, no duplicate (race,gender,state) triples (renderer dispatch ambiguity), baseAnimId=0 forbidden on non-Idle states (would freeze model); warns on variantAnimId==baseAnimId no-op and transitionMs > 2000 animation hang\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 8efa28e8..bce4f799 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -151,6 +151,7 @@ constexpr FormatRow kFormats[] = { {"WGCH", ".wgch", "chat", "ChatChannels.dbc + zone-default joins","Global chat channel catalog (access policy + zone auto-join)"}, {"WMOD", ".wmod", "addons", "per-addon TOC text + load-order rules","Addon manifest catalog (deps + cycle detection)"}, {"WSPK", ".wspk", "spells", "SkillLineAbility + per-spec tab order","Spell pack catalog (per-class spellbook tab layout)"}, + {"WPHM", ".wphm", "anim", "implicit M2 movementState->anim map","Player movement-to-animation map (per race/gender/state)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_player_movement_anim_catalog.cpp b/tools/editor/cli_player_movement_anim_catalog.cpp new file mode 100644 index 00000000..fc6f7ea4 --- /dev/null +++ b/tools/editor/cli_player_movement_anim_catalog.cpp @@ -0,0 +1,319 @@ +#include "cli_player_movement_anim_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_player_movement_anim.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWphmExt(std::string base) { + stripExt(base, ".wphm"); + return base; +} + +const char* movementStateName(uint8_t s) { + using P = wowee::pipeline::WoweePlayerMovementAnim; + switch (s) { + case P::StateIdle: return "idle"; + case P::StateWalk: return "walk"; + case P::StateRun: return "run"; + case P::StateSwim: return "swim"; + case P::StateFly: return "fly"; + case P::StateSit: return "sit"; + case P::StateMount: return "mount"; + case P::StateDeath: return "death"; + default: return "?"; + } +} + +const char* raceIdName(uint8_t r) { + // Vanilla 1.12 ChrRaces ids — display only. + switch (r) { + case 1: return "Human"; + case 2: return "Orc"; + case 3: return "Dwarf"; + case 4: return "NightElf"; + case 5: return "Undead"; + case 6: return "Tauren"; + case 7: return "Gnome"; + case 8: return "Troll"; + default: return "?"; + } +} + +const char* genderName(uint8_t g) { + return g == 0 ? "M" : (g == 1 ? "F" : "?"); +} + +bool saveOrError(const wowee::pipeline::WoweePlayerMovementAnim& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweePlayerMovementAnimLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wphm\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweePlayerMovementAnim& c, + const std::string& base) { + std::printf("Wrote %s.wphm\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" bindings: %zu\n", c.entries.size()); +} + +int handleGenHuman(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "HumanMovementAnim"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWphmExt(base); + auto c = wowee::pipeline::WoweePlayerMovementAnimLoader:: + makeHumanMovement(name); + if (!saveOrError(c, base, "gen-phm-human")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenOrc(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "OrcMovementAnim"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWphmExt(base); + auto c = wowee::pipeline::WoweePlayerMovementAnimLoader:: + makeOrcMovement(name); + if (!saveOrError(c, base, "gen-phm-orc")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenUndead(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "UndeadMovementAnim"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWphmExt(base); + auto c = wowee::pipeline::WoweePlayerMovementAnimLoader:: + makeUndeadMovement(name); + if (!saveOrError(c, base, "gen-phm-undead")) 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 = stripWphmExt(base); + if (!wowee::pipeline::WoweePlayerMovementAnimLoader::exists(base)) { + std::fprintf(stderr, "WPHM not found: %s.wphm\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweePlayerMovementAnimLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wphm"] = base + ".wphm"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"mapId", e.mapId}, + {"raceId", e.raceId}, + {"raceName", raceIdName(e.raceId)}, + {"genderId", e.genderId}, + {"genderName", genderName(e.genderId)}, + {"movementState", e.movementState}, + {"movementStateName", + movementStateName(e.movementState)}, + {"baseAnimId", e.baseAnimId}, + {"variantAnimId", e.variantAnimId}, + {"transitionMs", e.transitionMs}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WPHM: %s.wphm\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" bindings: %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id race g state baseAnim variant blendMs\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %-8s %s %-7s %8u %7u %7u\n", + e.mapId, + raceIdName(e.raceId), + genderName(e.genderId), + movementStateName(e.movementState), + e.baseAnimId, e.variantAnimId, + e.transitionMs); + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWphmExt(base); + if (!wowee::pipeline::WoweePlayerMovementAnimLoader::exists(base)) { + std::fprintf(stderr, + "validate-wphm: WPHM not found: %s.wphm\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweePlayerMovementAnimLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set idsSeen; + using KeyTriple = std::tuple; + std::set tripleSeen; + 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.mapId) + + " " + raceIdName(e.raceId) + + " " + genderName(e.genderId) + + " " + movementStateName(e.movementState) + + ")"; + if (e.mapId == 0) + errors.push_back(ctx + ": mapId is 0"); + if (e.raceId == 0 || e.raceId > 10) { + errors.push_back(ctx + ": raceId " + + std::to_string(e.raceId) + + " out of vanilla range (1..10)"); + } + if (e.genderId > 1) { + errors.push_back(ctx + ": genderId " + + std::to_string(e.genderId) + + " out of range (must be 0=male or 1=female)"); + } + if (e.movementState > 7) { + errors.push_back(ctx + ": movementState " + + std::to_string(e.movementState) + + " out of range (0..7)"); + } + // baseAnimId 0 IS valid (Stand is anim id 0 + // in the M2 table) BUT only for the Idle + // state. For any other state, base 0 means + // "no animation bound" which would freeze the + // model in the previous frame. + if (e.baseAnimId == 0 && + e.movementState != wowee::pipeline:: + WoweePlayerMovementAnim::StateIdle) { + errors.push_back(ctx + + ": baseAnimId 0 on non-Idle state — model " + "would freeze when entering this state"); + } + // (race, gender, state) MUST be unique — the + // renderer dispatches by this triple. Two + // bindings would non-deterministically tie. + KeyTriple key{e.raceId, e.genderId, e.movementState}; + if (!tripleSeen.insert(key).second) { + errors.push_back(ctx + + ": duplicate (raceId, genderId, " + "movementState) triple — renderer " + "dispatch ambiguous"); + } + if (!idsSeen.insert(e.mapId).second) { + errors.push_back(ctx + ": duplicate mapId"); + } + // Self-variant: variantAnimId pointing to the + // same id as baseAnimId is meaningless overhead + // — it's still a valid setup but the variant + // would be a no-op. + if (e.variantAnimId != 0 && + e.variantAnimId == e.baseAnimId) { + warnings.push_back(ctx + + ": variantAnimId equals baseAnimId — the " + "variant would visually equal the base " + "(no-op overhead)"); + } + // Excessive transition: more than 2s of blend + // would feel like an animation hang to the + // player. + if (e.transitionMs > 2000) { + warnings.push_back(ctx + + ": transitionMs=" + + std::to_string(e.transitionMs) + + " exceeds 2000ms — would feel like an " + "animation hang"); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wphm"] = base + ".wphm"; + 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-wphm: %s.wphm\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu bindings, all mapIds unique, " + "(race,gender,state) triple unique, " + "raceId 1..10, genderId 0..1, state " + "0..7, no non-Idle baseAnim==0\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 handlePlayerMovementAnimCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-phm-human") == 0 && + i + 1 < argc) { + outRc = handleGenHuman(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-phm-orc") == 0 && + i + 1 < argc) { + outRc = handleGenOrc(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-phm-undead") == 0 && + i + 1 < argc) { + outRc = handleGenUndead(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wphm") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wphm") == 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_player_movement_anim_catalog.hpp b/tools/editor/cli_player_movement_anim_catalog.hpp new file mode 100644 index 00000000..38b0312c --- /dev/null +++ b/tools/editor/cli_player_movement_anim_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handlePlayerMovementAnimCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee