From a1831430029afc0e2cd0e6b928e14ca166fe8ebd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 6 May 2026 13:27:18 -0700 Subject: [PATCH] feat(editor): add --check-zone-refs cross-reference validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches dangling references that --validate (which only checks open-format file presence) doesn't: wowee_editor --check-zone-refs custom_zones/MyZone Zone refs: custom_zones/MyZone objects checked : 12 (2 missing) quests checked : 5 (1 bad NPC refs) FAILED — 3 issue(s): - object[3] missing: World/Doodad/Tree.m2 - object[7] missing: World/Building/Inn.wmo - quest[1] 'Hunt' giver 999 not in creatures.json Two checks: 1. Every objects.json model path resolves on disk in any of the conventional roots (zone-local, output/, custom_zones/, Data/), trying both the open (.wom/.wob) and proprietary (.m2/.wmo) variants, with a case-fold fallback for case-sensitive Linux filesystems where extracted assets are usually lowercased. 2. Every quest's questGiverNpcId / turnInNpcId references a creature in creatures.json (when the zone has any). Filter: only flag IDs < 100000 since production wires upstream NPCs with 6-digit IDs that legitimately reference outside content. Errors capped at 30 listed (with full counts in the summary) so a misconfigured zone with 500 broken refs doesn't drown the output. Exit 1 on any issue so CI can gate. Verified: zone with 1 creature, 2 dead model refs, 1 bad NPC giver — all 3 issues reported with precise indices and exit 1. --- tools/editor/main.cpp | 146 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index 9628e162..56882d95 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -418,6 +419,8 @@ static void printUsage(const char* argv0) { std::printf(" Aggregate counts across every zone in \n"); std::printf(" --list-zone-deps [--json]\n"); std::printf(" List external M2/WMO model paths a zone references (objects + WOB doodads)\n"); + std::printf(" --check-zone-refs [--json]\n"); + std::printf(" Verify every referenced model/quest NPC actually exists; exit 1 on missing refs\n"); std::printf(" --for-each-zone -- \n"); std::printf(" Run for every zone in ; '{}' in cmd is replaced with the zone path\n"); std::printf(" --scaffold-zone [tx ty] Create a blank zone in custom_zones// and exit\n"); @@ -582,6 +585,7 @@ int main(int argc, char* argv[]) { "--export-zone-summary-md", "--scaffold-zone", "--add-tile", "--remove-tile", "--list-tiles", "--for-each-zone", "--zone-stats", "--list-zone-deps", + "--check-zone-refs", "--add-creature", "--add-object", "--add-quest", "--add-quest-objective", "--add-quest-reward-item", "--set-quest-reward", "--remove-quest-objective", "--clone-quest", "--clone-creature", @@ -6073,6 +6077,148 @@ int main(int argc, char* argv[]) { emit("Direct WMO placements", directWMO); emit("WOB doodad M2 refs", doodadM2); return 0; + } else if (std::strcmp(argv[i], "--check-zone-refs") == 0 && i + 1 < argc) { + // Cross-reference checker: every model path in objects.json + // must resolve as either an open WOM/WOB sidecar or a + // proprietary M2/WMO; every quest's giver/turnIn NPC ID must + // appear in creatures.json (when the zone has creatures). + // Catches dangling references that --validate doesn't, since + // --validate only checks open-format file presence. + std::string zoneDir = argv[++i]; + bool jsonOut = (i + 1 < argc && + std::strcmp(argv[i + 1], "--json") == 0); + if (jsonOut) i++; + namespace fs = std::filesystem; + if (!fs::exists(zoneDir + "/zone.json")) { + std::fprintf(stderr, + "check-zone-refs: %s has no zone.json\n", zoneDir.c_str()); + return 1; + } + // Try to find a model on disk in any of the conventional + // locations (zone-local, output/, custom_zones/, Data/). + // Strips extension and tries each open + proprietary variant. + auto stripExt = [](const std::string& p, const char* ext) { + size_t n = std::strlen(ext); + if (p.size() >= n) { + std::string tail = p.substr(p.size() - n); + std::string lower = tail; + for (auto& c : lower) c = std::tolower(static_cast(c)); + if (lower == ext) return p.substr(0, p.size() - n); + } + return p; + }; + auto modelExists = [&](const std::string& path, bool isWMO) { + std::string base; + std::vector exts; + if (isWMO) { + base = stripExt(path, ".wmo"); + exts = {".wob", ".wmo"}; + } else { + base = stripExt(path, ".m2"); + exts = {".wom", ".m2"}; + } + std::vector roots = { + "", zoneDir + "/", "output/", "custom_zones/", "Data/" + }; + for (const auto& root : roots) { + for (const auto& ext : exts) { + if (fs::exists(root + base + ext)) return true; + // Case-fold fallback for case-sensitive filesystems + // (designers usually type Mixed Case but Linux + // stores asset paths lowercase after extraction). + std::string lower = base + ext; + for (auto& c : lower) c = std::tolower(static_cast(c)); + if (fs::exists(root + lower)) return true; + } + } + return false; + }; + std::vector errors; + // Object placements -> models on disk + wowee::editor::ObjectPlacer op; + int objectsChecked = 0, objectsMissing = 0; + if (op.loadFromFile(zoneDir + "/objects.json")) { + for (size_t k = 0; k < op.getObjects().size(); ++k) { + const auto& o = op.getObjects()[k]; + objectsChecked++; + bool isWMO = (o.type == wowee::editor::PlaceableType::WMO); + if (!modelExists(o.path, isWMO)) { + objectsMissing++; + if (errors.size() < 30) { + errors.push_back("object[" + std::to_string(k) + + "] missing: " + o.path); + } + } + } + } + // Quest NPCs -> creatures.json IDs (only when creatures exist; + // otherwise NPC IDs may legitimately reference upstream content + // outside the zone). + wowee::editor::NpcSpawner sp; + wowee::editor::QuestEditor qe; + int questsChecked = 0, questsMissing = 0; + bool hasCreatures = sp.loadFromFile(zoneDir + "/creatures.json"); + std::unordered_set creatureIds; + if (hasCreatures) { + for (const auto& s : sp.getSpawns()) creatureIds.insert(s.id); + } + if (qe.loadFromFile(zoneDir + "/quests.json") && hasCreatures) { + for (size_t k = 0; k < qe.getQuests().size(); ++k) { + const auto& q = qe.getQuests()[k]; + questsChecked++; + bool localGiver = (q.questGiverNpcId != 0 && + creatureIds.count(q.questGiverNpcId) == 0); + bool localTurn = (q.turnInNpcId != 0 && + q.turnInNpcId != q.questGiverNpcId && + creatureIds.count(q.turnInNpcId) == 0); + // Only flag IDs that look 'small' (likely zone-local). + // Production uses 6-digit IDs that reference upstream + // content; designers wire those in deliberately. + if (localGiver && q.questGiverNpcId < 100000) { + questsMissing++; + if (errors.size() < 30) { + errors.push_back("quest[" + std::to_string(k) + "] '" + + q.title + "' giver " + + std::to_string(q.questGiverNpcId) + + " not in creatures.json"); + } + } + if (localTurn && q.turnInNpcId < 100000) { + questsMissing++; + if (errors.size() < 30) { + errors.push_back("quest[" + std::to_string(k) + "] '" + + q.title + "' turn-in " + + std::to_string(q.turnInNpcId) + + " not in creatures.json"); + } + } + } + } + int totalErrors = objectsMissing + questsMissing; + if (jsonOut) { + nlohmann::json j; + j["zone"] = zoneDir; + j["objectsChecked"] = objectsChecked; + j["objectsMissing"] = objectsMissing; + j["questsChecked"] = questsChecked; + j["questsMissing"] = questsMissing; + j["errors"] = errors; + j["passed"] = (totalErrors == 0); + std::printf("%s\n", j.dump(2).c_str()); + return totalErrors == 0 ? 0 : 1; + } + std::printf("Zone refs: %s\n", zoneDir.c_str()); + std::printf(" objects checked : %d (%d missing)\n", + objectsChecked, objectsMissing); + std::printf(" quests checked : %d (%d bad NPC refs)\n", + questsChecked, questsMissing); + if (totalErrors == 0) { + std::printf(" PASSED\n"); + return 0; + } + std::printf(" FAILED — %d issue(s):\n", totalErrors); + for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); + return 1; } else if (std::strcmp(argv[i], "--for-each-zone") == 0 && i + 1 < argc) { // Batch runner: enumerates zones in and runs the // command after '--' for each one. '{}' in the command is