refactor(editor): extract WOM info handlers into cli_wom_info.cpp

Moves the WOM model inspection commands out of main.cpp:

  --info               (bare WOM summary)
  --info-batches       (per-batch material info)
  --info-textures      (texture path list)
  --info-doodads       (WOB doodad set / instance list)
  --info-attachments   ⎫ combined handler with same M2 load +
  --info-particles     ⎬ skin merge but different sub-array
  --info-sequences     ⎭ iteration
  --info-bones         (bone hierarchy)
  --export-bones-dot   (Graphviz DOT output)

main.cpp drops 20,005 → 19,446 lines (-559). Behavior verified
by running --info, --info-batches, --info-textures on a fresh
WOM. Build error during extraction (combined-or handler header
spanned 4 lines, the transform script only stripped the first;
also missing #include for WoweeBuildingLoader) caught by build
and fixed before commit.
This commit is contained in:
Kelsi 2026-05-09 01:18:09 -07:00
parent d0c8ea582e
commit efe0a91335
4 changed files with 663 additions and 563 deletions

View file

@ -1309,6 +1309,7 @@ add_executable(wowee_editor
tools/editor/cli_gen_mesh.cpp
tools/editor/cli_mesh_io.cpp
tools/editor/cli_mesh_edit.cpp
tools/editor/cli_wom_info.cpp
tools/editor/editor_app.cpp
tools/editor/editor_camera.cpp
tools/editor/editor_viewport.cpp

View file

@ -0,0 +1,635 @@
#include "cli_wom_info.hpp"
#include "pipeline/wowee_model.hpp"
#include "pipeline/wowee_building.hpp"
#include "pipeline/m2_loader.hpp"
#include "pipeline/asset_manager.hpp"
#include <nlohmann/json.hpp>
#include <glm/glm.hpp>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <map>
#include <set>
#include <string>
#include <vector>
namespace wowee {
namespace editor {
namespace cli {
namespace {
int handleInfo(int& i, int argc, char** argv) {
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
// Allow either "/path/to/file.wom" or "/path/to/file"; load() expects no extension.
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeModelLoader::exists(base)) {
std::fprintf(stderr, "WOM not found: %s.wom\n", base.c_str());
return 1;
}
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
if (jsonOut) {
nlohmann::json j;
j["wom"] = base + ".wom";
j["version"] = wom.version;
j["name"] = wom.name;
j["vertices"] = wom.vertices.size();
j["indices"] = wom.indices.size();
j["triangles"] = wom.indices.size() / 3;
j["textures"] = wom.texturePaths.size();
j["bones"] = wom.bones.size();
j["animations"] = wom.animations.size();
j["batches"] = wom.batches.size();
j["boundRadius"] = wom.boundRadius;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("WOM: %s.wom\n", base.c_str());
std::printf(" version : %u%s\n", wom.version,
wom.version == 3 ? " (multi-batch)" :
wom.version == 2 ? " (animated)" : " (static)");
std::printf(" name : %s\n", wom.name.c_str());
std::printf(" vertices : %zu\n", wom.vertices.size());
std::printf(" indices : %zu (%zu tris)\n", wom.indices.size(), wom.indices.size() / 3);
std::printf(" textures : %zu\n", wom.texturePaths.size());
std::printf(" bones : %zu\n", wom.bones.size());
std::printf(" animations : %zu\n", wom.animations.size());
std::printf(" batches : %zu\n", wom.batches.size());
std::printf(" boundRadius: %.2f\n", wom.boundRadius);
return 0;
}
int handleInfoBatches(int& i, int argc, char** argv) {
// Per-batch breakdown of a WOM3 (multi-material) model.
// --info shows the total batch count; this drills into each
// one's index range, texture, blend mode, and flags. Useful
// for debugging 'why is this submesh transparent?' or
// 'which batch has the bad UV?'.
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeModelLoader::exists(base)) {
std::fprintf(stderr, "WOM not found: %s.wom\n", base.c_str());
return 1;
}
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
// Blend modes per WoweeModel::Batch comment:
// 0=opaque, 1=alpha-test, 2=alpha, 3=add
auto blendName = [](uint16_t b) {
switch (b) {
case 0: return "opaque";
case 1: return "alpha-test";
case 2: return "alpha";
case 3: return "add";
}
return "?";
};
// Flags bits:
// bit 0 (0x01) = unlit
// bit 1 (0x02) = two-sided
// bit 2 (0x04) = no z-write
auto flagsStr = [](uint16_t f) {
std::string s;
if (f & 0x01) s += "unlit ";
if (f & 0x02) s += "two-sided ";
if (f & 0x04) s += "no-zwrite ";
if (s.empty()) s = "-";
else s.pop_back(); // drop trailing space
return s;
};
if (jsonOut) {
nlohmann::json j;
j["wom"] = base + ".wom";
j["version"] = wom.version;
j["totalBatches"] = wom.batches.size();
nlohmann::json arr = nlohmann::json::array();
for (size_t k = 0; k < wom.batches.size(); ++k) {
const auto& b = wom.batches[k];
std::string tex = (b.textureIndex < wom.texturePaths.size())
? wom.texturePaths[b.textureIndex]
: std::string("<oob>");
arr.push_back({
{"index", k},
{"indexStart", b.indexStart},
{"indexCount", b.indexCount},
{"triangles", b.indexCount / 3},
{"textureIndex", b.textureIndex},
{"texturePath", tex},
{"blendMode", b.blendMode},
{"blendName", blendName(b.blendMode)},
{"flags", b.flags},
{"flagsStr", flagsStr(b.flags)},
});
}
j["batches"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("WOM batches: %s.wom (v%u, %zu batches)\n",
base.c_str(), wom.version, wom.batches.size());
if (wom.batches.empty()) {
std::printf(" *no batches (WOM1/WOM2 single-material model)*\n");
return 0;
}
std::printf(" idx iStart iCount tris blend flags texture\n");
for (size_t k = 0; k < wom.batches.size(); ++k) {
const auto& b = wom.batches[k];
std::string tex = (b.textureIndex < wom.texturePaths.size())
? wom.texturePaths[b.textureIndex]
: std::string("<oob>");
std::printf(" %3zu %6u %6u %5u %-10s %-13s %s\n",
k, b.indexStart, b.indexCount, b.indexCount / 3,
blendName(b.blendMode),
flagsStr(b.flags).c_str(),
tex.c_str());
}
return 0;
}
int handleInfoTextures(int& i, int argc, char** argv) {
// List every texture path a WOM references, with on-disk
// presence for both BLP (proprietary) and PNG (sidecar)
// forms. Useful for tracking which textures are missing
// before --pack-wcp would fail at runtime.
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeModelLoader::exists(base)) {
std::fprintf(stderr, "WOM not found: %s.wom\n", base.c_str());
return 1;
}
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
namespace fs = std::filesystem;
// Texture paths in WOMs are usually game-relative
// ('World/Generic/Tree.blp'); resolve them against the
// common Data/ root for the on-disk presence check. Skip
// the check when the path doesn't exist as either an
// absolute or relative file (avoids false 'missing'
// reports when the user runs from outside the data root).
auto checkBlp = [&](const std::string& p) {
if (fs::exists(p)) return true;
std::string lower = p;
for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c));
if (lower.size() < 4 || lower.substr(lower.size() - 4) != ".blp") {
lower += ".blp";
}
return fs::exists("Data/" + lower);
};
auto sidecarPng = [&](const std::string& p) {
std::string base = p;
if (base.size() >= 4 &&
(base.substr(base.size() - 4) == ".blp" ||
base.substr(base.size() - 4) == ".BLP")) {
base = base.substr(0, base.size() - 4);
}
std::string png = base + ".png";
if (fs::exists(png)) return true;
std::string lower = png;
for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c));
return fs::exists("Data/" + lower);
};
if (jsonOut) {
nlohmann::json j;
j["wom"] = base + ".wom";
j["textureCount"] = wom.texturePaths.size();
nlohmann::json arr = nlohmann::json::array();
for (size_t k = 0; k < wom.texturePaths.size(); ++k) {
const auto& p = wom.texturePaths[k];
arr.push_back({
{"index", k},
{"path", p},
{"blpPresent", checkBlp(p)},
{"pngPresent", sidecarPng(p)},
});
}
j["textures"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("WOM textures: %s.wom (%zu textures)\n",
base.c_str(), wom.texturePaths.size());
if (wom.texturePaths.empty()) {
std::printf(" *no texture references*\n");
return 0;
}
std::printf(" idx blp png path\n");
for (size_t k = 0; k < wom.texturePaths.size(); ++k) {
const auto& p = wom.texturePaths[k];
std::printf(" %3zu %s %s %s\n",
k,
checkBlp(p) ? "y" : "-",
sidecarPng(p) ? "y" : "-",
p.c_str());
}
return 0;
}
int handleInfoDoodads(int& i, int argc, char** argv) {
// List every doodad placement in a WOB (M2 instances inside
// a building). Companion to --info-textures: where one
// tracks GPU resources, this tracks scene composition.
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) {
std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str());
return 1;
}
auto bld = wowee::pipeline::WoweeBuildingLoader::load(base);
if (jsonOut) {
nlohmann::json j;
j["wob"] = base + ".wob";
j["count"] = bld.doodads.size();
nlohmann::json arr = nlohmann::json::array();
for (size_t k = 0; k < bld.doodads.size(); ++k) {
const auto& d = bld.doodads[k];
arr.push_back({
{"index", k},
{"modelPath", d.modelPath},
{"position", {d.position.x, d.position.y, d.position.z}},
{"rotation", {d.rotation.x, d.rotation.y, d.rotation.z}},
{"scale", d.scale},
});
}
j["doodads"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("WOB doodads: %s.wob (%zu placements)\n",
base.c_str(), bld.doodads.size());
if (bld.doodads.empty()) {
std::printf(" *no doodad placements*\n");
return 0;
}
std::printf(" idx scale pos (x, y, z) rot (x, y, z) model\n");
for (size_t k = 0; k < bld.doodads.size(); ++k) {
const auto& d = bld.doodads[k];
std::printf(" %3zu %5.2f (%6.1f, %6.1f, %6.1f) (%6.1f, %6.1f, %6.1f) %s\n",
k, d.scale,
d.position.x, d.position.y, d.position.z,
d.rotation.x, d.rotation.y, d.rotation.z,
d.modelPath.c_str());
}
return 0;
}
int handleInfoAttachParticleSequence(int& i, int argc, char** argv) {
// 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;
}
int handleInfoBones(int& i, int argc, char** argv) {
// Inspect M2 bone tree. Shows parent index, key-bone ID
// (-1 if not a named bone), pivot offset, and a depth
// indicator computed by walking up parents — useful for
// debugging skeleton structure when something looks wrong
// in the renderer ('why is this bone not following its parent?').
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, "info-bones: cannot open %s\n", path.c_str());
return 1;
}
std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
auto m2 = wowee::pipeline::M2Loader::load(bytes);
// Compute depth per bone — guard against cycles by capping
// walk length at boneCount (a real DAG can't exceed that).
std::vector<int> depths(m2.bones.size(), -1);
for (size_t k = 0; k < m2.bones.size(); ++k) {
int d = 0;
int idx = static_cast<int>(k);
while (idx >= 0 && d <= static_cast<int>(m2.bones.size())) {
int parent = m2.bones[idx].parentBone;
if (parent < 0) break;
idx = parent;
d++;
}
depths[k] = d;
}
if (jsonOut) {
nlohmann::json j;
j["m2"] = path;
j["count"] = m2.bones.size();
nlohmann::json arr = nlohmann::json::array();
for (size_t k = 0; k < m2.bones.size(); ++k) {
const auto& b = m2.bones[k];
arr.push_back({
{"index", k}, {"keyBoneId", b.keyBoneId},
{"parent", b.parentBone}, {"flags", b.flags},
{"depth", depths[k]},
{"pivot", {b.pivot.x, b.pivot.y, b.pivot.z}}
});
}
j["bones"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("M2 bones: %s (%zu)\n", path.c_str(), m2.bones.size());
if (m2.bones.empty()) {
std::printf(" *no bones (static model)*\n");
return 0;
}
std::printf(" idx parent depth keyBone flags pivot (x, y, z)\n");
for (size_t k = 0; k < m2.bones.size(); ++k) {
const auto& b = m2.bones[k];
// Indent the keyBone column by depth so the tree shape
// is visible at a glance.
std::printf(" %3zu %6d %5d %7d %5u (%6.2f, %6.2f, %6.2f)\n",
k, b.parentBone, depths[k], b.keyBoneId, b.flags,
b.pivot.x, b.pivot.y, b.pivot.z);
}
return 0;
}
int handleExportBonesDot(int& i, int argc, char** argv) {
// Render WOM bone hierarchy as Graphviz DOT. Mirrors
// --export-quest-graph for skeleton trees: trying to read
// a 50-bone tree from --info-bones output is painful;
// pipe this through `dot -Tpng` for the picture.
std::string base = argv[++i];
std::string outPath;
if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i];
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeModelLoader::exists(base)) {
std::fprintf(stderr,
"export-bones-dot: WOM not found: %s.wom\n", base.c_str());
return 1;
}
if (outPath.empty()) outPath = base + ".bones.dot";
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
std::ofstream out(outPath);
if (!out) {
std::fprintf(stderr,
"export-bones-dot: cannot write %s\n", outPath.c_str());
return 1;
}
out << "digraph BoneTree {\n";
out << " // Generated by wowee_editor --export-bones-dot\n";
out << " rankdir=TB;\n";
out << " node [shape=box, style=filled, fontname=\"sans-serif\", fontsize=10];\n";
// Color: green for keybones (named anchor points), gray for
// internal/blend bones. Root bones (parent=-1) get yellow border.
for (size_t k = 0; k < wom.bones.size(); ++k) {
const auto& b = wom.bones[k];
bool isKey = (b.keyBoneId >= 0);
std::string fill = isKey ? "lightgreen" : "lightgrey";
std::string label = "[" + std::to_string(k) + "]";
if (isKey) label += "\\nkey=" + std::to_string(b.keyBoneId);
out << " b" << k << " [label=\"" << label
<< "\", fillcolor=" << fill;
if (b.parentBone == -1) out << ", penwidth=2, color=goldenrod";
out << "];\n";
}
// Edges: child -> parent (parent is up).
int rootCount = 0;
for (size_t k = 0; k < wom.bones.size(); ++k) {
int16_t p = wom.bones[k].parentBone;
if (p < 0 || p >= static_cast<int16_t>(wom.bones.size())) {
rootCount++;
continue;
}
out << " b" << p << " -> b" << k << ";\n";
}
out << "}\n";
out.close();
std::printf("Wrote %s\n", outPath.c_str());
std::printf(" %zu bones, %d root(s)\n",
wom.bones.size(), rootCount);
std::printf(" next: dot -Tpng %s -o bones.png\n", outPath.c_str());
return 0;
}
} // namespace
bool handleWomInfo(int& i, int argc, char** argv, int& outRc) {
if (std::strcmp(argv[i], "--info") == 0 && i + 1 < argc) {
outRc = handleInfo(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--info-batches") == 0 && i + 1 < argc) {
outRc = handleInfoBatches(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--info-textures") == 0 && i + 1 < argc) {
outRc = handleInfoTextures(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--info-doodads") == 0 && i + 1 < argc) {
outRc = handleInfoDoodads(i, argc, argv); return true;
}
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) {
outRc = handleInfoAttachParticleSequence(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--info-bones") == 0 && i + 1 < argc) {
outRc = handleInfoBones(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--export-bones-dot") == 0 && i + 1 < argc) {
outRc = handleExportBonesDot(i, argc, argv); return true;
}
return false;
}
} // namespace cli
} // namespace editor
} // namespace wowee

View file

@ -0,0 +1,23 @@
#pragma once
namespace wowee {
namespace editor {
namespace cli {
// Dispatch the WOM model inspection handlers:
// --info (bare WOM summary)
// --info-batches (per-batch material info)
// --info-textures (texture path list)
// --info-doodads (doodad set / instance list)
// --info-attachments } combined handler under the hood —
// --info-particles } same M2 load + skin merge,
// --info-sequences } different sub-array iteration
// --info-bones (bone hierarchy)
// --export-bones-dot (Graphviz DOT output)
//
// Returns true if matched; outRc holds the exit code.
bool handleWomInfo(int& i, int argc, char** argv, int& outRc);
} // namespace cli
} // namespace editor
} // namespace wowee

View file

@ -10,6 +10,7 @@
#include "cli_gen_mesh.hpp"
#include "cli_mesh_io.hpp"
#include "cli_mesh_edit.hpp"
#include "cli_wom_info.hpp"
#include "content_pack.hpp"
#include "npc_spawner.hpp"
#include "object_placer.hpp"
@ -814,6 +815,9 @@ int main(int argc, char* argv[]) {
if (wowee::editor::cli::handleMeshEdit(i, argc, argv, outRc)) {
return outRc;
}
if (wowee::editor::cli::handleWomInfo(i, argc, argv, outRc)) {
return outRc;
}
}
if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) {
dataPath = argv[++i];
@ -821,569 +825,6 @@ int main(int argc, char* argv[]) {
adtMap = argv[++i];
adtX = std::atoi(argv[++i]);
adtY = std::atoi(argv[++i]);
} else if (std::strcmp(argv[i], "--info") == 0 && i + 1 < argc) {
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
// Allow either "/path/to/file.wom" or "/path/to/file"; load() expects no extension.
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeModelLoader::exists(base)) {
std::fprintf(stderr, "WOM not found: %s.wom\n", base.c_str());
return 1;
}
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
if (jsonOut) {
nlohmann::json j;
j["wom"] = base + ".wom";
j["version"] = wom.version;
j["name"] = wom.name;
j["vertices"] = wom.vertices.size();
j["indices"] = wom.indices.size();
j["triangles"] = wom.indices.size() / 3;
j["textures"] = wom.texturePaths.size();
j["bones"] = wom.bones.size();
j["animations"] = wom.animations.size();
j["batches"] = wom.batches.size();
j["boundRadius"] = wom.boundRadius;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("WOM: %s.wom\n", base.c_str());
std::printf(" version : %u%s\n", wom.version,
wom.version == 3 ? " (multi-batch)" :
wom.version == 2 ? " (animated)" : " (static)");
std::printf(" name : %s\n", wom.name.c_str());
std::printf(" vertices : %zu\n", wom.vertices.size());
std::printf(" indices : %zu (%zu tris)\n", wom.indices.size(), wom.indices.size() / 3);
std::printf(" textures : %zu\n", wom.texturePaths.size());
std::printf(" bones : %zu\n", wom.bones.size());
std::printf(" animations : %zu\n", wom.animations.size());
std::printf(" batches : %zu\n", wom.batches.size());
std::printf(" boundRadius: %.2f\n", wom.boundRadius);
return 0;
} else if (std::strcmp(argv[i], "--info-batches") == 0 && i + 1 < argc) {
// Per-batch breakdown of a WOM3 (multi-material) model.
// --info shows the total batch count; this drills into each
// one's index range, texture, blend mode, and flags. Useful
// for debugging 'why is this submesh transparent?' or
// 'which batch has the bad UV?'.
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeModelLoader::exists(base)) {
std::fprintf(stderr, "WOM not found: %s.wom\n", base.c_str());
return 1;
}
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
// Blend modes per WoweeModel::Batch comment:
// 0=opaque, 1=alpha-test, 2=alpha, 3=add
auto blendName = [](uint16_t b) {
switch (b) {
case 0: return "opaque";
case 1: return "alpha-test";
case 2: return "alpha";
case 3: return "add";
}
return "?";
};
// Flags bits:
// bit 0 (0x01) = unlit
// bit 1 (0x02) = two-sided
// bit 2 (0x04) = no z-write
auto flagsStr = [](uint16_t f) {
std::string s;
if (f & 0x01) s += "unlit ";
if (f & 0x02) s += "two-sided ";
if (f & 0x04) s += "no-zwrite ";
if (s.empty()) s = "-";
else s.pop_back(); // drop trailing space
return s;
};
if (jsonOut) {
nlohmann::json j;
j["wom"] = base + ".wom";
j["version"] = wom.version;
j["totalBatches"] = wom.batches.size();
nlohmann::json arr = nlohmann::json::array();
for (size_t k = 0; k < wom.batches.size(); ++k) {
const auto& b = wom.batches[k];
std::string tex = (b.textureIndex < wom.texturePaths.size())
? wom.texturePaths[b.textureIndex]
: std::string("<oob>");
arr.push_back({
{"index", k},
{"indexStart", b.indexStart},
{"indexCount", b.indexCount},
{"triangles", b.indexCount / 3},
{"textureIndex", b.textureIndex},
{"texturePath", tex},
{"blendMode", b.blendMode},
{"blendName", blendName(b.blendMode)},
{"flags", b.flags},
{"flagsStr", flagsStr(b.flags)},
});
}
j["batches"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("WOM batches: %s.wom (v%u, %zu batches)\n",
base.c_str(), wom.version, wom.batches.size());
if (wom.batches.empty()) {
std::printf(" *no batches (WOM1/WOM2 single-material model)*\n");
return 0;
}
std::printf(" idx iStart iCount tris blend flags texture\n");
for (size_t k = 0; k < wom.batches.size(); ++k) {
const auto& b = wom.batches[k];
std::string tex = (b.textureIndex < wom.texturePaths.size())
? wom.texturePaths[b.textureIndex]
: std::string("<oob>");
std::printf(" %3zu %6u %6u %5u %-10s %-13s %s\n",
k, b.indexStart, b.indexCount, b.indexCount / 3,
blendName(b.blendMode),
flagsStr(b.flags).c_str(),
tex.c_str());
}
return 0;
} else if (std::strcmp(argv[i], "--info-textures") == 0 && i + 1 < argc) {
// List every texture path a WOM references, with on-disk
// presence for both BLP (proprietary) and PNG (sidecar)
// forms. Useful for tracking which textures are missing
// before --pack-wcp would fail at runtime.
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeModelLoader::exists(base)) {
std::fprintf(stderr, "WOM not found: %s.wom\n", base.c_str());
return 1;
}
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
namespace fs = std::filesystem;
// Texture paths in WOMs are usually game-relative
// ('World/Generic/Tree.blp'); resolve them against the
// common Data/ root for the on-disk presence check. Skip
// the check when the path doesn't exist as either an
// absolute or relative file (avoids false 'missing'
// reports when the user runs from outside the data root).
auto checkBlp = [&](const std::string& p) {
if (fs::exists(p)) return true;
std::string lower = p;
for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c));
if (lower.size() < 4 || lower.substr(lower.size() - 4) != ".blp") {
lower += ".blp";
}
return fs::exists("Data/" + lower);
};
auto sidecarPng = [&](const std::string& p) {
std::string base = p;
if (base.size() >= 4 &&
(base.substr(base.size() - 4) == ".blp" ||
base.substr(base.size() - 4) == ".BLP")) {
base = base.substr(0, base.size() - 4);
}
std::string png = base + ".png";
if (fs::exists(png)) return true;
std::string lower = png;
for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c));
return fs::exists("Data/" + lower);
};
if (jsonOut) {
nlohmann::json j;
j["wom"] = base + ".wom";
j["textureCount"] = wom.texturePaths.size();
nlohmann::json arr = nlohmann::json::array();
for (size_t k = 0; k < wom.texturePaths.size(); ++k) {
const auto& p = wom.texturePaths[k];
arr.push_back({
{"index", k},
{"path", p},
{"blpPresent", checkBlp(p)},
{"pngPresent", sidecarPng(p)},
});
}
j["textures"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("WOM textures: %s.wom (%zu textures)\n",
base.c_str(), wom.texturePaths.size());
if (wom.texturePaths.empty()) {
std::printf(" *no texture references*\n");
return 0;
}
std::printf(" idx blp png path\n");
for (size_t k = 0; k < wom.texturePaths.size(); ++k) {
const auto& p = wom.texturePaths[k];
std::printf(" %3zu %s %s %s\n",
k,
checkBlp(p) ? "y" : "-",
sidecarPng(p) ? "y" : "-",
p.c_str());
}
return 0;
} else if (std::strcmp(argv[i], "--info-doodads") == 0 && i + 1 < argc) {
// List every doodad placement in a WOB (M2 instances inside
// a building). Companion to --info-textures: where one
// tracks GPU resources, this tracks scene composition.
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) {
std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str());
return 1;
}
auto bld = wowee::pipeline::WoweeBuildingLoader::load(base);
if (jsonOut) {
nlohmann::json j;
j["wob"] = base + ".wob";
j["count"] = bld.doodads.size();
nlohmann::json arr = nlohmann::json::array();
for (size_t k = 0; k < bld.doodads.size(); ++k) {
const auto& d = bld.doodads[k];
arr.push_back({
{"index", k},
{"modelPath", d.modelPath},
{"position", {d.position.x, d.position.y, d.position.z}},
{"rotation", {d.rotation.x, d.rotation.y, d.rotation.z}},
{"scale", d.scale},
});
}
j["doodads"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("WOB doodads: %s.wob (%zu placements)\n",
base.c_str(), bld.doodads.size());
if (bld.doodads.empty()) {
std::printf(" *no doodad placements*\n");
return 0;
}
std::printf(" idx scale pos (x, y, z) rot (x, y, z) model\n");
for (size_t k = 0; k < bld.doodads.size(); ++k) {
const auto& d = bld.doodads[k];
std::printf(" %3zu %5.2f (%6.1f, %6.1f, %6.1f) (%6.1f, %6.1f, %6.1f) %s\n",
k, d.scale,
d.position.x, d.position.y, d.position.z,
d.rotation.x, d.rotation.y, d.rotation.z,
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-bones") == 0 && i + 1 < argc) {
// Inspect M2 bone tree. Shows parent index, key-bone ID
// (-1 if not a named bone), pivot offset, and a depth
// indicator computed by walking up parents — useful for
// debugging skeleton structure when something looks wrong
// in the renderer ('why is this bone not following its parent?').
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, "info-bones: cannot open %s\n", path.c_str());
return 1;
}
std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
auto m2 = wowee::pipeline::M2Loader::load(bytes);
// Compute depth per bone — guard against cycles by capping
// walk length at boneCount (a real DAG can't exceed that).
std::vector<int> depths(m2.bones.size(), -1);
for (size_t k = 0; k < m2.bones.size(); ++k) {
int d = 0;
int idx = static_cast<int>(k);
while (idx >= 0 && d <= static_cast<int>(m2.bones.size())) {
int parent = m2.bones[idx].parentBone;
if (parent < 0) break;
idx = parent;
d++;
}
depths[k] = d;
}
if (jsonOut) {
nlohmann::json j;
j["m2"] = path;
j["count"] = m2.bones.size();
nlohmann::json arr = nlohmann::json::array();
for (size_t k = 0; k < m2.bones.size(); ++k) {
const auto& b = m2.bones[k];
arr.push_back({
{"index", k}, {"keyBoneId", b.keyBoneId},
{"parent", b.parentBone}, {"flags", b.flags},
{"depth", depths[k]},
{"pivot", {b.pivot.x, b.pivot.y, b.pivot.z}}
});
}
j["bones"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("M2 bones: %s (%zu)\n", path.c_str(), m2.bones.size());
if (m2.bones.empty()) {
std::printf(" *no bones (static model)*\n");
return 0;
}
std::printf(" idx parent depth keyBone flags pivot (x, y, z)\n");
for (size_t k = 0; k < m2.bones.size(); ++k) {
const auto& b = m2.bones[k];
// Indent the keyBone column by depth so the tree shape
// is visible at a glance.
std::printf(" %3zu %6d %5d %7d %5u (%6.2f, %6.2f, %6.2f)\n",
k, b.parentBone, depths[k], b.keyBoneId, b.flags,
b.pivot.x, b.pivot.y, b.pivot.z);
}
return 0;
} else if (std::strcmp(argv[i], "--export-bones-dot") == 0 && i + 1 < argc) {
// Render WOM bone hierarchy as Graphviz DOT. Mirrors
// --export-quest-graph for skeleton trees: trying to read
// a 50-bone tree from --info-bones output is painful;
// pipe this through `dot -Tpng` for the picture.
std::string base = argv[++i];
std::string outPath;
if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i];
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeModelLoader::exists(base)) {
std::fprintf(stderr,
"export-bones-dot: WOM not found: %s.wom\n", base.c_str());
return 1;
}
if (outPath.empty()) outPath = base + ".bones.dot";
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
std::ofstream out(outPath);
if (!out) {
std::fprintf(stderr,
"export-bones-dot: cannot write %s\n", outPath.c_str());
return 1;
}
out << "digraph BoneTree {\n";
out << " // Generated by wowee_editor --export-bones-dot\n";
out << " rankdir=TB;\n";
out << " node [shape=box, style=filled, fontname=\"sans-serif\", fontsize=10];\n";
// Color: green for keybones (named anchor points), gray for
// internal/blend bones. Root bones (parent=-1) get yellow border.
for (size_t k = 0; k < wom.bones.size(); ++k) {
const auto& b = wom.bones[k];
bool isKey = (b.keyBoneId >= 0);
std::string fill = isKey ? "lightgreen" : "lightgrey";
std::string label = "[" + std::to_string(k) + "]";
if (isKey) label += "\\nkey=" + std::to_string(b.keyBoneId);
out << " b" << k << " [label=\"" << label
<< "\", fillcolor=" << fill;
if (b.parentBone == -1) out << ", penwidth=2, color=goldenrod";
out << "];\n";
}
// Edges: child -> parent (parent is up).
int rootCount = 0;
for (size_t k = 0; k < wom.bones.size(); ++k) {
int16_t p = wom.bones[k].parentBone;
if (p < 0 || p >= static_cast<int16_t>(wom.bones.size())) {
rootCount++;
continue;
}
out << " b" << p << " -> b" << k << ";\n";
}
out << "}\n";
out.close();
std::printf("Wrote %s\n", outPath.c_str());
std::printf(" %zu bones, %d root(s)\n",
wom.bones.size(), rootCount);
std::printf(" next: dot -Tpng %s -o bones.png\n", outPath.c_str());
return 0;
} else if (std::strcmp(argv[i], "--info-zone-models-total") == 0 && i + 1 < argc) {
// Aggregate WOM/WOB stats across every model in a zone.
// Useful for capacity planning ('how many bones across all