feat(pipeline): WPHM player movement-to-animation map (127th open format)

Novel replacement for the implicit movementState->animation
binding that vanilla WoW baked into per-race M2 model files.
Each WPHM entry binds one (raceId, genderId, movementState)
tuple to a base M2 animation sequence id, an optional variant
sequence (drunk-walk, wounded-run), and a blend transition
duration in milliseconds.

8-state machine: Idle / Walk / Run / Swim / Fly / Sit / Mount /
Death. Three presets each emit the full 16 bindings (M+F):
  --gen-phm-human   16 bindings with drunk-walk variant on Walk
  --gen-phm-orc     16 bindings with AttackRun variant on Run
                    for war-stance flavor
  --gen-phm-undead  16 bindings with canonical shambling variant
                    (anim 38) on Run for low-health renderer
                    override + slower swim transition (undead are
                    awkward in water)

Validator catches: 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 (model would freeze
when entering that state). Warns on variantAnimId==baseAnimId
(no-op overhead) and transitionMs > 2000 (would feel like
animation hang).

Format count 126 -> 127. CLI flag count 1337 -> 1344.
This commit is contained in:
Kelsi 2026-05-10 03:44:31 -07:00
parent e652f8595d
commit 949a6e0182
10 changed files with 770 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1,125 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
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<Entry> 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<const Entry*> 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

View file

@ -0,0 +1,295 @@
#include "pipeline/wowee_player_movement_anim.hpp"
#include <cstdio>
#include <cstring>
#include <fstream>
namespace wowee {
namespace pipeline {
namespace {
constexpr char kMagic[4] = {'W', 'P', 'H', 'M'};
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) != ".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<const WoweePlayerMovementAnim::Entry*>
WoweePlayerMovementAnim::findByRaceGender(uint8_t raceId,
uint8_t genderId) const {
std::vector<const Entry*> 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<uint32_t>(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<StateRow>& rows) {
using P = WoweePlayerMovementAnim;
for (size_t i = 0; i < rows.size(); ++i) {
c.entries.push_back(makeEntry(
baseId + static_cast<uint32_t>(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

View file

@ -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",

View file

@ -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,

View file

@ -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"},

View file

@ -2531,6 +2531,16 @@ void printUsage(const char* argv0) {
std::printf(" Export binary .wspk to a human-editable JSON sidecar (defaults to <base>.wspk.json; emits spellIds as JSON int array preserving display order)\n");
std::printf(" --import-wspk-json <json-path> [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 <wphm-base> [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 <wphm-base> [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 <wphm-base> [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 <wphm-base> [--json]\n");
std::printf(" Print WPHM bindings (id / race / gender / movementState / baseAnimId / variantAnimId / transitionMs)\n");
std::printf(" --validate-wphm <wphm-base> [--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 <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");

View file

@ -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

View file

@ -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 <nlohmann/json.hpp>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <set>
#include <string>
#include <tuple>
#include <utility>
#include <vector>
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<std::string> errors;
std::vector<std::string> warnings;
if (c.entries.empty()) {
warnings.push_back("catalog has zero entries");
}
std::set<uint32_t> idsSeen;
using KeyTriple = std::tuple<uint8_t, uint8_t, uint8_t>;
std::set<KeyTriple> 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

View file

@ -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