Kelsidavis-WoWee/tools/editor/cli_format_validate.cpp
Kelsi b87ece2d5b refactor(editor): extract format validation into cli_format_validate.cpp
Moves the open-format validation + project-audit handlers out
of main.cpp:

  --validate                    --validate-wom
  --validate-wob                --validate-woc
  --validate-whm                --validate-all
  --validate-project            --validate-project-open-only
  --audit-project               --bench-audit-project
  --bench-validate-project

Also moves the four shared validate*Errors helpers (validateWom/
Wob/Woc/WhmErrors, ~365 lines) into the same module's anonymous
namespace — they were file-scope helpers in main.cpp used only
by these handlers, so co-locating eliminates the cross-TU
coupling.

main.cpp drops 19,446 → 18,396 lines (-1,050). Two build errors
caught during extraction (wrong include path for the WHM loader
header; missing #include for ContentPacker / std::set / std::map);
all fixed before commit.
2026-05-09 01:57:37 -07:00

1146 lines
48 KiB
C++

#include "cli_format_validate.hpp"
#include "pipeline/wowee_model.hpp"
#include "pipeline/wowee_building.hpp"
#include "pipeline/wowee_collision.hpp"
#include "pipeline/wowee_terrain_loader.hpp"
#include "content_pack.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <map>
#include <set>
#include <string>
#include <vector>
namespace wowee {
namespace editor {
namespace cli {
namespace {
std::vector<std::string> validateWomErrors(
const wowee::pipeline::WoweeModel& wom) {
std::vector<std::string> errors;
if (wom.version < 1 || wom.version > 3) {
errors.push_back("version " + std::to_string(wom.version) +
" outside [1,3]");
}
if (!wom.isValid()) errors.push_back("empty geometry (no verts/indices)");
if (wom.indices.size() % 3 != 0) {
errors.push_back("indices.size()=" + std::to_string(wom.indices.size()) +
" not divisible by 3");
}
int oobIdx = 0;
for (uint32_t idx : wom.indices) {
if (idx >= wom.vertices.size()) {
if (++oobIdx <= 3) {
errors.push_back("index " + std::to_string(idx) +
" >= vertexCount " +
std::to_string(wom.vertices.size()));
}
}
}
if (oobIdx > 3) {
errors.push_back("... and " + std::to_string(oobIdx - 3) +
" more out-of-range indices");
}
for (size_t b = 0; b < wom.bones.size(); ++b) {
int16_t p = wom.bones[b].parentBone;
if (p == -1) continue;
if (p < 0 || p >= static_cast<int16_t>(wom.bones.size())) {
errors.push_back("bone " + std::to_string(b) +
" parent=" + std::to_string(p) +
" out of range");
} else if (p >= static_cast<int16_t>(b)) {
errors.push_back("bone " + std::to_string(b) +
" parent=" + std::to_string(p) +
" not strictly less (DAG order)");
}
}
int oobVB = 0;
for (size_t v = 0; v < wom.vertices.size() && !wom.bones.empty(); ++v) {
const auto& vert = wom.vertices[v];
for (int k = 0; k < 4; ++k) {
if (vert.boneWeights[k] == 0) continue;
if (vert.boneIndices[k] >= wom.bones.size()) {
if (++oobVB <= 3) {
errors.push_back("vertex " + std::to_string(v) +
" boneIndex[" + std::to_string(k) +
"]=" + std::to_string(vert.boneIndices[k]) +
" >= boneCount " +
std::to_string(wom.bones.size()));
}
}
}
}
if (oobVB > 3) {
errors.push_back("... and " + std::to_string(oobVB - 3) +
" more out-of-range vertex bone refs");
}
for (size_t a = 0; a < wom.animations.size(); ++a) {
const auto& anim = wom.animations[a];
if (!anim.boneKeyframes.empty() &&
anim.boneKeyframes.size() != wom.bones.size()) {
errors.push_back("animation " + std::to_string(a) +
" boneKeyframes.size()=" +
std::to_string(anim.boneKeyframes.size()) +
" != boneCount " +
std::to_string(wom.bones.size()));
}
}
for (size_t b = 0; b < wom.batches.size(); ++b) {
const auto& batch = wom.batches[b];
uint64_t end = uint64_t(batch.indexStart) + batch.indexCount;
if (end > wom.indices.size()) {
errors.push_back("batch " + std::to_string(b) +
" indexStart+Count=" + std::to_string(end) +
" > indexCount " +
std::to_string(wom.indices.size()));
}
if (batch.indexCount % 3 != 0) {
errors.push_back("batch " + std::to_string(b) +
" indexCount=" + std::to_string(batch.indexCount) +
" not divisible by 3");
}
if (!wom.texturePaths.empty() &&
batch.textureIndex >= wom.texturePaths.size()) {
errors.push_back("batch " + std::to_string(b) +
" textureIndex=" + std::to_string(batch.textureIndex) +
" >= textureCount " +
std::to_string(wom.texturePaths.size()));
}
}
if (wom.boundMin.x > wom.boundMax.x ||
wom.boundMin.y > wom.boundMax.y ||
wom.boundMin.z > wom.boundMax.z) {
errors.push_back("boundMin > boundMax on at least one axis");
}
if (wom.boundRadius < 0.0f) {
errors.push_back("boundRadius=" + std::to_string(wom.boundRadius) +
" is negative");
}
return errors;
}
std::vector<std::string> validateWobErrors(
const wowee::pipeline::WoweeBuilding& bld) {
std::vector<std::string> errors;
if (!bld.isValid()) errors.push_back("empty building (no groups)");
int badMatTexCount = 0;
for (size_t g = 0; g < bld.groups.size(); ++g) {
const auto& grp = bld.groups[g];
if (grp.indices.size() % 3 != 0) {
errors.push_back("group " + std::to_string(g) +
" indices.size()=" + std::to_string(grp.indices.size()) +
" not divisible by 3");
}
int oobIdx = 0;
for (uint32_t idx : grp.indices) {
if (idx >= grp.vertices.size()) ++oobIdx;
}
if (oobIdx > 0) {
errors.push_back("group " + std::to_string(g) + " has " +
std::to_string(oobIdx) +
" indices out of range (vertCount=" +
std::to_string(grp.vertices.size()) + ")");
}
for (size_t m = 0; m < grp.materials.size(); ++m) {
if (grp.materials[m].texturePath.empty()) {
badMatTexCount++;
if (badMatTexCount <= 3) {
errors.push_back("group " + std::to_string(g) +
" material " + std::to_string(m) +
" has empty texturePath");
}
}
}
if (grp.boundMin.x > grp.boundMax.x ||
grp.boundMin.y > grp.boundMax.y ||
grp.boundMin.z > grp.boundMax.z) {
errors.push_back("group " + std::to_string(g) +
" boundMin > boundMax on at least one axis");
}
}
if (badMatTexCount > 3) {
errors.push_back("... and " + std::to_string(badMatTexCount - 3) +
" more empty material textures");
}
int badPortal = 0;
for (size_t p = 0; p < bld.portals.size(); ++p) {
const auto& portal = bld.portals[p];
auto inRange = [&](int g) {
return g == -1 ||
(g >= 0 && g < static_cast<int>(bld.groups.size()));
};
if (!inRange(portal.groupA) || !inRange(portal.groupB)) {
if (++badPortal <= 3) {
errors.push_back("portal " + std::to_string(p) +
" refs out-of-range groups (" +
std::to_string(portal.groupA) + ", " +
std::to_string(portal.groupB) + ")");
}
}
if (portal.vertices.size() < 3) {
if (++badPortal <= 3) {
errors.push_back("portal " + std::to_string(p) +
" has only " +
std::to_string(portal.vertices.size()) +
" verts (need >= 3 for a polygon)");
}
}
}
if (badPortal > 3) {
errors.push_back("... and " + std::to_string(badPortal - 3) +
" more bad portal entries");
}
int badDoodad = 0;
for (size_t d = 0; d < bld.doodads.size(); ++d) {
const auto& doodad = bld.doodads[d];
if (doodad.modelPath.empty()) {
if (++badDoodad <= 3) {
errors.push_back("doodad " + std::to_string(d) +
" has empty modelPath");
}
}
if (!std::isfinite(doodad.scale) || doodad.scale <= 0.0f) {
if (++badDoodad <= 3) {
errors.push_back("doodad " + std::to_string(d) +
" has non-positive scale " +
std::to_string(doodad.scale));
}
}
}
if (badDoodad > 3) {
errors.push_back("... and " + std::to_string(badDoodad - 3) +
" more bad doodad entries");
}
if (bld.boundRadius < 0.0f) {
errors.push_back("boundRadius=" + std::to_string(bld.boundRadius) +
" is negative");
}
return errors;
}
std::vector<std::string> validateWocErrors(
const wowee::pipeline::WoweeCollision& woc) {
std::vector<std::string> errors;
if (!woc.isValid()) errors.push_back("empty collision (no triangles)");
if (woc.tileX >= 64 || woc.tileY >= 64) {
errors.push_back("tile coords out of WoW grid: (" +
std::to_string(woc.tileX) + ", " +
std::to_string(woc.tileY) + ") — must be < 64");
}
int nanTris = 0, degenerate = 0, badFlags = 0;
auto isFiniteVec = [](const glm::vec3& v) {
return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z);
};
constexpr uint8_t kKnownFlags = 0x0F; // walkable|water|steep|indoor
for (size_t t = 0; t < woc.triangles.size(); ++t) {
const auto& tri = woc.triangles[t];
if (!isFiniteVec(tri.v0) || !isFiniteVec(tri.v1) || !isFiniteVec(tri.v2)) {
if (++nanTris <= 3) {
errors.push_back("triangle " + std::to_string(t) +
" has non-finite vertex coord");
}
}
if (tri.v0 == tri.v1 || tri.v1 == tri.v2 || tri.v0 == tri.v2) {
if (++degenerate <= 3) {
errors.push_back("triangle " + std::to_string(t) +
" is degenerate (two vertices identical)");
}
}
if (tri.flags & ~kKnownFlags) {
if (++badFlags <= 3) {
errors.push_back("triangle " + std::to_string(t) +
" has unknown flag bits 0x" +
[&]{ char b[8]; std::snprintf(b,sizeof b,"%02X",tri.flags); return std::string(b); }());
}
}
}
if (nanTris > 3) errors.push_back("... and " + std::to_string(nanTris - 3) +
" more non-finite triangles");
if (degenerate > 3) errors.push_back("... and " + std::to_string(degenerate - 3) +
" more degenerate triangles");
if (badFlags > 3) errors.push_back("... and " + std::to_string(badFlags - 3) +
" more triangles with unknown flag bits");
if (woc.bounds.min.x > woc.bounds.max.x ||
woc.bounds.min.y > woc.bounds.max.y ||
woc.bounds.min.z > woc.bounds.max.z) {
errors.push_back("bounds.min > bounds.max on at least one axis");
}
return errors;
}
std::vector<std::string> validateWhmErrors(
const wowee::pipeline::ADTTerrain& terrain) {
std::vector<std::string> errors;
if (!terrain.isLoaded()) {
errors.push_back("terrain not loaded");
return errors;
}
if (terrain.coord.x < 0 || terrain.coord.x >= 64 ||
terrain.coord.y < 0 || terrain.coord.y >= 64) {
errors.push_back("tile coord out of WoW grid: (" +
std::to_string(terrain.coord.x) + ", " +
std::to_string(terrain.coord.y) + ")");
}
int nanHeightChunks = 0, nanPosChunks = 0;
int loadedChunks = 0;
float minH = 1e30f, maxH = -1e30f;
for (size_t c = 0; c < 256; ++c) {
const auto& chunk = terrain.chunks[c];
if (!chunk.heightMap.isLoaded()) continue;
loadedChunks++;
if (!std::isfinite(chunk.position[0]) ||
!std::isfinite(chunk.position[1]) ||
!std::isfinite(chunk.position[2])) {
if (++nanPosChunks <= 3) {
errors.push_back("chunk " + std::to_string(c) +
" has non-finite position");
}
}
bool chunkHasBadHeight = false;
for (float h : chunk.heightMap.heights) {
if (!std::isfinite(h)) {
chunkHasBadHeight = true;
} else {
if (h < minH) minH = h;
if (h > maxH) maxH = h;
}
}
if (chunkHasBadHeight) {
if (++nanHeightChunks <= 3) {
errors.push_back("chunk " + std::to_string(c) +
" contains non-finite heights");
}
}
}
if (nanHeightChunks > 3) {
errors.push_back("... and " + std::to_string(nanHeightChunks - 3) +
" more chunks with non-finite heights");
}
if (nanPosChunks > 3) {
errors.push_back("... and " + std::to_string(nanPosChunks - 3) +
" more chunks with non-finite positions");
}
if (loadedChunks == 0) {
errors.push_back("no chunks loaded (heightmap empty)");
}
// Heights outside the WoW world envelope often signal a units-confusion
// bug — most maps stay in [-3000, 3000]. Warn-class, not fail.
if (loadedChunks > 0 && (minH < -10000.0f || maxH > 10000.0f)) {
errors.push_back("height range [" + std::to_string(minH) +
", " + std::to_string(maxH) +
"] is outside reasonable WoW envelope");
}
int badPlacements = 0;
for (size_t p = 0; p < terrain.doodadPlacements.size(); ++p) {
const auto& d = terrain.doodadPlacements[p];
if (!std::isfinite(d.position[0]) ||
!std::isfinite(d.position[1]) ||
!std::isfinite(d.position[2])) {
if (++badPlacements <= 3) {
errors.push_back("doodad placement " + std::to_string(p) +
" has non-finite position");
}
}
if (d.scale == 0) {
if (++badPlacements <= 3) {
errors.push_back("doodad placement " + std::to_string(p) +
" has scale=0");
}
}
if (!terrain.doodadNames.empty() && d.nameId >= terrain.doodadNames.size()) {
if (++badPlacements <= 3) {
errors.push_back("doodad placement " + std::to_string(p) +
" nameId=" + std::to_string(d.nameId) +
" >= doodadNames " +
std::to_string(terrain.doodadNames.size()));
}
}
}
for (size_t p = 0; p < terrain.wmoPlacements.size(); ++p) {
const auto& w = terrain.wmoPlacements[p];
if (!std::isfinite(w.position[0]) ||
!std::isfinite(w.position[1]) ||
!std::isfinite(w.position[2])) {
if (++badPlacements <= 3) {
errors.push_back("wmo placement " + std::to_string(p) +
" has non-finite position");
}
}
if (!terrain.wmoNames.empty() && w.nameId >= terrain.wmoNames.size()) {
if (++badPlacements <= 3) {
errors.push_back("wmo placement " + std::to_string(p) +
" nameId=" + std::to_string(w.nameId) +
" >= wmoNames " +
std::to_string(terrain.wmoNames.size()));
}
}
}
if (badPlacements > 3) {
errors.push_back("... and " + std::to_string(badPlacements - 3) +
" more bad placement entries");
}
return errors;
}
int handleValidate(int& i, int argc, char** argv) {
std::string zoneDir = argv[++i];
// Optional --json after the dir for machine-readable output
// (matches --info-extract --json).
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
auto v = wowee::editor::ContentPacker::validateZone(zoneDir);
int score = v.openFormatScore();
if (jsonOut) {
nlohmann::json j;
j["zone"] = zoneDir;
j["score"] = score;
j["maxScore"] = 7;
j["formats"] = v.summary();
auto fmt = [&](const char* name, bool present, int count,
bool valid = true, int invalid = 0) {
nlohmann::json f;
f["present"] = present;
f["count"] = count;
f["valid"] = valid;
if (invalid > 0) f["invalid"] = invalid;
j[name] = f;
};
fmt("wot", v.hasWot, v.wotCount);
fmt("whm", v.hasWhm, v.whmCount, v.whmValid);
fmt("wom", v.hasWom, v.womCount, v.womValid, v.womInvalidCount);
fmt("wob", v.hasWob, v.wobCount, v.wobValid, v.wobInvalidCount);
fmt("woc", v.hasWoc, v.wocCount, v.wocValid, v.wocInvalidCount);
fmt("png", v.hasPng, v.pngCount);
j["zoneJson"] = v.hasZoneJson;
j["creatures"] = v.hasCreatures;
j["quests"] = v.hasQuests;
j["objects"] = v.hasObjects;
std::printf("%s\n", j.dump(2).c_str());
return score == 7 ? 0 : 1;
}
std::printf("Zone: %s\n", zoneDir.c_str());
std::printf("Open format score: %d/7\n", score);
std::printf("Formats: %s\n", v.summary().c_str());
std::printf("Files present:\n");
std::printf(" WOT (terrain meta) : %s (%d)\n",
v.hasWot ? "yes" : "no", v.wotCount);
std::printf(" WHM (heightmap) : %s (%d)%s\n",
v.hasWhm ? "yes" : "no", v.whmCount,
v.hasWhm && !v.whmValid ? " (BAD MAGIC)" : "");
std::printf(" WOM (models) : %s (%d)%s\n",
v.hasWom ? "yes" : "no", v.womCount,
v.womInvalidCount > 0 ?
(" (" + std::to_string(v.womInvalidCount) + " invalid)").c_str() : "");
std::printf(" WOB (buildings) : %s (%d)%s\n",
v.hasWob ? "yes" : "no", v.wobCount,
v.wobInvalidCount > 0 ?
(" (" + std::to_string(v.wobInvalidCount) + " invalid)").c_str() : "");
std::printf(" WOC (collision) : %s (%d)%s\n",
v.hasWoc ? "yes" : "no", v.wocCount,
v.wocInvalidCount > 0 ?
(" (" + std::to_string(v.wocInvalidCount) + " invalid)").c_str() : "");
std::printf(" PNG (textures) : %s (%d)\n",
v.hasPng ? "yes" : "no", v.pngCount);
std::printf(" zone.json : %s\n", v.hasZoneJson ? "yes" : "no");
std::printf(" creatures.json : %s\n", v.hasCreatures ? "yes" : "no");
std::printf(" quests.json : %s\n", v.hasQuests ? "yes" : "no");
std::printf(" objects.json : %s\n", v.hasObjects ? "yes" : "no");
return score == 7 ? 0 : 1;
}
int handleValidateWom(int& i, int argc, char** argv) {
// Deep consistency check on a single WOM. The loader is
// deliberately lenient (it accepts older/partial files), so
// silent corruption can survive load. This walks every cross-
// reference and reports anything out of range.
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);
auto errors = validateWomErrors(wom);
if (jsonOut) {
nlohmann::json j;
j["wom"] = base + ".wom";
j["version"] = wom.version;
j["errorCount"] = errors.size();
j["errors"] = errors;
j["passed"] = errors.empty();
std::printf("%s\n", j.dump(2).c_str());
return errors.empty() ? 0 : 1;
}
std::printf("WOM: %s.wom (v%u)\n", base.c_str(), wom.version);
if (errors.empty()) {
std::printf(" PASSED — %zu verts, %zu indices, %zu bones, %zu anims, %zu batches\n",
wom.vertices.size(), wom.indices.size(),
wom.bones.size(), wom.animations.size(),
wom.batches.size());
return 0;
}
std::printf(" FAILED — %zu error(s):\n", errors.size());
for (const auto& e : errors) std::printf(" - %s\n", e.c_str());
return 1;
}
int handleValidateWob(int& i, int argc, char** argv) {
// Deep consistency check on a single WOB. Like --validate-wom
// but covering buildings: per-group index/material refs, portal
// group references, doodad scales, and bounds.
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);
auto errors = validateWobErrors(bld);
if (jsonOut) {
nlohmann::json j;
j["wob"] = base + ".wob";
j["name"] = bld.name;
j["groups"] = bld.groups.size();
j["portals"] = bld.portals.size();
j["doodads"] = bld.doodads.size();
j["errorCount"] = errors.size();
j["errors"] = errors;
j["passed"] = errors.empty();
std::printf("%s\n", j.dump(2).c_str());
return errors.empty() ? 0 : 1;
}
std::printf("WOB: %s.wob\n", base.c_str());
std::printf(" name : %s\n", bld.name.c_str());
if (errors.empty()) {
std::printf(" PASSED — %zu groups, %zu portals, %zu doodads\n",
bld.groups.size(), bld.portals.size(), bld.doodads.size());
return 0;
}
std::printf(" FAILED — %zu error(s):\n", errors.size());
for (const auto& e : errors) std::printf(" - %s\n", e.c_str());
return 1;
}
int handleValidateWoc(int& i, int argc, char** argv) {
// Deep check on a WOC collision mesh — finite vertex coords,
// non-degenerate triangles, valid flag bits, sane bounds.
// Catches corruption that breaks movement queries silently.
std::string path = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
if (!std::filesystem::exists(path)) {
std::fprintf(stderr, "WOC not found: %s\n", path.c_str());
return 1;
}
auto woc = wowee::pipeline::WoweeCollisionBuilder::load(path);
auto errors = validateWocErrors(woc);
if (jsonOut) {
nlohmann::json j;
j["woc"] = path;
j["triangles"] = woc.triangles.size();
j["walkable"] = woc.walkableCount();
j["steep"] = woc.steepCount();
j["tile"] = {woc.tileX, woc.tileY};
j["errorCount"] = errors.size();
j["errors"] = errors;
j["passed"] = errors.empty();
std::printf("%s\n", j.dump(2).c_str());
return errors.empty() ? 0 : 1;
}
std::printf("WOC: %s\n", path.c_str());
std::printf(" tile : (%u, %u)\n", woc.tileX, woc.tileY);
if (errors.empty()) {
std::printf(" PASSED — %zu triangles (%zu walkable, %zu steep)\n",
woc.triangles.size(),
woc.walkableCount(), woc.steepCount());
return 0;
}
std::printf(" FAILED — %zu error(s):\n", errors.size());
for (const auto& e : errors) std::printf(" - %s\n", e.c_str());
return 1;
}
int handleValidateWhm(int& i, int argc, char** argv) {
// Deep check on a WHM/WOT terrain pair — finite heights,
// chunks present, placements within name-table bounds.
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
for (const char* ext : {".wot", ".whm"}) {
if (base.size() >= 4 && base.substr(base.size() - 4) == ext) {
base = base.substr(0, base.size() - 4);
break;
}
}
if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) {
std::fprintf(stderr, "WHM/WOT not found: %s.{whm,wot}\n", base.c_str());
return 1;
}
wowee::pipeline::ADTTerrain terrain;
wowee::pipeline::WoweeTerrainLoader::load(base, terrain);
auto errors = validateWhmErrors(terrain);
if (jsonOut) {
nlohmann::json j;
j["whm"] = base + ".whm";
j["wot"] = base + ".wot";
j["coord"] = {terrain.coord.x, terrain.coord.y};
j["doodadPlacements"] = terrain.doodadPlacements.size();
j["wmoPlacements"] = terrain.wmoPlacements.size();
int loadedChunks = 0;
for (const auto& c : terrain.chunks) if (c.heightMap.isLoaded()) loadedChunks++;
j["loadedChunks"] = loadedChunks;
j["errorCount"] = errors.size();
j["errors"] = errors;
j["passed"] = errors.empty();
std::printf("%s\n", j.dump(2).c_str());
return errors.empty() ? 0 : 1;
}
std::printf("WHM/WOT: %s.{whm,wot}\n", base.c_str());
std::printf(" tile : (%d, %d)\n", terrain.coord.x, terrain.coord.y);
if (errors.empty()) {
int loaded = 0;
for (const auto& c : terrain.chunks) if (c.heightMap.isLoaded()) loaded++;
std::printf(" PASSED — %d/256 chunks, %zu doodad + %zu wmo placements\n",
loaded, terrain.doodadPlacements.size(),
terrain.wmoPlacements.size());
return 0;
}
std::printf(" FAILED — %zu error(s):\n", errors.size());
for (const auto& e : errors) std::printf(" - %s\n", e.c_str());
return 1;
}
int handleValidateAll(int& i, int argc, char** argv) {
// CI gate: walk a directory, run every per-format validator on
// every matching file. Aggregate counts for fast triage; per-
// file errors are listed (capped at 20) so the user knows which
// file to drill into with --validate-{wom,wob,woc,whm}.
std::string root = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
namespace fs = std::filesystem;
if (!fs::exists(root)) {
std::fprintf(stderr, "validate-all: not found: %s\n", root.c_str());
return 1;
}
int womTotal = 0, womFail = 0, wobTotal = 0, wobFail = 0;
int wocTotal = 0, wocFail = 0, whmTotal = 0, whmFail = 0;
int totalErrors = 0;
std::vector<std::pair<std::string, std::vector<std::string>>> failures;
auto recordFailure = [&](const std::string& path,
const std::vector<std::string>& errs) {
totalErrors += errs.size();
if (failures.size() < 20) failures.push_back({path, errs});
};
for (const auto& entry : fs::recursive_directory_iterator(root)) {
if (!entry.is_regular_file()) continue;
std::string ext = entry.path().extension().string();
std::string base = entry.path().string();
base = base.substr(0, base.size() - ext.size());
if (ext == ".wom") {
womTotal++;
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
auto errs = validateWomErrors(wom);
if (!errs.empty()) { womFail++; recordFailure(entry.path().string(), errs); }
} else if (ext == ".wob") {
wobTotal++;
auto bld = wowee::pipeline::WoweeBuildingLoader::load(base);
auto errs = validateWobErrors(bld);
if (!errs.empty()) { wobFail++; recordFailure(entry.path().string(), errs); }
} else if (ext == ".woc") {
wocTotal++;
auto woc = wowee::pipeline::WoweeCollisionBuilder::load(entry.path().string());
auto errs = validateWocErrors(woc);
if (!errs.empty()) { wocFail++; recordFailure(entry.path().string(), errs); }
} else if (ext == ".whm") {
// Only validate via the .whm half — .wot is its sidecar
// and gets pulled in by load(base).
whmTotal++;
wowee::pipeline::ADTTerrain terrain;
wowee::pipeline::WoweeTerrainLoader::load(base, terrain);
auto errs = validateWhmErrors(terrain);
if (!errs.empty()) { whmFail++; recordFailure(entry.path().string(), errs); }
}
}
int allPassed = (womFail == 0 && wobFail == 0 &&
wocFail == 0 && whmFail == 0);
int totalFiles = womTotal + wobTotal + wocTotal + whmTotal;
if (jsonOut) {
nlohmann::json j;
j["root"] = root;
j["wom"] = {{"total", womTotal}, {"failed", womFail}};
j["wob"] = {{"total", wobTotal}, {"failed", wobFail}};
j["woc"] = {{"total", wocTotal}, {"failed", wocFail}};
j["whm"] = {{"total", whmTotal}, {"failed", whmFail}};
j["totalErrors"] = totalErrors;
j["passed"] = bool(allPassed);
nlohmann::json failArr = nlohmann::json::array();
for (const auto& [path, errs] : failures) {
failArr.push_back({{"file", path}, {"errors", errs}});
}
j["failures"] = failArr;
std::printf("%s\n", j.dump(2).c_str());
return allPassed ? 0 : 1;
}
std::printf("validate-all: %s\n", root.c_str());
std::printf(" WOM: %d total, %d failed\n", womTotal, womFail);
std::printf(" WOB: %d total, %d failed\n", wobTotal, wobFail);
std::printf(" WOC: %d total, %d failed\n", wocTotal, wocFail);
std::printf(" WHM: %d total, %d failed\n", whmTotal, whmFail);
if (allPassed) {
std::printf(" PASSED — all %d file(s) clean\n", totalFiles);
return 0;
}
std::printf(" FAILED — %d total error(s) across %zu file(s):\n",
totalErrors, failures.size());
for (const auto& [path, errs] : failures) {
std::printf(" %s:\n", path.c_str());
for (const auto& e : errs) std::printf(" - %s\n", e.c_str());
}
return 1;
}
int handleValidateProject(int& i, int argc, char** argv) {
// Project-level validate. Walks every zone in <projectDir>
// and runs the per-format validators (same as --validate-all).
// Aggregates pass/fail counts; exits 1 if any zone has any
// validation errors. Designed for CI gates before --pack-wcp.
std::string projectDir = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
namespace fs = std::filesystem;
if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) {
std::fprintf(stderr,
"validate-project: %s is not a directory\n",
projectDir.c_str());
return 1;
}
std::vector<std::string> zones;
for (const auto& entry : fs::directory_iterator(projectDir)) {
if (!entry.is_directory()) continue;
if (!fs::exists(entry.path() / "zone.json")) continue;
zones.push_back(entry.path().string());
}
std::sort(zones.begin(), zones.end());
// Per-zone pass/fail with file-level breakdown.
struct ZoneResult { std::string name; int totalFiles, failedFiles, totalErrors; };
std::vector<ZoneResult> results;
int projectFailedZones = 0;
for (const auto& zoneDir : zones) {
ZoneResult r{zoneDir, 0, 0, 0};
std::error_code ec;
for (const auto& entry : fs::recursive_directory_iterator(zoneDir, ec)) {
if (!entry.is_regular_file()) continue;
std::string ext = entry.path().extension().string();
std::string base = entry.path().string();
base = base.substr(0, base.size() - ext.size());
std::vector<std::string> errs;
if (ext == ".wom") {
r.totalFiles++;
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
errs = validateWomErrors(wom);
} else if (ext == ".wob") {
r.totalFiles++;
auto wob = wowee::pipeline::WoweeBuildingLoader::load(base);
errs = validateWobErrors(wob);
} else if (ext == ".woc") {
r.totalFiles++;
auto woc = wowee::pipeline::WoweeCollisionBuilder::load(entry.path().string());
errs = validateWocErrors(woc);
} else if (ext == ".whm") {
r.totalFiles++;
wowee::pipeline::ADTTerrain terrain;
wowee::pipeline::WoweeTerrainLoader::load(base, terrain);
errs = validateWhmErrors(terrain);
}
if (!errs.empty()) {
r.failedFiles++;
r.totalErrors += static_cast<int>(errs.size());
}
}
if (r.failedFiles > 0) projectFailedZones++;
results.push_back(r);
}
int allPassed = (projectFailedZones == 0);
if (jsonOut) {
nlohmann::json j;
j["projectDir"] = projectDir;
j["totalZones"] = zones.size();
j["failedZones"] = projectFailedZones;
j["passed"] = bool(allPassed);
nlohmann::json zarr = nlohmann::json::array();
for (const auto& r : results) {
zarr.push_back({
{"zone", r.name},
{"totalFiles", r.totalFiles},
{"failedFiles", r.failedFiles},
{"totalErrors", r.totalErrors}
});
}
j["zones"] = zarr;
std::printf("%s\n", j.dump(2).c_str());
return allPassed ? 0 : 1;
}
std::printf("validate-project: %s\n", projectDir.c_str());
std::printf(" zones : %zu (%d failed)\n",
zones.size(), projectFailedZones);
std::printf("\n zone files failed errors status\n");
for (const auto& r : results) {
std::string shortName = fs::path(r.name).filename().string();
std::printf(" %-26s %5d %6d %6d %s\n",
shortName.substr(0, 26).c_str(),
r.totalFiles, r.failedFiles, r.totalErrors,
r.failedFiles == 0 ? "PASS" : "FAIL");
}
if (allPassed) {
std::printf("\n ALL ZONES PASSED\n");
return 0;
}
std::printf("\n %d zone(s) failed validation\n", projectFailedZones);
return 1;
}
int handleValidateProjectOpenOnly(int& i, int argc, char** argv) {
// Release gate. Walks every file in <projectDir> and exits
// 1 if any proprietary Blizzard asset is present (.m2, .skin,
// .wmo, .blp, .dbc). Designed for CI to enforce a
// "no-proprietary-assets" release condition once a project
// has fully migrated to the open WOM/WOB/PNG/JSON formats.
std::string projectDir = argv[++i];
namespace fs = std::filesystem;
if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) {
std::fprintf(stderr,
"validate-project-open-only: %s is not a directory\n",
projectDir.c_str());
return 1;
}
// Standard set of proprietary extensions. Mirrors the
// "(proprietary)" categories used by --info-project-bytes.
static const std::set<std::string> propExt = {
".m2", ".skin", ".wmo", ".blp", ".dbc",
};
std::map<std::string, int> byExt;
std::vector<std::string> hits;
std::error_code ec;
for (const auto& e : fs::recursive_directory_iterator(projectDir, ec)) {
if (!e.is_regular_file()) continue;
std::string ext = e.path().extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return std::tolower(c); });
if (!propExt.count(ext)) continue;
byExt[ext]++;
std::string rel = fs::relative(e.path(), projectDir, ec).string();
if (ec) rel = e.path().string();
hits.push_back(rel);
}
std::sort(hits.begin(), hits.end());
std::printf("validate-project-open-only: %s\n", projectDir.c_str());
if (hits.empty()) {
std::printf(" PASSED — no proprietary Blizzard assets present\n");
return 0;
}
std::printf(" FAILED — %zu proprietary file(s) remain\n", hits.size());
std::printf("\n Per-extension:\n");
for (const auto& [ext, count] : byExt) {
std::printf(" %-6s : %d\n", ext.c_str(), count);
}
std::printf("\n Files (sorted):\n");
// Cap the file list at 50 entries so a wholly unmigrated
// project doesn't fill the user's terminal.
size_t shown = 0;
for (const auto& h : hits) {
if (shown >= 50) {
std::printf(" ... and %zu more\n", hits.size() - shown);
break;
}
std::printf(" - %s\n", h.c_str());
shown++;
}
return 1;
}
int handleAuditProject(int& i, int argc, char** argv) {
// Composite CI gate. Re-invokes the binary to run the four
// most important per-project checks back-to-back and rolls
// their exit codes into a single PASS/FAIL verdict. Emits
// a one-line summary for each sub-check plus the final
// overall result. Designed to be the only command CI needs
// to run before --pack-wcp.
//
// Sub-checks (ordered cheapest→most expensive so a fast
// failure surfaces before the slow ones run):
// 1. validate-project (per-format integrity)
// 2. validate-project-open-only (no proprietary leaks)
// 3. validate-project-items (items.json schema)
// 4. check-project-refs (every model/NPC ref resolves)
// 5. check-project-content (sane field values)
// 6. audit-project-spawns (spawn Z near terrain)
std::string projectDir = argv[++i];
namespace fs = std::filesystem;
if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) {
std::fprintf(stderr,
"audit-project: %s is not a directory\n",
projectDir.c_str());
return 1;
}
// Use the binary's own path so the audit works from any cwd.
std::string self = argv[0];
// Quote both to survive paths with spaces; redirect each
// sub-check's stdout to a separate temp file so the final
// verdict isn't drowned in their output.
auto runStep = [&](const std::string& flag) -> int {
std::string cmd = "\"" + self + "\" " + flag + " \"" + projectDir + "\"";
// Suppress stdout so the audit's own report stays
// readable; users can rerun the individual sub-check
// for full output if needed.
cmd += " >/dev/null 2>&1";
// std::system returns 0 on success across POSIX and
// Windows. Anything else is a failure for our purposes;
// we just need PASS/FAIL granularity here.
return std::system(cmd.c_str());
};
struct Step { const char* name; const char* flag; int rc; };
std::vector<Step> steps = {
{"format validation ", "--validate-project", 0},
{"open-only release gate ", "--validate-project-open-only", 0},
{"items schema ", "--validate-project-items", 0},
{"reference integrity ", "--check-project-refs", 0},
{"content field sanity ", "--check-project-content", 0},
{"spawn placement ", "--audit-project-spawns", 0},
};
int totalFailed = 0;
std::printf("audit-project: %s\n\n", projectDir.c_str());
for (auto& s : steps) {
s.rc = runStep(s.flag);
bool pass = (s.rc == 0);
std::printf(" [%s] %s (%s, rc=%d)\n",
pass ? "PASS" : "FAIL",
s.name, s.flag, s.rc);
if (!pass) totalFailed++;
}
std::printf("\n");
if (totalFailed == 0) {
std::printf("OVERALL: PASS — project is release-ready\n");
return 0;
}
std::printf("OVERALL: FAIL — %d sub-check(s) failed\n", totalFailed);
std::printf(" rerun a failing sub-check directly for detailed output\n");
return 1;
}
int handleBenchAuditProject(int& i, int argc, char** argv) {
// Time each --audit-project sub-step end-to-end so users
// can see where the slow checks are. Useful for tuning a
// CI pipeline: drop the slowest check from a fast-feedback
// pre-commit hook, run the full audit on push.
std::string projectDir = argv[++i];
namespace fs = std::filesystem;
if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) {
std::fprintf(stderr,
"bench-audit-project: %s is not a directory\n",
projectDir.c_str());
return 1;
}
std::string self = argv[0];
struct Step { const char* name; const char* flag; double ms; int rc; };
std::vector<Step> steps = {
{"format validation ", "--validate-project", 0, 0},
{"open-only release gate ", "--validate-project-open-only", 0, 0},
{"items schema ", "--validate-project-items", 0, 0},
{"reference integrity ", "--check-project-refs", 0, 0},
{"content field sanity ", "--check-project-content", 0, 0},
{"spawn placement ", "--audit-project-spawns", 0, 0},
};
double totalMs = 0;
for (auto& s : steps) {
std::string cmd = "\"" + self + "\" " + s.flag + " \"" +
projectDir + "\" >/dev/null 2>&1";
auto t0 = std::chrono::steady_clock::now();
s.rc = std::system(cmd.c_str());
auto t1 = std::chrono::steady_clock::now();
s.ms = std::chrono::duration<double, std::milli>(t1 - t0).count();
totalMs += s.ms;
}
std::printf("bench-audit-project: %s\n", projectDir.c_str());
std::printf(" total : %.1f ms (%.2f s)\n", totalMs, totalMs / 1000.0);
std::printf("\n step wall-clock share status\n");
for (const auto& s : steps) {
double share = totalMs > 0 ? 100.0 * s.ms / totalMs : 0.0;
std::printf(" %s %9.1f ms %5.1f%% %s (rc=%d)\n",
s.name, s.ms, share,
s.rc == 0 ? "ok" : "FAIL", s.rc);
}
return 0;
}
int handleBenchValidateProject(int& i, int argc, char** argv) {
// Time --validate-project per zone. Reports avg/min/max
// latency so users can spot zones that are unusually slow
// to validate (huge WHM/WOC pairs, lots of WOM batches).
std::string projectDir = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
namespace fs = std::filesystem;
if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) {
std::fprintf(stderr,
"bench-validate-project: %s is not a directory\n",
projectDir.c_str());
return 1;
}
std::vector<std::string> zones;
for (const auto& entry : fs::directory_iterator(projectDir)) {
if (!entry.is_directory()) continue;
if (!fs::exists(entry.path() / "zone.json")) continue;
zones.push_back(entry.path().string());
}
std::sort(zones.begin(), zones.end());
// Per-zone timing pass — same validator walk as
// --validate-project but timing each zone separately.
struct Timing { std::string name; double ms; int files; };
std::vector<Timing> timings;
double totalMs = 0;
for (const auto& zoneDir : zones) {
auto t0 = std::chrono::steady_clock::now();
int files = 0;
std::error_code ec;
for (const auto& entry : fs::recursive_directory_iterator(zoneDir, ec)) {
if (!entry.is_regular_file()) continue;
std::string ext = entry.path().extension().string();
std::string base = entry.path().string();
base = base.substr(0, base.size() - ext.size());
if (ext == ".wom") {
files++;
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
(void)validateWomErrors(wom);
} else if (ext == ".wob") {
files++;
auto wob = wowee::pipeline::WoweeBuildingLoader::load(base);
(void)validateWobErrors(wob);
} else if (ext == ".woc") {
files++;
auto woc = wowee::pipeline::WoweeCollisionBuilder::load(entry.path().string());
(void)validateWocErrors(woc);
} else if (ext == ".whm") {
files++;
wowee::pipeline::ADTTerrain terrain;
wowee::pipeline::WoweeTerrainLoader::load(base, terrain);
(void)validateWhmErrors(terrain);
}
}
auto t1 = std::chrono::steady_clock::now();
double ms = std::chrono::duration<double, std::milli>(t1 - t0).count();
totalMs += ms;
timings.push_back({fs::path(zoneDir).filename().string(), ms, files});
}
// Compute aggregate stats.
double avgMs = !timings.empty() ? totalMs / timings.size() : 0.0;
double minMs = 1e30, maxMs = 0;
std::string slowestZone;
for (const auto& t : timings) {
if (t.ms < minMs) minMs = t.ms;
if (t.ms > maxMs) { maxMs = t.ms; slowestZone = t.name; }
}
if (timings.empty()) { minMs = 0; maxMs = 0; }
if (jsonOut) {
nlohmann::json j;
j["projectDir"] = projectDir;
j["totalMs"] = totalMs;
j["zoneCount"] = timings.size();
j["avgMs"] = avgMs;
j["minMs"] = minMs;
j["maxMs"] = maxMs;
j["slowestZone"] = slowestZone;
nlohmann::json arr = nlohmann::json::array();
for (const auto& t : timings) {
arr.push_back({{"zone", t.name}, {"ms", t.ms},
{"files", t.files}});
}
j["perZone"] = arr;
std::printf("%s\n", j.dump(2).c_str());
return 0;
}
std::printf("Bench validate: %s\n", projectDir.c_str());
std::printf(" zones : %zu\n", timings.size());
std::printf(" total : %.2f ms\n", totalMs);
std::printf(" per zone : avg=%.2f min=%.2f max=%.2f ms\n",
avgMs, minMs, maxMs);
if (!slowestZone.empty()) {
std::printf(" slowest : %s (%.2f ms)\n",
slowestZone.c_str(), maxMs);
}
std::printf("\n Per-zone timings:\n");
std::printf(" zone ms files ms/file\n");
for (const auto& t : timings) {
double mspf = t.files > 0 ? t.ms / t.files : 0.0;
std::printf(" %-26s %7.2f %5d %6.3f\n",
t.name.substr(0, 26).c_str(), t.ms, t.files, mspf);
}
return 0;
}
} // namespace
bool handleFormatValidate(int& i, int argc, char** argv, int& outRc) {
if (std::strcmp(argv[i], "--validate") == 0 && i + 1 < argc) {
outRc = handleValidate(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--validate-wom") == 0 && i + 1 < argc) {
outRc = handleValidateWom(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--validate-wob") == 0 && i + 1 < argc) {
outRc = handleValidateWob(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--validate-woc") == 0 && i + 1 < argc) {
outRc = handleValidateWoc(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--validate-whm") == 0 && i + 1 < argc) {
outRc = handleValidateWhm(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--validate-all") == 0 && i + 1 < argc) {
outRc = handleValidateAll(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--validate-project") == 0 && i + 1 < argc) {
outRc = handleValidateProject(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--validate-project-open-only") == 0 && i + 1 < argc) {
outRc = handleValidateProjectOpenOnly(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--audit-project") == 0 && i + 1 < argc) {
outRc = handleAuditProject(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--bench-audit-project") == 0 && i + 1 < argc) {
outRc = handleBenchAuditProject(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--bench-validate-project") == 0 && i + 1 < argc) {
outRc = handleBenchValidateProject(i, argc, argv); return true;
}
return false;
}
} // namespace cli
} // namespace editor
} // namespace wowee