#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