From 83c7fd9bee3d72b8ebcb48aeddc7358ac01cd53c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 05:05:22 -0700 Subject: [PATCH] refactor(editor): extract 8 spawn/snap handlers into cli_spawn_audit.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the NPC spawn / object placer audit + ground-snap handlers out of main.cpp: --snap-zone-to-ground --snap-project-to-ground --audit-zone-spawns --audit-project-spawns --list-zone-spawns --list-project-spawns --diff-zone-spawns --info-spawn All operate on creatures.json + objects.json sidecars and the WHM terrain heightfield via WoweeTerrainLoader. main.cpp drops 14,628 → 13,887 lines (-741). Behavior verified by re-running --audit-zone-spawns on a test zone (PASSED with 0 issues, same as before). --- CMakeLists.txt | 1 + tools/editor/cli_spawn_audit.cpp | 822 +++++++++++++++++++++++++++++++ tools/editor/cli_spawn_audit.hpp | 22 + tools/editor/main.cpp | 749 +--------------------------- 4 files changed, 849 insertions(+), 745 deletions(-) create mode 100644 tools/editor/cli_spawn_audit.cpp create mode 100644 tools/editor/cli_spawn_audit.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f48aef6..c2cb22c0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1318,6 +1318,7 @@ add_executable(wowee_editor tools/editor/cli_zone_info.cpp tools/editor/cli_data_tree.cpp tools/editor/cli_diff.cpp + tools/editor/cli_spawn_audit.cpp tools/editor/editor_app.cpp tools/editor/editor_camera.cpp tools/editor/editor_viewport.cpp diff --git a/tools/editor/cli_spawn_audit.cpp b/tools/editor/cli_spawn_audit.cpp new file mode 100644 index 00000000..02fdc90a --- /dev/null +++ b/tools/editor/cli_spawn_audit.cpp @@ -0,0 +1,822 @@ +#include "cli_spawn_audit.hpp" + +#include "npc_spawner.hpp" +#include "object_placer.hpp" +#include "zone_manifest.hpp" +#include "pipeline/wowee_terrain_loader.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +int handleSnapZoneToGround(int& i, int argc, char** argv) { + // Walk every creature + object in a zone and snap their Z + // to the actual terrain height. Useful after terrain edits + // or after --random-populate-zone if the spawn baseZ + // doesn't match the carved terrain. + // + // Height lookup walks the loaded WHM tiles and finds the + // chunk containing each spawn's (x, y), then uses the + // chunk's average heightmap height + base. + std::string zoneDir = argv[++i]; + namespace fs = std::filesystem; + std::string manifestPath = zoneDir + "/zone.json"; + if (!fs::exists(manifestPath)) { + std::fprintf(stderr, + "snap-zone-to-ground: %s has no zone.json\n", + zoneDir.c_str()); + return 1; + } + wowee::editor::ZoneManifest zm; + if (!zm.load(manifestPath)) { + std::fprintf(stderr, + "snap-zone-to-ground: failed to parse %s\n", + manifestPath.c_str()); + return 1; + } + // Load all tiles into a flat map keyed by (tx, ty). + struct LoadedTile { + wowee::pipeline::ADTTerrain terrain; + int tx, ty; + }; + std::vector tiles; + for (const auto& [tx, ty] : zm.tiles) { + std::string base = zoneDir + "/" + zm.mapName + "_" + + std::to_string(tx) + "_" + std::to_string(ty); + if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) continue; + LoadedTile lt; + lt.tx = tx; lt.ty = ty; + if (wowee::pipeline::WoweeTerrainLoader::load(base, lt.terrain)) { + tiles.push_back(std::move(lt)); + } + } + if (tiles.empty()) { + std::fprintf(stderr, + "snap-zone-to-ground: no .whm tiles loaded\n"); + return 1; + } + // Compute terrain height at world (x, y) by finding the + // chunk that contains it and averaging its heightmap. Each + // chunk is 33.33y across; chunk position[1]=wowX origin, + // [0]=wowY origin. + constexpr float kChunkSize = 33.33333f; + auto sampleHeight = [&](float wx, float wy) -> float { + for (const auto& lt : tiles) { + for (const auto& chunk : lt.terrain.chunks) { + if (!chunk.heightMap.isLoaded()) continue; + float cx0 = chunk.position[1]; + float cy0 = chunk.position[0]; + if (wx < cx0 || wx >= cx0 + kChunkSize) continue; + if (wy < cy0 || wy >= cy0 + kChunkSize) continue; + // Use average heightmap height to dodge the + // need for full bilinear sampling. Good enough + // for spawn placement; finer interpolation is + // a future optimization. + float sum = 0; int n = 0; + for (float h : chunk.heightMap.heights) { + if (std::isfinite(h)) { sum += h; n++; } + } + if (n == 0) return chunk.position[2]; + return chunk.position[2] + sum / n; + } + } + return zm.baseHeight; // outside any loaded chunk + }; + int snappedC = 0, snappedO = 0; + // Creatures. + wowee::editor::NpcSpawner spawner; + std::string cpath = zoneDir + "/creatures.json"; + if (fs::exists(cpath) && spawner.loadFromFile(cpath)) { + auto& spawns = spawner.getSpawns(); + for (auto& s : spawns) { + s.position.z = sampleHeight(s.position.x, s.position.y); + snappedC++; + } + if (snappedC > 0) spawner.saveToFile(cpath); + } + // Objects. + wowee::editor::ObjectPlacer placer; + std::string opath = zoneDir + "/objects.json"; + if (fs::exists(opath) && placer.loadFromFile(opath)) { + auto& objs = placer.getObjects(); + for (auto& o : objs) { + o.position.z = sampleHeight(o.position.x, o.position.y); + snappedO++; + } + if (snappedO > 0) placer.saveToFile(opath); + } + std::printf("snap-zone-to-ground: %s\n", zoneDir.c_str()); + std::printf(" tiles loaded : %zu\n", tiles.size()); + std::printf(" creatures : %d snapped\n", snappedC); + std::printf(" objects : %d snapped\n", snappedO); + return 0; +} + +int handleAuditZoneSpawns(int& i, int argc, char** argv) { + // Non-destructive companion to --snap-zone-to-ground. + // Loads the zone's terrain, walks every creature + object, + // and flags any whose Z is more than yards + // off from the sampled terrain height. Useful for + // surveying placement issues before deciding whether to + // run --snap-zone-to-ground (which would silently rewrite + // every spawn). + std::string zoneDir = argv[++i]; + float threshold = 5.0f; + if (i + 2 < argc && std::strcmp(argv[i + 1], "--threshold") == 0) { + try { threshold = std::stof(argv[i + 2]); i += 2; } + catch (...) {} + } + namespace fs = std::filesystem; + std::string manifestPath = zoneDir + "/zone.json"; + if (!fs::exists(manifestPath)) { + std::fprintf(stderr, + "audit-zone-spawns: %s has no zone.json\n", + zoneDir.c_str()); + return 1; + } + wowee::editor::ZoneManifest zm; + if (!zm.load(manifestPath)) { + std::fprintf(stderr, + "audit-zone-spawns: failed to parse %s\n", + manifestPath.c_str()); + return 1; + } + // Same chunk-average sampler as --snap-zone-to-ground. + // Returning baseHeight when no chunk hits = "no terrain + // data here", so flag those too via the threshold check. + struct LoadedTile { + wowee::pipeline::ADTTerrain terrain; + }; + std::vector tiles; + for (const auto& [tx, ty] : zm.tiles) { + std::string base = zoneDir + "/" + zm.mapName + "_" + + std::to_string(tx) + "_" + std::to_string(ty); + if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) continue; + LoadedTile lt; + if (wowee::pipeline::WoweeTerrainLoader::load(base, lt.terrain)) { + tiles.push_back(std::move(lt)); + } + } + constexpr float kChunkSize = 33.33333f; + auto sampleHeight = [&](float wx, float wy) -> float { + for (const auto& lt : tiles) { + for (const auto& chunk : lt.terrain.chunks) { + if (!chunk.heightMap.isLoaded()) continue; + float cx0 = chunk.position[1]; + float cy0 = chunk.position[0]; + if (wx < cx0 || wx >= cx0 + kChunkSize) continue; + if (wy < cy0 || wy >= cy0 + kChunkSize) continue; + float sum = 0; int n = 0; + for (float h : chunk.heightMap.heights) { + if (std::isfinite(h)) { sum += h; n++; } + } + if (n == 0) return chunk.position[2]; + return chunk.position[2] + sum / n; + } + } + return zm.baseHeight; + }; + struct Issue { std::string kind; std::string name; + float spawnZ, terrainZ; }; + std::vector issues; + wowee::editor::NpcSpawner spawner; + if (fs::exists(zoneDir + "/creatures.json") && + spawner.loadFromFile(zoneDir + "/creatures.json")) { + for (const auto& s : spawner.getSpawns()) { + float th = sampleHeight(s.position.x, s.position.y); + if (std::fabs(s.position.z - th) > threshold) { + issues.push_back({"creature", s.name, + s.position.z, th}); + } + } + } + wowee::editor::ObjectPlacer placer; + if (fs::exists(zoneDir + "/objects.json") && + placer.loadFromFile(zoneDir + "/objects.json")) { + for (const auto& o : placer.getObjects()) { + float th = sampleHeight(o.position.x, o.position.y); + if (std::fabs(o.position.z - th) > threshold) { + issues.push_back({"object", o.path, + o.position.z, th}); + } + } + } + std::printf("audit-zone-spawns: %s\n", zoneDir.c_str()); + std::printf(" threshold : %.1f yards\n", threshold); + std::printf(" creatures : %zu\n", spawner.spawnCount()); + std::printf(" objects : %zu\n", placer.getObjects().size()); + std::printf(" issues : %zu\n", issues.size()); + if (issues.empty()) { + std::printf("\n PASSED — every spawn is within %.1f y of the terrain\n", + threshold); + return 0; + } + std::printf("\n Flagged spawns (delta = spawnZ - terrainZ):\n"); + std::printf(" kind delta spawnZ terrainZ name\n"); + for (const auto& iss : issues) { + float delta = iss.spawnZ - iss.terrainZ; + std::printf(" %-8s %+6.1f %7.1f %7.1f %s\n", + iss.kind.c_str(), delta, iss.spawnZ, + iss.terrainZ, + iss.name.substr(0, 40).c_str()); + } + std::printf("\n Run --snap-zone-to-ground to fix in bulk.\n"); + return 1; +} + +int handleListZoneSpawns(int& i, int argc, char** argv) { + // Combined creature + object listing. Useful for a quick + // "what's in this zone" survey without running both + // --info-creatures and --info-objects separately. + 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, + "list-zone-spawns: %s has no zone.json\n", zoneDir.c_str()); + return 1; + } + wowee::editor::NpcSpawner spawner; + wowee::editor::ObjectPlacer placer; + spawner.loadFromFile(zoneDir + "/creatures.json"); + placer.loadFromFile(zoneDir + "/objects.json"); + const auto& spawns = spawner.getSpawns(); + const auto& objs = placer.getObjects(); + if (jsonOut) { + nlohmann::json j; + j["zone"] = zoneDir; + j["creatureCount"] = spawns.size(); + j["objectCount"] = objs.size(); + nlohmann::json carr = nlohmann::json::array(); + for (const auto& s : spawns) { + carr.push_back({{"name", s.name}, + {"level", s.level}, + {"x", s.position.x}, + {"y", s.position.y}, + {"z", s.position.z}, + {"hostile", s.hostile}}); + } + j["creatures"] = carr; + nlohmann::json oarr = nlohmann::json::array(); + for (const auto& o : objs) { + oarr.push_back({{"path", o.path}, + {"type", o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"}, + {"x", o.position.x}, + {"y", o.position.y}, + {"z", o.position.z}, + {"scale", o.scale}}); + } + j["objects"] = oarr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("Zone spawns: %s\n", zoneDir.c_str()); + std::printf(" creatures : %zu\n", spawns.size()); + std::printf(" objects : %zu\n", objs.size()); + if (!spawns.empty()) { + std::printf("\n Creatures:\n"); + std::printf(" idx lvl hostile x y z name\n"); + for (size_t k = 0; k < spawns.size(); ++k) { + const auto& s = spawns[k]; + std::printf(" %3zu %3u %-7s %8.1f %8.1f %8.1f %s\n", + k, s.level, s.hostile ? "yes" : "no", + s.position.x, s.position.y, s.position.z, + s.name.c_str()); + } + } + if (!objs.empty()) { + std::printf("\n Objects:\n"); + std::printf(" idx type scale x y z path\n"); + for (size_t k = 0; k < objs.size(); ++k) { + const auto& o = objs[k]; + std::printf(" %3zu %-4s %5.2f %8.1f %8.1f %8.1f %s\n", + k, + o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo", + o.scale, + o.position.x, o.position.y, o.position.z, + o.path.c_str()); + } + } + return 0; +} + +int handleDiffZoneSpawns(int& i, int argc, char** argv) { + // Compare two zones' creatures + objects. Matches by + // (kind, name) — paired entries with mismatched positions + // are reported as "moved" with the delta. Entries that + // exist in only one zone are added/removed. + // + // Useful for "what did the new branch change vs main" + // before merging, or for confirming a copy-zone-items + // produced what was expected. + std::string aDir = argv[++i]; + std::string bDir = argv[++i]; + namespace fs = std::filesystem; + if (!fs::exists(aDir + "/zone.json")) { + std::fprintf(stderr, + "diff-zone-spawns: %s has no zone.json\n", aDir.c_str()); + return 1; + } + if (!fs::exists(bDir + "/zone.json")) { + std::fprintf(stderr, + "diff-zone-spawns: %s has no zone.json\n", bDir.c_str()); + return 1; + } + // Multiset key: kind/name. Position comes along so we can + // report "moved" deltas when a name appears in both with + // different XYZ. + struct Entry { std::string kind, name; glm::vec3 pos; }; + auto load = [&](const std::string& dir) { + std::vector out; + wowee::editor::NpcSpawner spawner; + if (spawner.loadFromFile(dir + "/creatures.json")) { + for (const auto& s : spawner.getSpawns()) { + out.push_back({"creature", s.name, s.position}); + } + } + wowee::editor::ObjectPlacer placer; + if (placer.loadFromFile(dir + "/objects.json")) { + for (const auto& o : placer.getObjects()) { + out.push_back({"object", o.path, o.position}); + } + } + return out; + }; + auto av = load(aDir); + auto bv = load(bDir); + // Sort each side for stable key matching. + auto cmp = [](const Entry& x, const Entry& y) { + if (x.kind != y.kind) return x.kind < y.kind; + return x.name < y.name; + }; + std::sort(av.begin(), av.end(), cmp); + std::sort(bv.begin(), bv.end(), cmp); + int added = 0, removed = 0, moved = 0, same = 0; + std::vector diffs; + // Two-pointer walk: equal keys → check position; A-only → + // removed; B-only → added. + size_t i_a = 0, i_b = 0; + while (i_a < av.size() || i_b < bv.size()) { + if (i_a < av.size() && i_b < bv.size() && + av[i_a].kind == bv[i_b].kind && + av[i_a].name == bv[i_b].name) { + glm::vec3 d = bv[i_b].pos - av[i_a].pos; + float dlen = glm::length(d); + if (dlen > 0.5f) { + char buf[256]; + std::snprintf(buf, sizeof(buf), + " moved %-9s %-30s by (%+.1f, %+.1f, %+.1f)", + av[i_a].kind.c_str(), + av[i_a].name.substr(0, 30).c_str(), + d.x, d.y, d.z); + diffs.push_back(buf); + moved++; + } else { + same++; + } + i_a++; i_b++; + } else if (i_b == bv.size() || + (i_a < av.size() && cmp(av[i_a], bv[i_b]))) { + char buf[256]; + std::snprintf(buf, sizeof(buf), + " removed %-9s %s", + av[i_a].kind.c_str(), + av[i_a].name.substr(0, 60).c_str()); + diffs.push_back(buf); + removed++; + i_a++; + } else { + char buf[256]; + std::snprintf(buf, sizeof(buf), + " added %-9s %s", + bv[i_b].kind.c_str(), + bv[i_b].name.substr(0, 60).c_str()); + diffs.push_back(buf); + added++; + i_b++; + } + } + std::printf("diff-zone-spawns: %s -> %s\n", + aDir.c_str(), bDir.c_str()); + std::printf(" added : %d\n", added); + std::printf(" removed : %d\n", removed); + std::printf(" moved : %d (>0.5y)\n", moved); + std::printf(" same : %d\n", same); + if (!diffs.empty()) { + std::printf("\n"); + for (const auto& d : diffs) std::printf("%s\n", d.c_str()); + } + return (added + removed + moved) == 0 ? 0 : 1; +} + +int handleInfoSpawn(int& i, int argc, char** argv) { + // Detailed view of one creature or object by index. The + // list-zone-spawns table only shows headline fields; this + // dumps every field including AI behavior, faction, + // patrol path waypoints, etc. + std::string zoneDir = argv[++i]; + std::string kind = argv[++i]; + int idx = -1; + try { idx = std::stoi(argv[++i]); } + catch (...) { + std::fprintf(stderr, + "info-spawn: must be an integer\n"); + return 1; + } + bool jsonOut = (i + 1 < argc && + std::strcmp(argv[i + 1], "--json") == 0); + if (jsonOut) i++; + namespace fs = std::filesystem; + std::transform(kind.begin(), kind.end(), kind.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (kind == "creature") { + wowee::editor::NpcSpawner spawner; + if (!spawner.loadFromFile(zoneDir + "/creatures.json")) { + std::fprintf(stderr, + "info-spawn: %s has no creatures.json\n", + zoneDir.c_str()); + return 1; + } + const auto& spawns = spawner.getSpawns(); + if (idx < 0 || static_cast(idx) >= spawns.size()) { + std::fprintf(stderr, + "info-spawn: index %d out of range (have %zu)\n", + idx, spawns.size()); + return 1; + } + const auto& s = spawns[idx]; + static const char* behaviors[] = { + "Stationary", "Patrol", "Wander", "Scripted" + }; + int bIdx = static_cast(s.behavior); + if (bIdx < 0 || bIdx > 3) bIdx = 0; + if (jsonOut) { + nlohmann::json j; + j["zone"] = zoneDir; + j["kind"] = "creature"; + j["index"] = idx; + j["id"] = s.id; + j["name"] = s.name; + j["modelPath"] = s.modelPath; + j["displayId"] = s.displayId; + j["position"] = {s.position.x, s.position.y, s.position.z}; + j["orientation"] = s.orientation; + j["level"] = s.level; + j["health"] = s.health; + j["mana"] = s.mana; + j["faction"] = s.faction; + j["scale"] = s.scale; + j["behavior"] = behaviors[bIdx]; + j["wanderRadius"] = s.wanderRadius; + j["aggroRadius"] = s.aggroRadius; + j["leashRadius"] = s.leashRadius; + j["respawnTimeMs"] = s.respawnTimeMs; + j["hostile"] = s.hostile; + j["questgiver"] = s.questgiver; + j["vendor"] = s.vendor; + j["trainer"] = s.trainer; + j["patrolPathSize"] = s.patrolPath.size(); + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("Creature spawn %d in %s\n", idx, zoneDir.c_str()); + std::printf(" id : %u\n", s.id); + std::printf(" name : %s\n", s.name.c_str()); + std::printf(" modelPath : %s\n", + s.modelPath.empty() ? "(template)" : s.modelPath.c_str()); + std::printf(" displayId : %u\n", s.displayId); + std::printf(" position : (%.2f, %.2f, %.2f)\n", + s.position.x, s.position.y, s.position.z); + std::printf(" orientation : %.1f°\n", s.orientation); + std::printf(" level : %u\n", s.level); + std::printf(" health/mana : %u / %u\n", s.health, s.mana); + std::printf(" faction : %u\n", s.faction); + std::printf(" scale : %.2f\n", s.scale); + std::printf(" behavior : %s\n", behaviors[bIdx]); + std::printf(" wander/aggro : %.1f / %.1f y\n", + s.wanderRadius, s.aggroRadius); + std::printf(" leash : %.1f y\n", s.leashRadius); + std::printf(" respawn : %.0f s\n", s.respawnTimeMs / 1000.0f); + std::printf(" flags : %s%s%s%s\n", + s.hostile ? "hostile " : "", + s.questgiver ? "questgiver " : "", + s.vendor ? "vendor " : "", + s.trainer ? "trainer " : ""); + std::printf(" patrol path : %zu waypoint(s)\n", + s.patrolPath.size()); + return 0; + } else if (kind == "object") { + wowee::editor::ObjectPlacer placer; + if (!placer.loadFromFile(zoneDir + "/objects.json")) { + std::fprintf(stderr, + "info-spawn: %s has no objects.json\n", + zoneDir.c_str()); + return 1; + } + const auto& objs = placer.getObjects(); + if (idx < 0 || static_cast(idx) >= objs.size()) { + std::fprintf(stderr, + "info-spawn: index %d out of range (have %zu)\n", + idx, objs.size()); + return 1; + } + const auto& o = objs[idx]; + if (jsonOut) { + nlohmann::json j; + j["zone"] = zoneDir; + j["kind"] = "object"; + j["index"] = idx; + j["uniqueId"] = o.uniqueId; + j["path"] = o.path; + j["type"] = o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"; + j["position"] = {o.position.x, o.position.y, o.position.z}; + j["rotation"] = {o.rotation.x, o.rotation.y, o.rotation.z}; + j["scale"] = o.scale; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("Object spawn %d in %s\n", idx, zoneDir.c_str()); + std::printf(" uniqueId : %u\n", o.uniqueId); + std::printf(" path : %s\n", o.path.c_str()); + std::printf(" type : %s\n", + o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"); + std::printf(" position : (%.2f, %.2f, %.2f)\n", + o.position.x, o.position.y, o.position.z); + std::printf(" rotation : (%.2f, %.2f, %.2f) rad\n", + o.rotation.x, o.rotation.y, o.rotation.z); + std::printf(" scale : %.2f\n", o.scale); + return 0; + } + std::fprintf(stderr, + "info-spawn: kind must be 'creature' or 'object' (got '%s')\n", + kind.c_str()); + return 1; +} + +int handleListProjectSpawns(int& i, int argc, char** argv) { + // Project-wide companion to --list-zone-spawns. Combines + // creatures + objects across every zone into one big + // listing keyed by (zone, kind, name). Useful for project- + // wide review and for piping into spreadsheets via --json. + 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, + "list-project-spawns: %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()); + int totalCreat = 0, totalObj = 0; + struct Row { + std::string zone, kind, name; + float x, y, z; + std::string extra; + }; + std::vector rows; + for (const auto& zoneDir : zones) { + std::string zname = fs::path(zoneDir).filename().string(); + wowee::editor::NpcSpawner spawner; + if (spawner.loadFromFile(zoneDir + "/creatures.json")) { + for (const auto& s : spawner.getSpawns()) { + Row r; + r.zone = zname; + r.kind = "creature"; + r.name = s.name; + r.x = s.position.x; r.y = s.position.y; + r.z = s.position.z; + r.extra = "lvl " + std::to_string(s.level); + rows.push_back(r); + totalCreat++; + } + } + wowee::editor::ObjectPlacer placer; + if (placer.loadFromFile(zoneDir + "/objects.json")) { + for (const auto& o : placer.getObjects()) { + Row r; + r.zone = zname; + r.kind = "object"; + r.name = o.path; + r.x = o.position.x; r.y = o.position.y; + r.z = o.position.z; + char buf[32]; + std::snprintf(buf, sizeof(buf), "scale %.2f", o.scale); + r.extra = buf; + rows.push_back(r); + totalObj++; + } + } + } + if (jsonOut) { + nlohmann::json j; + j["project"] = projectDir; + j["zoneCount"] = zones.size(); + j["creatureCount"] = totalCreat; + j["objectCount"] = totalObj; + nlohmann::json arr = nlohmann::json::array(); + for (const auto& r : rows) { + arr.push_back({{"zone", r.zone}, + {"kind", r.kind}, + {"name", r.name}, + {"x", r.x}, {"y", r.y}, {"z", r.z}, + {"extra", r.extra}}); + } + j["spawns"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("Project spawns: %s\n", projectDir.c_str()); + std::printf(" zones : %zu\n", zones.size()); + std::printf(" creatures : %d\n", totalCreat); + std::printf(" objects : %d\n", totalObj); + if (rows.empty()) { + std::printf("\n *no spawns in any zone*\n"); + return 0; + } + std::printf("\n zone kind x y z info name\n"); + for (const auto& r : rows) { + std::printf(" %-20s %-8s %8.1f %8.1f %8.1f %-10s %s\n", + r.zone.substr(0, 20).c_str(), + r.kind.c_str(), + r.x, r.y, r.z, + r.extra.c_str(), + r.name.substr(0, 60).c_str()); + } + return 0; +} + +int handleAuditProjectSpawns(int& i, int argc, char** argv) { + // Project-wide wrapper around --audit-zone-spawns. Spawns + // the binary per-zone (only those with creatures.json or + // objects.json), aggregates how many issues each zone has, + // and exits 1 if any zone reports problems. CI-friendly + // pre-release placement check. + std::string projectDir = argv[++i]; + std::string thresholdArg; + if (i + 2 < argc && std::strcmp(argv[i + 1], "--threshold") == 0) { + thresholdArg = argv[i + 2]; + i += 2; + } + namespace fs = std::filesystem; + if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { + std::fprintf(stderr, + "audit-project-spawns: %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; + bool hasContent = fs::exists(entry.path() / "creatures.json") || + fs::exists(entry.path() / "objects.json"); + if (!hasContent) continue; + zones.push_back(entry.path().string()); + } + std::sort(zones.begin(), zones.end()); + if (zones.empty()) { + std::printf("audit-project-spawns: %s\n", projectDir.c_str()); + std::printf(" no zones with creatures.json or objects.json\n"); + return 0; + } + std::string self = argv[0]; + int passed = 0, failed = 0; + std::printf("audit-project-spawns: %s\n", projectDir.c_str()); + std::printf(" zones to audit : %zu\n", zones.size()); + if (!thresholdArg.empty()) { + std::printf(" threshold : %s yards\n", thresholdArg.c_str()); + } + std::printf("\n"); + for (const auto& zoneDir : zones) { + std::printf("--- %s ---\n", + fs::path(zoneDir).filename().string().c_str()); + std::fflush(stdout); + std::string cmd = "\"" + self + "\" --audit-zone-spawns \"" + + zoneDir + "\""; + if (!thresholdArg.empty()) { + cmd += " --threshold " + thresholdArg; + } + int rc = std::system(cmd.c_str()); + if (rc == 0) passed++; + else failed++; + } + std::printf("\n--- summary ---\n"); + std::printf(" passed : %d\n", passed); + std::printf(" failed : %d\n", failed); + if (failed == 0) { + std::printf("\n ALL ZONES PASSED\n"); + return 0; + } + std::printf("\n Run --snap-project-to-ground to fix in bulk.\n"); + return 1; +} + +int handleSnapProjectToGround(int& i, int argc, char** argv) { + // Orchestrator wrapper around --snap-zone-to-ground. Spawns + // the binary per-zone (only zones with at least one of + // creatures.json or objects.json since pure-terrain zones + // have nothing to snap), aggregates a final summary. + std::string projectDir = argv[++i]; + namespace fs = std::filesystem; + if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { + std::fprintf(stderr, + "snap-project-to-ground: %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; + bool hasContent = fs::exists(entry.path() / "creatures.json") || + fs::exists(entry.path() / "objects.json"); + if (!hasContent) continue; + zones.push_back(entry.path().string()); + } + std::sort(zones.begin(), zones.end()); + if (zones.empty()) { + std::printf("snap-project-to-ground: %s\n", projectDir.c_str()); + std::printf(" no zones with creatures.json or objects.json\n"); + return 0; + } + std::string self = argv[0]; + int passed = 0, failed = 0; + std::printf("snap-project-to-ground: %s\n", projectDir.c_str()); + std::printf(" zones to snap : %zu\n\n", zones.size()); + for (const auto& zoneDir : zones) { + std::printf("--- %s ---\n", + fs::path(zoneDir).filename().string().c_str()); + std::fflush(stdout); + std::string cmd = "\"" + self + "\" --snap-zone-to-ground \"" + + zoneDir + "\""; + int rc = std::system(cmd.c_str()); + if (rc == 0) passed++; + else failed++; + } + std::printf("\n--- summary ---\n"); + std::printf(" zones snapped : %d\n", passed); + std::printf(" failed : %d\n", failed); + return failed == 0 ? 0 : 1; +} + + +} // namespace + +bool handleSpawnAudit(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--snap-zone-to-ground") == 0 && i + 1 < argc) { + outRc = handleSnapZoneToGround(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--audit-zone-spawns") == 0 && i + 1 < argc) { + outRc = handleAuditZoneSpawns(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--list-zone-spawns") == 0 && i + 1 < argc) { + outRc = handleListZoneSpawns(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--diff-zone-spawns") == 0 && i + 2 < argc) { + outRc = handleDiffZoneSpawns(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-spawn") == 0 && i + 3 < argc) { + outRc = handleInfoSpawn(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--list-project-spawns") == 0 && i + 1 < argc) { + outRc = handleListProjectSpawns(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--audit-project-spawns") == 0 && i + 1 < argc) { + outRc = handleAuditProjectSpawns(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--snap-project-to-ground") == 0 && i + 1 < argc) { + outRc = handleSnapProjectToGround(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_spawn_audit.hpp b/tools/editor/cli_spawn_audit.hpp new file mode 100644 index 00000000..b826e03d --- /dev/null +++ b/tools/editor/cli_spawn_audit.hpp @@ -0,0 +1,22 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +// Dispatch the NPC spawn / object placer audit + ground-snap +// handlers (8 in this group): +// --snap-zone-to-ground --snap-project-to-ground +// --audit-zone-spawns --audit-project-spawns +// --list-zone-spawns --list-project-spawns +// --diff-zone-spawns --info-spawn +// +// All operate on creatures.json + objects.json sidecars and +// the WHM terrain heightfield via WoweeTerrainLoader. +// +// Returns true if matched; outRc holds the exit code. +bool handleSpawnAudit(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 5c4ea135..b41fe538 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -19,6 +19,7 @@ #include "cli_zone_info.hpp" #include "cli_data_tree.hpp" #include "cli_diff.hpp" +#include "cli_spawn_audit.hpp" #include "content_pack.hpp" #include "npc_spawner.hpp" #include "object_placer.hpp" @@ -487,6 +488,9 @@ int main(int argc, char* argv[]) { if (wowee::editor::cli::handleDiff(i, argc, argv, outRc)) { return outRc; } + if (wowee::editor::cli::handleSpawnAudit(i, argc, argv, outRc)) { + return outRc; + } } if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { dataPath = argv[++i]; @@ -8881,751 +8885,6 @@ int main(int argc, char* argv[]) { r.musicVol, r.ambVol); } return 0; - } else if (std::strcmp(argv[i], "--snap-zone-to-ground") == 0 && i + 1 < argc) { - // Walk every creature + object in a zone and snap their Z - // to the actual terrain height. Useful after terrain edits - // or after --random-populate-zone if the spawn baseZ - // doesn't match the carved terrain. - // - // Height lookup walks the loaded WHM tiles and finds the - // chunk containing each spawn's (x, y), then uses the - // chunk's average heightmap height + base. - std::string zoneDir = argv[++i]; - namespace fs = std::filesystem; - std::string manifestPath = zoneDir + "/zone.json"; - if (!fs::exists(manifestPath)) { - std::fprintf(stderr, - "snap-zone-to-ground: %s has no zone.json\n", - zoneDir.c_str()); - return 1; - } - wowee::editor::ZoneManifest zm; - if (!zm.load(manifestPath)) { - std::fprintf(stderr, - "snap-zone-to-ground: failed to parse %s\n", - manifestPath.c_str()); - return 1; - } - // Load all tiles into a flat map keyed by (tx, ty). - struct LoadedTile { - wowee::pipeline::ADTTerrain terrain; - int tx, ty; - }; - std::vector tiles; - for (const auto& [tx, ty] : zm.tiles) { - std::string base = zoneDir + "/" + zm.mapName + "_" + - std::to_string(tx) + "_" + std::to_string(ty); - if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) continue; - LoadedTile lt; - lt.tx = tx; lt.ty = ty; - if (wowee::pipeline::WoweeTerrainLoader::load(base, lt.terrain)) { - tiles.push_back(std::move(lt)); - } - } - if (tiles.empty()) { - std::fprintf(stderr, - "snap-zone-to-ground: no .whm tiles loaded\n"); - return 1; - } - // Compute terrain height at world (x, y) by finding the - // chunk that contains it and averaging its heightmap. Each - // chunk is 33.33y across; chunk position[1]=wowX origin, - // [0]=wowY origin. - constexpr float kChunkSize = 33.33333f; - auto sampleHeight = [&](float wx, float wy) -> float { - for (const auto& lt : tiles) { - for (const auto& chunk : lt.terrain.chunks) { - if (!chunk.heightMap.isLoaded()) continue; - float cx0 = chunk.position[1]; - float cy0 = chunk.position[0]; - if (wx < cx0 || wx >= cx0 + kChunkSize) continue; - if (wy < cy0 || wy >= cy0 + kChunkSize) continue; - // Use average heightmap height to dodge the - // need for full bilinear sampling. Good enough - // for spawn placement; finer interpolation is - // a future optimization. - float sum = 0; int n = 0; - for (float h : chunk.heightMap.heights) { - if (std::isfinite(h)) { sum += h; n++; } - } - if (n == 0) return chunk.position[2]; - return chunk.position[2] + sum / n; - } - } - return zm.baseHeight; // outside any loaded chunk - }; - int snappedC = 0, snappedO = 0; - // Creatures. - wowee::editor::NpcSpawner spawner; - std::string cpath = zoneDir + "/creatures.json"; - if (fs::exists(cpath) && spawner.loadFromFile(cpath)) { - auto& spawns = spawner.getSpawns(); - for (auto& s : spawns) { - s.position.z = sampleHeight(s.position.x, s.position.y); - snappedC++; - } - if (snappedC > 0) spawner.saveToFile(cpath); - } - // Objects. - wowee::editor::ObjectPlacer placer; - std::string opath = zoneDir + "/objects.json"; - if (fs::exists(opath) && placer.loadFromFile(opath)) { - auto& objs = placer.getObjects(); - for (auto& o : objs) { - o.position.z = sampleHeight(o.position.x, o.position.y); - snappedO++; - } - if (snappedO > 0) placer.saveToFile(opath); - } - std::printf("snap-zone-to-ground: %s\n", zoneDir.c_str()); - std::printf(" tiles loaded : %zu\n", tiles.size()); - std::printf(" creatures : %d snapped\n", snappedC); - std::printf(" objects : %d snapped\n", snappedO); - return 0; - } else if (std::strcmp(argv[i], "--audit-zone-spawns") == 0 && i + 1 < argc) { - // Non-destructive companion to --snap-zone-to-ground. - // Loads the zone's terrain, walks every creature + object, - // and flags any whose Z is more than yards - // off from the sampled terrain height. Useful for - // surveying placement issues before deciding whether to - // run --snap-zone-to-ground (which would silently rewrite - // every spawn). - std::string zoneDir = argv[++i]; - float threshold = 5.0f; - if (i + 2 < argc && std::strcmp(argv[i + 1], "--threshold") == 0) { - try { threshold = std::stof(argv[i + 2]); i += 2; } - catch (...) {} - } - namespace fs = std::filesystem; - std::string manifestPath = zoneDir + "/zone.json"; - if (!fs::exists(manifestPath)) { - std::fprintf(stderr, - "audit-zone-spawns: %s has no zone.json\n", - zoneDir.c_str()); - return 1; - } - wowee::editor::ZoneManifest zm; - if (!zm.load(manifestPath)) { - std::fprintf(stderr, - "audit-zone-spawns: failed to parse %s\n", - manifestPath.c_str()); - return 1; - } - // Same chunk-average sampler as --snap-zone-to-ground. - // Returning baseHeight when no chunk hits = "no terrain - // data here", so flag those too via the threshold check. - struct LoadedTile { - wowee::pipeline::ADTTerrain terrain; - }; - std::vector tiles; - for (const auto& [tx, ty] : zm.tiles) { - std::string base = zoneDir + "/" + zm.mapName + "_" + - std::to_string(tx) + "_" + std::to_string(ty); - if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) continue; - LoadedTile lt; - if (wowee::pipeline::WoweeTerrainLoader::load(base, lt.terrain)) { - tiles.push_back(std::move(lt)); - } - } - constexpr float kChunkSize = 33.33333f; - auto sampleHeight = [&](float wx, float wy) -> float { - for (const auto& lt : tiles) { - for (const auto& chunk : lt.terrain.chunks) { - if (!chunk.heightMap.isLoaded()) continue; - float cx0 = chunk.position[1]; - float cy0 = chunk.position[0]; - if (wx < cx0 || wx >= cx0 + kChunkSize) continue; - if (wy < cy0 || wy >= cy0 + kChunkSize) continue; - float sum = 0; int n = 0; - for (float h : chunk.heightMap.heights) { - if (std::isfinite(h)) { sum += h; n++; } - } - if (n == 0) return chunk.position[2]; - return chunk.position[2] + sum / n; - } - } - return zm.baseHeight; - }; - struct Issue { std::string kind; std::string name; - float spawnZ, terrainZ; }; - std::vector issues; - wowee::editor::NpcSpawner spawner; - if (fs::exists(zoneDir + "/creatures.json") && - spawner.loadFromFile(zoneDir + "/creatures.json")) { - for (const auto& s : spawner.getSpawns()) { - float th = sampleHeight(s.position.x, s.position.y); - if (std::fabs(s.position.z - th) > threshold) { - issues.push_back({"creature", s.name, - s.position.z, th}); - } - } - } - wowee::editor::ObjectPlacer placer; - if (fs::exists(zoneDir + "/objects.json") && - placer.loadFromFile(zoneDir + "/objects.json")) { - for (const auto& o : placer.getObjects()) { - float th = sampleHeight(o.position.x, o.position.y); - if (std::fabs(o.position.z - th) > threshold) { - issues.push_back({"object", o.path, - o.position.z, th}); - } - } - } - std::printf("audit-zone-spawns: %s\n", zoneDir.c_str()); - std::printf(" threshold : %.1f yards\n", threshold); - std::printf(" creatures : %zu\n", spawner.spawnCount()); - std::printf(" objects : %zu\n", placer.getObjects().size()); - std::printf(" issues : %zu\n", issues.size()); - if (issues.empty()) { - std::printf("\n PASSED — every spawn is within %.1f y of the terrain\n", - threshold); - return 0; - } - std::printf("\n Flagged spawns (delta = spawnZ - terrainZ):\n"); - std::printf(" kind delta spawnZ terrainZ name\n"); - for (const auto& iss : issues) { - float delta = iss.spawnZ - iss.terrainZ; - std::printf(" %-8s %+6.1f %7.1f %7.1f %s\n", - iss.kind.c_str(), delta, iss.spawnZ, - iss.terrainZ, - iss.name.substr(0, 40).c_str()); - } - std::printf("\n Run --snap-zone-to-ground to fix in bulk.\n"); - return 1; - } else if (std::strcmp(argv[i], "--list-zone-spawns") == 0 && i + 1 < argc) { - // Combined creature + object listing. Useful for a quick - // "what's in this zone" survey without running both - // --info-creatures and --info-objects separately. - 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, - "list-zone-spawns: %s has no zone.json\n", zoneDir.c_str()); - return 1; - } - wowee::editor::NpcSpawner spawner; - wowee::editor::ObjectPlacer placer; - spawner.loadFromFile(zoneDir + "/creatures.json"); - placer.loadFromFile(zoneDir + "/objects.json"); - const auto& spawns = spawner.getSpawns(); - const auto& objs = placer.getObjects(); - if (jsonOut) { - nlohmann::json j; - j["zone"] = zoneDir; - j["creatureCount"] = spawns.size(); - j["objectCount"] = objs.size(); - nlohmann::json carr = nlohmann::json::array(); - for (const auto& s : spawns) { - carr.push_back({{"name", s.name}, - {"level", s.level}, - {"x", s.position.x}, - {"y", s.position.y}, - {"z", s.position.z}, - {"hostile", s.hostile}}); - } - j["creatures"] = carr; - nlohmann::json oarr = nlohmann::json::array(); - for (const auto& o : objs) { - oarr.push_back({{"path", o.path}, - {"type", o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"}, - {"x", o.position.x}, - {"y", o.position.y}, - {"z", o.position.z}, - {"scale", o.scale}}); - } - j["objects"] = oarr; - std::printf("%s\n", j.dump(2).c_str()); - return 0; - } - std::printf("Zone spawns: %s\n", zoneDir.c_str()); - std::printf(" creatures : %zu\n", spawns.size()); - std::printf(" objects : %zu\n", objs.size()); - if (!spawns.empty()) { - std::printf("\n Creatures:\n"); - std::printf(" idx lvl hostile x y z name\n"); - for (size_t k = 0; k < spawns.size(); ++k) { - const auto& s = spawns[k]; - std::printf(" %3zu %3u %-7s %8.1f %8.1f %8.1f %s\n", - k, s.level, s.hostile ? "yes" : "no", - s.position.x, s.position.y, s.position.z, - s.name.c_str()); - } - } - if (!objs.empty()) { - std::printf("\n Objects:\n"); - std::printf(" idx type scale x y z path\n"); - for (size_t k = 0; k < objs.size(); ++k) { - const auto& o = objs[k]; - std::printf(" %3zu %-4s %5.2f %8.1f %8.1f %8.1f %s\n", - k, - o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo", - o.scale, - o.position.x, o.position.y, o.position.z, - o.path.c_str()); - } - } - return 0; - } else if (std::strcmp(argv[i], "--diff-zone-spawns") == 0 && i + 2 < argc) { - // Compare two zones' creatures + objects. Matches by - // (kind, name) — paired entries with mismatched positions - // are reported as "moved" with the delta. Entries that - // exist in only one zone are added/removed. - // - // Useful for "what did the new branch change vs main" - // before merging, or for confirming a copy-zone-items - // produced what was expected. - std::string aDir = argv[++i]; - std::string bDir = argv[++i]; - namespace fs = std::filesystem; - if (!fs::exists(aDir + "/zone.json")) { - std::fprintf(stderr, - "diff-zone-spawns: %s has no zone.json\n", aDir.c_str()); - return 1; - } - if (!fs::exists(bDir + "/zone.json")) { - std::fprintf(stderr, - "diff-zone-spawns: %s has no zone.json\n", bDir.c_str()); - return 1; - } - // Multiset key: kind/name. Position comes along so we can - // report "moved" deltas when a name appears in both with - // different XYZ. - struct Entry { std::string kind, name; glm::vec3 pos; }; - auto load = [&](const std::string& dir) { - std::vector out; - wowee::editor::NpcSpawner spawner; - if (spawner.loadFromFile(dir + "/creatures.json")) { - for (const auto& s : spawner.getSpawns()) { - out.push_back({"creature", s.name, s.position}); - } - } - wowee::editor::ObjectPlacer placer; - if (placer.loadFromFile(dir + "/objects.json")) { - for (const auto& o : placer.getObjects()) { - out.push_back({"object", o.path, o.position}); - } - } - return out; - }; - auto av = load(aDir); - auto bv = load(bDir); - // Sort each side for stable key matching. - auto cmp = [](const Entry& x, const Entry& y) { - if (x.kind != y.kind) return x.kind < y.kind; - return x.name < y.name; - }; - std::sort(av.begin(), av.end(), cmp); - std::sort(bv.begin(), bv.end(), cmp); - int added = 0, removed = 0, moved = 0, same = 0; - std::vector diffs; - // Two-pointer walk: equal keys → check position; A-only → - // removed; B-only → added. - size_t i_a = 0, i_b = 0; - while (i_a < av.size() || i_b < bv.size()) { - if (i_a < av.size() && i_b < bv.size() && - av[i_a].kind == bv[i_b].kind && - av[i_a].name == bv[i_b].name) { - glm::vec3 d = bv[i_b].pos - av[i_a].pos; - float dlen = glm::length(d); - if (dlen > 0.5f) { - char buf[256]; - std::snprintf(buf, sizeof(buf), - " moved %-9s %-30s by (%+.1f, %+.1f, %+.1f)", - av[i_a].kind.c_str(), - av[i_a].name.substr(0, 30).c_str(), - d.x, d.y, d.z); - diffs.push_back(buf); - moved++; - } else { - same++; - } - i_a++; i_b++; - } else if (i_b == bv.size() || - (i_a < av.size() && cmp(av[i_a], bv[i_b]))) { - char buf[256]; - std::snprintf(buf, sizeof(buf), - " removed %-9s %s", - av[i_a].kind.c_str(), - av[i_a].name.substr(0, 60).c_str()); - diffs.push_back(buf); - removed++; - i_a++; - } else { - char buf[256]; - std::snprintf(buf, sizeof(buf), - " added %-9s %s", - bv[i_b].kind.c_str(), - bv[i_b].name.substr(0, 60).c_str()); - diffs.push_back(buf); - added++; - i_b++; - } - } - std::printf("diff-zone-spawns: %s -> %s\n", - aDir.c_str(), bDir.c_str()); - std::printf(" added : %d\n", added); - std::printf(" removed : %d\n", removed); - std::printf(" moved : %d (>0.5y)\n", moved); - std::printf(" same : %d\n", same); - if (!diffs.empty()) { - std::printf("\n"); - for (const auto& d : diffs) std::printf("%s\n", d.c_str()); - } - return (added + removed + moved) == 0 ? 0 : 1; - } else if (std::strcmp(argv[i], "--info-spawn") == 0 && i + 3 < argc) { - // Detailed view of one creature or object by index. The - // list-zone-spawns table only shows headline fields; this - // dumps every field including AI behavior, faction, - // patrol path waypoints, etc. - std::string zoneDir = argv[++i]; - std::string kind = argv[++i]; - int idx = -1; - try { idx = std::stoi(argv[++i]); } - catch (...) { - std::fprintf(stderr, - "info-spawn: must be an integer\n"); - return 1; - } - bool jsonOut = (i + 1 < argc && - std::strcmp(argv[i + 1], "--json") == 0); - if (jsonOut) i++; - namespace fs = std::filesystem; - std::transform(kind.begin(), kind.end(), kind.begin(), - [](unsigned char c) { return std::tolower(c); }); - if (kind == "creature") { - wowee::editor::NpcSpawner spawner; - if (!spawner.loadFromFile(zoneDir + "/creatures.json")) { - std::fprintf(stderr, - "info-spawn: %s has no creatures.json\n", - zoneDir.c_str()); - return 1; - } - const auto& spawns = spawner.getSpawns(); - if (idx < 0 || static_cast(idx) >= spawns.size()) { - std::fprintf(stderr, - "info-spawn: index %d out of range (have %zu)\n", - idx, spawns.size()); - return 1; - } - const auto& s = spawns[idx]; - static const char* behaviors[] = { - "Stationary", "Patrol", "Wander", "Scripted" - }; - int bIdx = static_cast(s.behavior); - if (bIdx < 0 || bIdx > 3) bIdx = 0; - if (jsonOut) { - nlohmann::json j; - j["zone"] = zoneDir; - j["kind"] = "creature"; - j["index"] = idx; - j["id"] = s.id; - j["name"] = s.name; - j["modelPath"] = s.modelPath; - j["displayId"] = s.displayId; - j["position"] = {s.position.x, s.position.y, s.position.z}; - j["orientation"] = s.orientation; - j["level"] = s.level; - j["health"] = s.health; - j["mana"] = s.mana; - j["faction"] = s.faction; - j["scale"] = s.scale; - j["behavior"] = behaviors[bIdx]; - j["wanderRadius"] = s.wanderRadius; - j["aggroRadius"] = s.aggroRadius; - j["leashRadius"] = s.leashRadius; - j["respawnTimeMs"] = s.respawnTimeMs; - j["hostile"] = s.hostile; - j["questgiver"] = s.questgiver; - j["vendor"] = s.vendor; - j["trainer"] = s.trainer; - j["patrolPathSize"] = s.patrolPath.size(); - std::printf("%s\n", j.dump(2).c_str()); - return 0; - } - std::printf("Creature spawn %d in %s\n", idx, zoneDir.c_str()); - std::printf(" id : %u\n", s.id); - std::printf(" name : %s\n", s.name.c_str()); - std::printf(" modelPath : %s\n", - s.modelPath.empty() ? "(template)" : s.modelPath.c_str()); - std::printf(" displayId : %u\n", s.displayId); - std::printf(" position : (%.2f, %.2f, %.2f)\n", - s.position.x, s.position.y, s.position.z); - std::printf(" orientation : %.1f°\n", s.orientation); - std::printf(" level : %u\n", s.level); - std::printf(" health/mana : %u / %u\n", s.health, s.mana); - std::printf(" faction : %u\n", s.faction); - std::printf(" scale : %.2f\n", s.scale); - std::printf(" behavior : %s\n", behaviors[bIdx]); - std::printf(" wander/aggro : %.1f / %.1f y\n", - s.wanderRadius, s.aggroRadius); - std::printf(" leash : %.1f y\n", s.leashRadius); - std::printf(" respawn : %.0f s\n", s.respawnTimeMs / 1000.0f); - std::printf(" flags : %s%s%s%s\n", - s.hostile ? "hostile " : "", - s.questgiver ? "questgiver " : "", - s.vendor ? "vendor " : "", - s.trainer ? "trainer " : ""); - std::printf(" patrol path : %zu waypoint(s)\n", - s.patrolPath.size()); - return 0; - } else if (kind == "object") { - wowee::editor::ObjectPlacer placer; - if (!placer.loadFromFile(zoneDir + "/objects.json")) { - std::fprintf(stderr, - "info-spawn: %s has no objects.json\n", - zoneDir.c_str()); - return 1; - } - const auto& objs = placer.getObjects(); - if (idx < 0 || static_cast(idx) >= objs.size()) { - std::fprintf(stderr, - "info-spawn: index %d out of range (have %zu)\n", - idx, objs.size()); - return 1; - } - const auto& o = objs[idx]; - if (jsonOut) { - nlohmann::json j; - j["zone"] = zoneDir; - j["kind"] = "object"; - j["index"] = idx; - j["uniqueId"] = o.uniqueId; - j["path"] = o.path; - j["type"] = o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"; - j["position"] = {o.position.x, o.position.y, o.position.z}; - j["rotation"] = {o.rotation.x, o.rotation.y, o.rotation.z}; - j["scale"] = o.scale; - std::printf("%s\n", j.dump(2).c_str()); - return 0; - } - std::printf("Object spawn %d in %s\n", idx, zoneDir.c_str()); - std::printf(" uniqueId : %u\n", o.uniqueId); - std::printf(" path : %s\n", o.path.c_str()); - std::printf(" type : %s\n", - o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"); - std::printf(" position : (%.2f, %.2f, %.2f)\n", - o.position.x, o.position.y, o.position.z); - std::printf(" rotation : (%.2f, %.2f, %.2f) rad\n", - o.rotation.x, o.rotation.y, o.rotation.z); - std::printf(" scale : %.2f\n", o.scale); - return 0; - } - std::fprintf(stderr, - "info-spawn: kind must be 'creature' or 'object' (got '%s')\n", - kind.c_str()); - return 1; - } else if (std::strcmp(argv[i], "--list-project-spawns") == 0 && i + 1 < argc) { - // Project-wide companion to --list-zone-spawns. Combines - // creatures + objects across every zone into one big - // listing keyed by (zone, kind, name). Useful for project- - // wide review and for piping into spreadsheets via --json. - 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, - "list-project-spawns: %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()); - int totalCreat = 0, totalObj = 0; - struct Row { - std::string zone, kind, name; - float x, y, z; - std::string extra; - }; - std::vector rows; - for (const auto& zoneDir : zones) { - std::string zname = fs::path(zoneDir).filename().string(); - wowee::editor::NpcSpawner spawner; - if (spawner.loadFromFile(zoneDir + "/creatures.json")) { - for (const auto& s : spawner.getSpawns()) { - Row r; - r.zone = zname; - r.kind = "creature"; - r.name = s.name; - r.x = s.position.x; r.y = s.position.y; - r.z = s.position.z; - r.extra = "lvl " + std::to_string(s.level); - rows.push_back(r); - totalCreat++; - } - } - wowee::editor::ObjectPlacer placer; - if (placer.loadFromFile(zoneDir + "/objects.json")) { - for (const auto& o : placer.getObjects()) { - Row r; - r.zone = zname; - r.kind = "object"; - r.name = o.path; - r.x = o.position.x; r.y = o.position.y; - r.z = o.position.z; - char buf[32]; - std::snprintf(buf, sizeof(buf), "scale %.2f", o.scale); - r.extra = buf; - rows.push_back(r); - totalObj++; - } - } - } - if (jsonOut) { - nlohmann::json j; - j["project"] = projectDir; - j["zoneCount"] = zones.size(); - j["creatureCount"] = totalCreat; - j["objectCount"] = totalObj; - nlohmann::json arr = nlohmann::json::array(); - for (const auto& r : rows) { - arr.push_back({{"zone", r.zone}, - {"kind", r.kind}, - {"name", r.name}, - {"x", r.x}, {"y", r.y}, {"z", r.z}, - {"extra", r.extra}}); - } - j["spawns"] = arr; - std::printf("%s\n", j.dump(2).c_str()); - return 0; - } - std::printf("Project spawns: %s\n", projectDir.c_str()); - std::printf(" zones : %zu\n", zones.size()); - std::printf(" creatures : %d\n", totalCreat); - std::printf(" objects : %d\n", totalObj); - if (rows.empty()) { - std::printf("\n *no spawns in any zone*\n"); - return 0; - } - std::printf("\n zone kind x y z info name\n"); - for (const auto& r : rows) { - std::printf(" %-20s %-8s %8.1f %8.1f %8.1f %-10s %s\n", - r.zone.substr(0, 20).c_str(), - r.kind.c_str(), - r.x, r.y, r.z, - r.extra.c_str(), - r.name.substr(0, 60).c_str()); - } - return 0; - } else if (std::strcmp(argv[i], "--audit-project-spawns") == 0 && i + 1 < argc) { - // Project-wide wrapper around --audit-zone-spawns. Spawns - // the binary per-zone (only those with creatures.json or - // objects.json), aggregates how many issues each zone has, - // and exits 1 if any zone reports problems. CI-friendly - // pre-release placement check. - std::string projectDir = argv[++i]; - std::string thresholdArg; - if (i + 2 < argc && std::strcmp(argv[i + 1], "--threshold") == 0) { - thresholdArg = argv[i + 2]; - i += 2; - } - namespace fs = std::filesystem; - if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { - std::fprintf(stderr, - "audit-project-spawns: %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; - bool hasContent = fs::exists(entry.path() / "creatures.json") || - fs::exists(entry.path() / "objects.json"); - if (!hasContent) continue; - zones.push_back(entry.path().string()); - } - std::sort(zones.begin(), zones.end()); - if (zones.empty()) { - std::printf("audit-project-spawns: %s\n", projectDir.c_str()); - std::printf(" no zones with creatures.json or objects.json\n"); - return 0; - } - std::string self = argv[0]; - int passed = 0, failed = 0; - std::printf("audit-project-spawns: %s\n", projectDir.c_str()); - std::printf(" zones to audit : %zu\n", zones.size()); - if (!thresholdArg.empty()) { - std::printf(" threshold : %s yards\n", thresholdArg.c_str()); - } - std::printf("\n"); - for (const auto& zoneDir : zones) { - std::printf("--- %s ---\n", - fs::path(zoneDir).filename().string().c_str()); - std::fflush(stdout); - std::string cmd = "\"" + self + "\" --audit-zone-spawns \"" + - zoneDir + "\""; - if (!thresholdArg.empty()) { - cmd += " --threshold " + thresholdArg; - } - int rc = std::system(cmd.c_str()); - if (rc == 0) passed++; - else failed++; - } - std::printf("\n--- summary ---\n"); - std::printf(" passed : %d\n", passed); - std::printf(" failed : %d\n", failed); - if (failed == 0) { - std::printf("\n ALL ZONES PASSED\n"); - return 0; - } - std::printf("\n Run --snap-project-to-ground to fix in bulk.\n"); - return 1; - } else if (std::strcmp(argv[i], "--snap-project-to-ground") == 0 && i + 1 < argc) { - // Orchestrator wrapper around --snap-zone-to-ground. Spawns - // the binary per-zone (only zones with at least one of - // creatures.json or objects.json since pure-terrain zones - // have nothing to snap), aggregates a final summary. - std::string projectDir = argv[++i]; - namespace fs = std::filesystem; - if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { - std::fprintf(stderr, - "snap-project-to-ground: %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; - bool hasContent = fs::exists(entry.path() / "creatures.json") || - fs::exists(entry.path() / "objects.json"); - if (!hasContent) continue; - zones.push_back(entry.path().string()); - } - std::sort(zones.begin(), zones.end()); - if (zones.empty()) { - std::printf("snap-project-to-ground: %s\n", projectDir.c_str()); - std::printf(" no zones with creatures.json or objects.json\n"); - return 0; - } - std::string self = argv[0]; - int passed = 0, failed = 0; - std::printf("snap-project-to-ground: %s\n", projectDir.c_str()); - std::printf(" zones to snap : %zu\n\n", zones.size()); - for (const auto& zoneDir : zones) { - std::printf("--- %s ---\n", - fs::path(zoneDir).filename().string().c_str()); - std::fflush(stdout); - std::string cmd = "\"" + self + "\" --snap-zone-to-ground \"" + - zoneDir + "\""; - int rc = std::system(cmd.c_str()); - if (rc == 0) passed++; - else failed++; - } - std::printf("\n--- summary ---\n"); - std::printf(" zones snapped : %d\n", passed); - std::printf(" failed : %d\n", failed); - return failed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--list-items") == 0 && i + 1 < argc) { // Inspect /items.json. Pretty-prints id / quality // / item level / display id / name as a table; also