From b87ece2d5b2a3b5c6d6e4a3a30cef4dec44c3054 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 01:57:37 -0700 Subject: [PATCH] refactor(editor): extract format validation into cli_format_validate.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CMakeLists.txt | 1 + tools/editor/cli_format_validate.cpp | 1146 ++++++++++++++++++++++++++ tools/editor/cli_format_validate.hpp | 20 + tools/editor/main.cpp | 1058 +----------------------- 4 files changed, 1171 insertions(+), 1054 deletions(-) create mode 100644 tools/editor/cli_format_validate.cpp create mode 100644 tools/editor/cli_format_validate.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0bf52181..98d28094 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1310,6 +1310,7 @@ add_executable(wowee_editor tools/editor/cli_mesh_io.cpp tools/editor/cli_mesh_edit.cpp tools/editor/cli_wom_info.cpp + tools/editor/cli_format_validate.cpp tools/editor/editor_app.cpp tools/editor/editor_camera.cpp tools/editor/editor_viewport.cpp diff --git a/tools/editor/cli_format_validate.cpp b/tools/editor/cli_format_validate.cpp new file mode 100644 index 00000000..9cd62d5d --- /dev/null +++ b/tools/editor/cli_format_validate.cpp @@ -0,0 +1,1146 @@ +#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 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::vector validateWomErrors( + const wowee::pipeline::WoweeModel& wom) { + std::vector 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(wom.bones.size())) { + errors.push_back("bone " + std::to_string(b) + + " parent=" + std::to_string(p) + + " out of range"); + } else if (p >= static_cast(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 validateWobErrors( + const wowee::pipeline::WoweeBuilding& bld) { + std::vector 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(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 validateWocErrors( + const wowee::pipeline::WoweeCollision& woc) { + std::vector 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 validateWhmErrors( + const wowee::pipeline::ADTTerrain& terrain) { + std::vector 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>> failures; + auto recordFailure = [&](const std::string& path, + const std::vector& 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 + // 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 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 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 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(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 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 propExt = { + ".m2", ".skin", ".wmo", ".blp", ".dbc", + }; + std::map byExt; + std::vector 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 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 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(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 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 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(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 diff --git a/tools/editor/cli_format_validate.hpp b/tools/editor/cli_format_validate.hpp new file mode 100644 index 00000000..9f786d81 --- /dev/null +++ b/tools/editor/cli_format_validate.hpp @@ -0,0 +1,20 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +// Dispatch the open-format validation + project audit handlers: +// --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 +// +// Returns true if matched; outRc holds the exit code. +bool handleFormatValidate(int& i, int argc, char** argv, int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index c81a5e60..7d1f2edd 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -11,6 +11,7 @@ #include "cli_mesh_io.hpp" #include "cli_mesh_edit.hpp" #include "cli_wom_info.hpp" +#include "cli_format_validate.hpp" #include "content_pack.hpp" #include "npc_spawner.hpp" #include "object_placer.hpp" @@ -144,372 +145,6 @@ static std::string hex(const uint8_t* data, size_t len) { } } // namespace wowee_sha256 -static std::vector validateWomErrors( - const wowee::pipeline::WoweeModel& wom) { - std::vector 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(wom.bones.size())) { - errors.push_back("bone " + std::to_string(b) + - " parent=" + std::to_string(p) + - " out of range"); - } else if (p >= static_cast(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; -} - -static std::vector validateWobErrors( - const wowee::pipeline::WoweeBuilding& bld) { - std::vector 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(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; -} - -static std::vector validateWocErrors( - const wowee::pipeline::WoweeCollision& woc) { - std::vector 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; -} - -static std::vector validateWhmErrors( - const wowee::pipeline::ADTTerrain& terrain) { - std::vector 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 main(int argc, char* argv[]) { @@ -818,6 +453,9 @@ int main(int argc, char* argv[]) { if (wowee::editor::cli::handleWomInfo(i, argc, argv, outRc)) { return outRc; } + if (wowee::editor::cli::handleFormatValidate(i, argc, argv, outRc)) { + return outRc; + } } if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { dataPath = argv[++i]; @@ -6928,694 +6566,6 @@ int main(int argc, char* argv[]) { quests.size(), chainEdges, brokenEdges); std::printf(" next: dot -Tpng %s -o quests.png\n", outPath.c_str()); return 0; - } else if (std::strcmp(argv[i], "--validate") == 0 && i + 1 < argc) { - 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; - } else if (std::strcmp(argv[i], "--validate-wom") == 0 && i + 1 < argc) { - // 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; - } else if (std::strcmp(argv[i], "--validate-wob") == 0 && i + 1 < argc) { - // 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; - } else if (std::strcmp(argv[i], "--validate-woc") == 0 && i + 1 < argc) { - // 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; - } else if (std::strcmp(argv[i], "--validate-whm") == 0 && i + 1 < argc) { - // 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; - } else if (std::strcmp(argv[i], "--validate-all") == 0 && i + 1 < argc) { - // 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>> failures; - auto recordFailure = [&](const std::string& path, - const std::vector& 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; - } else if (std::strcmp(argv[i], "--validate-project") == 0 && i + 1 < argc) { - // Project-level validate. Walks every zone in - // 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 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 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 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(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; - } else if (std::strcmp(argv[i], "--validate-project-open-only") == 0 && i + 1 < argc) { - // Release gate. Walks every file in 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 propExt = { - ".m2", ".skin", ".wmo", ".blp", ".dbc", - }; - std::map byExt; - std::vector 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; - } else if (std::strcmp(argv[i], "--audit-project") == 0 && i + 1 < argc) { - // 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 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; - } else if (std::strcmp(argv[i], "--bench-audit-project") == 0 && i + 1 < argc) { - // 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 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(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; - } else if (std::strcmp(argv[i], "--bench-validate-project") == 0 && i + 1 < argc) { - // 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 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 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(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; } else if (std::strcmp(argv[i], "--bench-bake-project") == 0 && i + 1 < argc) { // Time WHM/WOT load (the dominant cost in --bake-zone-glb/obj/ // stl) per zone. The actual write side adds ~constant cost