From 6c3f5cb33f285fbe898c6cc74dc2bf51dd85d4cd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 14:34:22 -0700 Subject: [PATCH] feat(editor): add --validate-wom static sanity checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a validator for .wom files mirroring --validate-wol / --validate-wow. Catches malformed hand-built or import- corrupted models before they reach the renderer (where bad data usually crashes or renders blank with no diagnostic). Hard errors (exit non-zero): • version not in 1..3 • empty vertex / index list • index count not a multiple of 3 • triangle indices referencing out-of-range vertices • boneIndices referencing out-of-range bones • parentBone referencing out-of-range bones • inverted AABB (boundMin > boundMax on any axis) • WOM3 batch.textureIndex out of range • WOM3 batch range past end of index buffer • animation has wrong number of bone tracks Warnings (informational, exit zero): • boneWeight slots not summing to 0 or 255 • triangles uncovered or double-covered by WOM3 batches • boundRadius <= 0 (frustum-cull failure) Adds 451st kArgRequired entry. Smoke test: 0/0/0 errors on all generated procedural primitives. Both text and --json output supported, mirroring the other validators. --- tools/editor/cli_arg_required.cpp | 1 + tools/editor/cli_help.cpp | 2 + tools/editor/cli_wom_info.cpp | 189 ++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 1f1ec589..c3cefc57 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -20,6 +20,7 @@ const char* const kArgRequired[] = { "--export-wol-json", "--import-wol-json", "--export-wow-json", "--import-wow-json", "--info-wow", "--validate-wow", + "--validate-wom", "--gen-weather-temperate", "--gen-weather-arctic", "--gen-weather-desert", "--gen-weather-stormy", "--gen-zone-atmosphere", diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index b6fe5a9a..fb22e7ec 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -815,6 +815,8 @@ void printUsage(const char* argv0) { std::printf(" Print WOW weather entries (zone + per-state type / intensity / weight / duration) and exit\n"); std::printf(" --validate-wow [--json]\n"); std::printf(" Walk every WOW entry; check typeId / intensity bounds [0,1] / weight > 0 / duration min ≤ max\n"); + std::printf(" --validate-wom [--json]\n"); + std::printf(" Static sanity checks on .wom: index range, bone refs, bound box, batch coverage, animation track count\n"); std::printf(" --gen-weather-temperate [zoneName]\n"); std::printf(" Emit .wow weather schedule: clear-dominant + occasional rain + fog (forest / grassland)\n"); std::printf(" --gen-weather-arctic [zoneName]\n"); diff --git a/tools/editor/cli_wom_info.cpp b/tools/editor/cli_wom_info.cpp index 47de11b4..9b4230f6 100644 --- a/tools/editor/cli_wom_info.cpp +++ b/tools/editor/cli_wom_info.cpp @@ -540,6 +540,192 @@ int handleInfoBones(int& i, int argc, char** argv) { return 0; } +int handleValidateWom(int& i, int argc, char** argv) { + // Static sanity checks on a .wom: catches malformed + // hand-built or import-corrupted models before they reach + // the renderer (where errors usually crash or render blank). + // Mirrors --validate-wol / --validate-wow. Reports each + // failed check with details and exits non-zero on any + // failure; clean models print a single OK line. + 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, "validate-wom: WOM not found: %s.wom\n", + base.c_str()); + return 1; + } + auto wom = wowee::pipeline::WoweeModelLoader::load(base); + std::vector errors; + std::vector warnings; + + // 1) version field + if (wom.version < 1 || wom.version > 3) { + errors.push_back("version " + std::to_string(wom.version) + + " not in supported range 1-3"); + } + // 2) non-empty geometry + if (wom.vertices.empty()) errors.push_back("vertex list is empty"); + if (wom.indices.empty()) errors.push_back("index list is empty"); + if (wom.indices.size() % 3 != 0) { + errors.push_back("index count " + + std::to_string(wom.indices.size()) + + " is not a multiple of 3 (not a triangle list)"); + } + // 3) all indices < vertex count + uint32_t vCount = static_cast(wom.vertices.size()); + size_t oobIdx = 0; + for (uint32_t idx : wom.indices) { + if (idx >= vCount) { oobIdx++; } + } + if (oobIdx > 0) { + errors.push_back(std::to_string(oobIdx) + + " triangle indices reference out-of-range vertices"); + } + // 4) bone refs (only meaningful when bones exist) + if (!wom.bones.empty()) { + size_t oobBoneIdx = 0; + size_t badWeightSum = 0; + for (const auto& v : wom.vertices) { + int sum = 0; + for (int k = 0; k < 4; ++k) { + if (v.boneWeights[k] > 0 && + v.boneIndices[k] >= wom.bones.size()) { + oobBoneIdx++; + } + sum += v.boneWeights[k]; + } + // Allow either 0 (no skinning) or 255 (full skinning, + // possibly split across slots). Anything else is a + // weight-table mistake. + if (sum != 0 && sum != 255) { + badWeightSum++; + } + } + if (oobBoneIdx > 0) { + errors.push_back(std::to_string(oobBoneIdx) + + " vertex bone-index slots reference out-of-range bones"); + } + if (badWeightSum > 0) { + warnings.push_back(std::to_string(badWeightSum) + + " vertices have boneWeights summing to neither 0 nor 255"); + } + // parentBone < bones.size() (or -1 for root) + size_t oobParent = 0; + for (const auto& b : wom.bones) { + if (b.parentBone >= 0 && + b.parentBone >= static_cast(wom.bones.size())) { + oobParent++; + } + } + if (oobParent > 0) { + errors.push_back(std::to_string(oobParent) + + " bones reference out-of-range parent bones"); + } + } + // 5) WOM3 batch coverage: union of all batch ranges should + // equal [0, indices.size()) without gaps or overlaps, and + // each batch.textureIndex must be a valid index. + if (wom.hasBatches()) { + size_t oobTex = 0, oobRange = 0; + for (const auto& b : wom.batches) { + if (!wom.texturePaths.empty() && + b.textureIndex >= wom.texturePaths.size()) { + oobTex++; + } + if (static_cast(b.indexStart) + b.indexCount > + wom.indices.size()) { + oobRange++; + } + } + if (oobTex > 0) { + errors.push_back(std::to_string(oobTex) + + " batch.textureIndex values out of range"); + } + if (oobRange > 0) { + errors.push_back(std::to_string(oobRange) + + " batches index past end of index buffer"); + } + // Coverage check via bytemap of triangles. + size_t triCount = wom.indices.size() / 3; + std::vector covered(triCount, 0); + for (const auto& b : wom.batches) { + uint32_t tStart = b.indexStart / 3; + uint32_t tEnd = (b.indexStart + b.indexCount) / 3; + for (uint32_t t = tStart; t < tEnd && t < triCount; ++t) + covered[t]++; + } + size_t uncovered = 0, overlapped = 0; + for (auto c : covered) { + if (c == 0) uncovered++; + else if (c > 1) overlapped++; + } + if (uncovered > 0) { + warnings.push_back(std::to_string(uncovered) + + " triangles not covered by any batch"); + } + if (overlapped > 0) { + warnings.push_back(std::to_string(overlapped) + + " triangles covered by multiple batches"); + } + } + // 6) bounds sanity + if (wom.boundRadius <= 0) { + warnings.push_back("boundRadius <= 0 (model will fail frustum culling)"); + } + 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 (inverted AABB)"); + } + // 7) animation sanity (WOM2/3): per-bone keyframe arrays + // must have one entry per bone in the model. + for (size_t a = 0; a < wom.animations.size(); ++a) { + const auto& anim = wom.animations[a]; + if (!wom.bones.empty() && + anim.boneKeyframes.size() != wom.bones.size()) { + errors.push_back("animation " + std::to_string(a) + + " (id=" + std::to_string(anim.id) + + ") has " + std::to_string(anim.boneKeyframes.size()) + + " bone tracks but model has " + + std::to_string(wom.bones.size()) + " bones"); + } + } + + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wom"] = base + ".wom"; + j["ok"] = ok; + j["errors"] = errors; + j["warnings"] = warnings; + std::printf("%s\n", j.dump(2).c_str()); + return ok ? 0 : 1; + } + std::printf("validate-wom: %s.wom\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu vertices, %zu triangles, %zu batches, %zu bones, %zu animations\n", + wom.vertices.size(), wom.indices.size() / 3, + wom.batches.size(), wom.bones.size(), + wom.animations.size()); + return 0; + } + if (!warnings.empty()) { + std::printf(" warnings (%zu):\n", warnings.size()); + for (const auto& w : warnings) + std::printf(" - %s\n", w.c_str()); + } + if (!errors.empty()) { + std::printf(" ERRORS (%zu):\n", errors.size()); + for (const auto& e : errors) + std::printf(" - %s\n", e.c_str()); + } + return ok ? 0 : 1; +} + int handleExportBonesDot(int& i, int argc, char** argv) { // Render WOM bone hierarchy as Graphviz DOT. Mirrors // --export-quest-graph for skeleton trees: trying to read @@ -627,6 +813,9 @@ bool handleWomInfo(int& i, int argc, char** argv, int& outRc) { if (std::strcmp(argv[i], "--export-bones-dot") == 0 && i + 1 < argc) { outRc = handleExportBonesDot(i, argc, argv); return true; } + if (std::strcmp(argv[i], "--validate-wom") == 0 && i + 1 < argc) { + outRc = handleValidateWom(i, argc, argv); return true; + } return false; }