feat(editor): add --info-attachments / --info-particles / --info-sequences

Three M2 sub-inspectors covering data fields the proprietary M2 format
carries that the open WOM doesn't yet (or carries differently). Useful
for understanding what gets lost when running --convert-m2 vs what
ships with the open conversion:

  wowee_editor --info-attachments Character/Human/HumanMale.m2
    M2 attachments: ... (15)
      idx   id  bone  pos (x, y, z)
        0    0     5  (  0.00,   0.50,   1.20)   # head
        1    1    27  (  0.10,  -0.20,   0.30)   # right hand
        ...

  wowee_editor --info-particles Spells/Fireball.m2
    particles: 8, ribbons: 2
    Particles:
      idx   id  bone  tex  blend     type  pos (x, y, z)
        ...
    Ribbons:
      idx   id  bone  tex  mat  pos (x, y, z)
        ...

  wowee_editor --info-sequences Creature/Wolf/Wolf.m2
    M2 sequences: ... (12)
      idx   id  var  duration  flags    speed  blend
        0    0    0      1733     32    0.00    150   # Stand
        4    4    0       950      0    1.50    150   # Walk
        5    5    0       625      0    3.20    150   # Run
        ...

Three commands share one entry point — they all need the same
M2Loader::load + skin-merge dance, then differ only in which sub-
array they iterate. Reduces duplicate boilerplate. JSON mode
emits per-entry records with index for programmatic consumption.

Why these matter: M2 carries scene-graph metadata (where to mount
weapons, where particles spawn, which animation is which) that
gameplay code reads at runtime. Surfacing it in a CLI lets
designers verify content without spinning up the renderer.
This commit is contained in:
Kelsi 2026-05-06 13:34:23 -07:00
parent 749aa18f0d
commit eca43cfefa

View file

@ -514,6 +514,12 @@ static void printUsage(const char* argv0) {
std::printf(" List every texture path referenced by a WOM (with on-disk presence)\n");
std::printf(" --info-doodads <wob-base> [--json]\n");
std::printf(" List every doodad placement in a WOB (model path, position, rotation, scale)\n");
std::printf(" --info-attachments <m2-path> [--json]\n");
std::printf(" List M2 attachment points (weapon mounts, etc.) with bone + offset\n");
std::printf(" --info-particles <m2-path> [--json]\n");
std::printf(" List M2 particle + ribbon emitters (texture, blend, bone)\n");
std::printf(" --info-sequences <m2-path> [--json]\n");
std::printf(" List M2 animation sequences (id, duration, flags)\n");
std::printf(" --info-wob <wob-base> [--json]\n");
std::printf(" Print WOB building metadata (groups, portals, doodads) and exit\n");
std::printf(" --info-woc <woc-path> [--json]\n");
@ -577,6 +583,7 @@ int main(int argc, char* argv[]) {
// with a helpful message instead of silently dropping into the GUI.
static const char* kArgRequired[] = {
"--data", "--info", "--info-batches", "--info-textures", "--info-doodads",
"--info-attachments", "--info-particles", "--info-sequences",
"--info-wob", "--info-woc", "--info-wot",
"--info-creatures", "--info-objects", "--info-quests",
"--info-extract", "--list-missing-sidecars",
@ -972,6 +979,188 @@ int main(int argc, char* argv[]) {
d.modelPath.c_str());
}
return 0;
} else if ((std::strcmp(argv[i], "--info-attachments") == 0 ||
std::strcmp(argv[i], "--info-particles") == 0 ||
std::strcmp(argv[i], "--info-sequences") == 0) &&
i + 1 < argc) {
// Three M2 inspectors share an entry point — they all need
// the same M2Loader::load + skin merge dance, then differ
// only in which sub-array they iterate.
enum Kind { kAttach, kParticle, kSequence };
Kind kind;
const char* cmdName;
if (std::strcmp(argv[i], "--info-attachments") == 0) {
kind = kAttach; cmdName = "info-attachments";
} else if (std::strcmp(argv[i], "--info-particles") == 0) {
kind = kParticle; cmdName = "info-particles";
} else {
kind = kSequence; cmdName = "info-sequences";
}
std::string path = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
std::ifstream in(path, std::ios::binary);
if (!in) {
std::fprintf(stderr, "%s: cannot open %s\n", cmdName, path.c_str());
return 1;
}
std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
// Auto-merge skin for vertex/index counts to match render.
std::vector<uint8_t> skinBytes;
{
std::string skinPath = path;
auto dot = skinPath.rfind('.');
if (dot != std::string::npos)
skinPath = skinPath.substr(0, dot) + "00.skin";
std::ifstream sf(skinPath, std::ios::binary);
if (sf) {
skinBytes.assign((std::istreambuf_iterator<char>(sf)),
std::istreambuf_iterator<char>());
}
}
auto m2 = wowee::pipeline::M2Loader::load(bytes);
if (!skinBytes.empty()) {
wowee::pipeline::M2Loader::loadSkin(skinBytes, m2);
}
if (kind == kAttach) {
if (jsonOut) {
nlohmann::json j;
j["m2"] = path;
j["count"] = m2.attachments.size();
nlohmann::json arr = nlohmann::json::array();
for (size_t k = 0; k < m2.attachments.size(); ++k) {
const auto& a = m2.attachments[k];
arr.push_back({
{"index", k}, {"id", a.id}, {"bone", a.bone},
{"position", {a.position.x, a.position.y, a.position.z}}
});
}
j["attachments"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("M2 attachments: %s (%zu)\n", path.c_str(),
m2.attachments.size());
if (m2.attachments.empty()) {
std::printf(" *no attachments*\n");
return 0;
}
std::printf(" idx id bone pos (x, y, z)\n");
for (size_t k = 0; k < m2.attachments.size(); ++k) {
const auto& a = m2.attachments[k];
std::printf(" %3zu %3u %4u (%6.2f, %6.2f, %6.2f)\n",
k, a.id, a.bone,
a.position.x, a.position.y, a.position.z);
}
return 0;
}
if (kind == kParticle) {
auto blendName = [](uint8_t b) {
switch (b) {
case 0: return "opaque";
case 1: return "alphakey";
case 2: return "alpha";
case 4: return "add";
}
return "?";
};
if (jsonOut) {
nlohmann::json j;
j["m2"] = path;
j["particleEmitters"] = m2.particleEmitters.size();
j["ribbonEmitters"] = m2.ribbonEmitters.size();
nlohmann::json parts = nlohmann::json::array();
for (size_t k = 0; k < m2.particleEmitters.size(); ++k) {
const auto& p = m2.particleEmitters[k];
parts.push_back({
{"index", k}, {"particleId", p.particleId},
{"bone", p.bone}, {"texture", p.texture},
{"blendingType", p.blendingType},
{"blendName", blendName(p.blendingType)},
{"emitterType", p.emitterType},
{"position", {p.position.x, p.position.y, p.position.z}}
});
}
j["particles"] = parts;
nlohmann::json ribbons = nlohmann::json::array();
for (size_t k = 0; k < m2.ribbonEmitters.size(); ++k) {
const auto& r = m2.ribbonEmitters[k];
ribbons.push_back({
{"index", k}, {"ribbonId", r.ribbonId},
{"bone", r.bone},
{"textureIndex", r.textureIndex},
{"materialIndex", r.materialIndex},
{"position", {r.position.x, r.position.y, r.position.z}}
});
}
j["ribbons"] = ribbons;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("M2 emitters: %s\n", path.c_str());
std::printf(" particles: %zu, ribbons: %zu\n",
m2.particleEmitters.size(), m2.ribbonEmitters.size());
if (!m2.particleEmitters.empty()) {
std::printf("\n Particles:\n");
std::printf(" idx id bone tex blend type pos (x, y, z)\n");
for (size_t k = 0; k < m2.particleEmitters.size(); ++k) {
const auto& p = m2.particleEmitters[k];
std::printf(" %3zu %3d %4u %3u %-8s %4u (%5.1f, %5.1f, %5.1f)\n",
k, p.particleId, p.bone, p.texture,
blendName(p.blendingType), p.emitterType,
p.position.x, p.position.y, p.position.z);
}
}
if (!m2.ribbonEmitters.empty()) {
std::printf("\n Ribbons:\n");
std::printf(" idx id bone tex mat pos (x, y, z)\n");
for (size_t k = 0; k < m2.ribbonEmitters.size(); ++k) {
const auto& r = m2.ribbonEmitters[k];
std::printf(" %3zu %3d %4u %3u %3u (%5.1f, %5.1f, %5.1f)\n",
k, r.ribbonId, r.bone, r.textureIndex, r.materialIndex,
r.position.x, r.position.y, r.position.z);
}
}
return 0;
}
// kind == kSequence
if (jsonOut) {
nlohmann::json j;
j["m2"] = path;
j["count"] = m2.sequences.size();
nlohmann::json arr = nlohmann::json::array();
for (size_t k = 0; k < m2.sequences.size(); ++k) {
const auto& s = m2.sequences[k];
arr.push_back({
{"index", k}, {"id", s.id},
{"variation", s.variationIndex},
{"durationMs", s.duration}, {"flags", s.flags},
{"movingSpeed", s.movingSpeed},
{"frequency", s.frequency},
{"blendTimeMs", s.blendTime}
});
}
j["sequences"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("M2 sequences: %s (%zu)\n", path.c_str(),
m2.sequences.size());
if (m2.sequences.empty()) {
std::printf(" *no sequences*\n");
return 0;
}
std::printf(" idx id var duration flags speed blend\n");
for (size_t k = 0; k < m2.sequences.size(); ++k) {
const auto& s = m2.sequences[k];
std::printf(" %3zu %3u %3u %8u %5u %5.2f %5u\n",
k, s.id, s.variationIndex,
s.duration, s.flags,
s.movingSpeed, s.blendTime);
}
return 0;
} else if (std::strcmp(argv[i], "--info-wob") == 0 && i + 1 < argc) {
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&