From 4f72fe6222be87dcc46cbbe90534da2276884bab Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 09:54:17 -0700 Subject: [PATCH] refactor(editor): extract project actions into cli_project_actions.cpp Moves three project-level handlers (--copy-project, --zone-summary, --bench-bake-project) out of main.cpp into a new cli_project_actions.{hpp,cpp} module. None of these fit the more-specific modules: copy-project does recursive project-tree duplication, zone-summary collapses validate + content rollup into one CI-friendly command, bench-bake-project times per-zone WHM/WOT load to track perf regressions. main.cpp shrinks by 243 lines (1,187 to 944). Both --json output modes preserved for CI pipelines. --- CMakeLists.txt | 1 + tools/editor/cli_project_actions.cpp | 300 +++++++++++++++++++++++++++ tools/editor/cli_project_actions.hpp | 20 ++ tools/editor/main.cpp | 252 +--------------------- 4 files changed, 325 insertions(+), 248 deletions(-) create mode 100644 tools/editor/cli_project_actions.cpp create mode 100644 tools/editor/cli_project_actions.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 13e06541..574e732f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1359,6 +1359,7 @@ add_executable(wowee_editor tools/editor/cli_texture_helpers.cpp tools/editor/cli_mesh_info.cpp tools/editor/cli_zone_data.cpp + tools/editor/cli_project_actions.cpp tools/editor/editor_app.cpp tools/editor/editor_camera.cpp tools/editor/editor_viewport.cpp diff --git a/tools/editor/cli_project_actions.cpp b/tools/editor/cli_project_actions.cpp new file mode 100644 index 00000000..c4ab0639 --- /dev/null +++ b/tools/editor/cli_project_actions.cpp @@ -0,0 +1,300 @@ +#include "cli_project_actions.hpp" + +#include "content_pack.hpp" +#include "zone_manifest.hpp" +#include "npc_spawner.hpp" +#include "object_placer.hpp" +#include "quest_editor.hpp" +#include "pipeline/wowee_terrain_loader.hpp" +#include "pipeline/adt_loader.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +int handleCopyProject(int& i, int argc, char** argv) { + // Recursively copy an entire project tree. Refuses to + // overwrite an existing destination so a typo doesn't + // silently merge into the wrong project. + std::string fromDir = argv[++i]; + std::string toDir = argv[++i]; + namespace fs = std::filesystem; + if (!fs::exists(fromDir) || !fs::is_directory(fromDir)) { + std::fprintf(stderr, + "copy-project: %s is not a directory\n", fromDir.c_str()); + return 1; + } + if (fs::exists(toDir)) { + std::fprintf(stderr, + "copy-project: destination %s already exists " + "(delete it first if intentional)\n", toDir.c_str()); + return 1; + } + std::error_code ec; + fs::copy(fromDir, toDir, + fs::copy_options::recursive | fs::copy_options::copy_symlinks, + ec); + if (ec) { + std::fprintf(stderr, + "copy-project: copy failed (%s)\n", ec.message().c_str()); + return 1; + } + // Count what was copied for the report. + int zoneCount = 0, fileCount = 0; + uint64_t totalBytes = 0; + for (const auto& entry : fs::directory_iterator(toDir, ec)) { + if (entry.is_directory() && + fs::exists(entry.path() / "zone.json")) zoneCount++; + } + for (const auto& e : fs::recursive_directory_iterator(toDir, ec)) { + if (e.is_regular_file()) { + fileCount++; + totalBytes += e.file_size(ec); + } + } + std::printf("Copied %s -> %s\n", fromDir.c_str(), toDir.c_str()); + std::printf(" zones : %d\n", zoneCount); + std::printf(" files : %d\n", fileCount); + std::printf(" total bytes : %llu (%.1f MB)\n", + static_cast(totalBytes), + totalBytes / (1024.0 * 1024.0)); + return 0; +} + +int handleZoneSummary(int& i, int argc, char** argv) { + // One-shot zone overview: validate + creature/object/quest counts. + // Collapses the most common multi-step inspection into a single + // command; useful for CI reports and quick sanity checks. + std::string zoneDir = argv[++i]; + // Optional --json after the dir for machine-readable output. + bool jsonOut = (i + 1 < argc && + std::strcmp(argv[i + 1], "--json") == 0); + if (jsonOut) i++; + namespace fs = std::filesystem; + if (!fs::exists(zoneDir)) { + std::fprintf(stderr, "zone-summary: %s does not exist\n", zoneDir.c_str()); + return 1; + } + auto v = wowee::editor::ContentPacker::validateZone(zoneDir); + + // Read creature/object/quest data once so both human and JSON + // outputs share the same numbers. + int creatureTotal = 0, hostile = 0, qg = 0, vendor = 0; + int objectTotal = 0, m2Count = 0, wmoCount = 0; + int questTotal = 0, chainWarnings = 0; + std::string creaturesPath = zoneDir + "/creatures.json"; + if (fs::exists(creaturesPath)) { + wowee::editor::NpcSpawner sp; + if (sp.loadFromFile(creaturesPath)) { + creatureTotal = static_cast(sp.getSpawns().size()); + for (const auto& s : sp.getSpawns()) { + if (s.hostile) hostile++; + if (s.questgiver) qg++; + if (s.vendor) vendor++; + } + } + } + std::string objectsPath = zoneDir + "/objects.json"; + if (fs::exists(objectsPath)) { + wowee::editor::ObjectPlacer op; + if (op.loadFromFile(objectsPath)) { + objectTotal = static_cast(op.getObjects().size()); + for (const auto& o : op.getObjects()) { + if (o.type == wowee::editor::PlaceableType::M2) m2Count++; + else wmoCount++; + } + } + } + std::string questsPath = zoneDir + "/quests.json"; + if (fs::exists(questsPath)) { + wowee::editor::QuestEditor qe; + if (qe.loadFromFile(questsPath)) { + questTotal = static_cast(qe.getQuests().size()); + std::vector errors; + qe.validateChains(errors); + chainWarnings = static_cast(errors.size()); + } + } + + if (jsonOut) { + nlohmann::json j; + j["zone"] = zoneDir; + j["score"] = v.openFormatScore(); + j["maxScore"] = 7; + j["formats"] = v.summary(); + j["counts"] = { + {"wot", v.wotCount}, {"whm", v.whmCount}, + {"wom", v.womCount}, {"wob", v.wobCount}, + {"woc", v.wocCount}, {"png", v.pngCount}, + }; + j["creatures"] = { + {"total", creatureTotal}, + {"hostile", hostile}, + {"questgiver", qg}, + {"vendor", vendor}, + }; + j["objects"] = { + {"total", objectTotal}, + {"m2", m2Count}, + {"wmo", wmoCount}, + }; + j["quests"] = { + {"total", questTotal}, + {"chainWarnings", chainWarnings}, + }; + std::printf("%s\n", j.dump(2).c_str()); + return v.openFormatScore() == 7 ? 0 : 1; + } + std::printf("Zone: %s\n", zoneDir.c_str()); + std::printf(" open formats : %d/7 (%s)\n", + v.openFormatScore(), v.summary().c_str()); + std::printf(" WOT/WHM : %d/%d WOM: %d WOB: %d WOC: %d PNG: %d\n", + v.wotCount, v.whmCount, v.womCount, v.wobCount, + v.wocCount, v.pngCount); + if (creatureTotal > 0) { + std::printf(" creatures : %d (%d hostile, %d quest, %d vendor)\n", + creatureTotal, hostile, qg, vendor); + } + if (objectTotal > 0) { + std::printf(" objects : %d (%d M2, %d WMO)\n", + objectTotal, m2Count, wmoCount); + } + if (questTotal > 0) { + std::printf(" quests : %d (%d chain warnings)\n", + questTotal, chainWarnings); + } + return v.openFormatScore() == 7 ? 0 : 1; +} + +int handleBenchBakeProject(int& i, int argc, char** argv) { + // Time WHM/WOT load (the dominant cost in --bake-zone-glb/obj/ + // stl) per zone. The actual write side adds ~constant cost + // proportional to vertex count, so load time is a strong + // proxy. Useful for tracking 'has my latest geometry change + // made baking 3× slower?' across releases. + 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-bake-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()); + struct Timing { + std::string name; + int tiles; + double loadMs; + int chunks; + }; + std::vector timings; + double totalMs = 0; + for (const auto& zoneDir : zones) { + wowee::editor::ZoneManifest zm; + if (!zm.load(zoneDir + "/zone.json")) continue; + Timing t{fs::path(zoneDir).filename().string(), 0, 0.0, 0}; + auto t0 = std::chrono::steady_clock::now(); + 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; + wowee::pipeline::ADTTerrain terrain; + wowee::pipeline::WoweeTerrainLoader::load(base, terrain); + t.tiles++; + for (const auto& chunk : terrain.chunks) { + if (chunk.heightMap.isLoaded()) t.chunks++; + } + } + auto t1 = std::chrono::steady_clock::now(); + t.loadMs = std::chrono::duration(t1 - t0).count(); + totalMs += t.loadMs; + timings.push_back(t); + } + double avgMs = !timings.empty() ? totalMs / timings.size() : 0.0; + double minMs = 1e30, maxMs = 0; + std::string slowest; + for (const auto& t : timings) { + if (t.loadMs < minMs) minMs = t.loadMs; + if (t.loadMs > maxMs) { maxMs = t.loadMs; slowest = 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"] = slowest; + nlohmann::json arr = nlohmann::json::array(); + for (const auto& t : timings) { + arr.push_back({{"zone", t.name}, + {"loadMs", t.loadMs}, + {"tiles", t.tiles}, + {"chunks", t.chunks}}); + } + j["perZone"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("Bench bake (load-only): %s\n", projectDir.c_str()); + std::printf(" zones : %zu\n", timings.size()); + std::printf(" total : %.2f ms (terrain load)\n", totalMs); + std::printf(" per zone : avg=%.2f min=%.2f max=%.2f ms\n", + avgMs, minMs, maxMs); + if (!slowest.empty()) { + std::printf(" slowest : %s (%.2f ms)\n", slowest.c_str(), maxMs); + } + std::printf("\n Per-zone:\n"); + std::printf(" zone ms tiles chunks ms/tile\n"); + for (const auto& t : timings) { + double mspt = t.tiles > 0 ? t.loadMs / t.tiles : 0.0; + std::printf(" %-26s %7.2f %5d %5d %6.2f\n", + t.name.substr(0, 26).c_str(), + t.loadMs, t.tiles, t.chunks, mspt); + } + return 0; +} + + +} // namespace + +bool handleProjectActions(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--copy-project") == 0 && i + 2 < argc) { + outRc = handleCopyProject(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--zone-summary") == 0 && i + 1 < argc) { + outRc = handleZoneSummary(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--bench-bake-project") == 0 && i + 1 < argc) { + outRc = handleBenchBakeProject(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_project_actions.hpp b/tools/editor/cli_project_actions.hpp new file mode 100644 index 00000000..b5a41f94 --- /dev/null +++ b/tools/editor/cli_project_actions.hpp @@ -0,0 +1,20 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +// Dispatch the project-level action handlers — top-level +// project utilities that don't fit any of the more specific +// modules. Each operates on a project directory rather than +// a single zone. +// --copy-project recursive copy of an entire project tree +// --zone-summary one-shot validate + content rollup for a zone +// --bench-bake-project per-zone WHM/WOT load timing benchmark +// +// Returns true if matched; outRc holds the exit code. +bool handleProjectActions(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 a96f8cb0..f613ba83 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -60,6 +60,7 @@ #include "cli_texture_helpers.hpp" #include "cli_mesh_info.hpp" #include "cli_zone_data.hpp" +#include "cli_project_actions.hpp" #include "content_pack.hpp" #include "npc_spawner.hpp" #include "object_placer.hpp" @@ -583,6 +584,9 @@ int main(int argc, char* argv[]) { if (wowee::editor::cli::handleZoneData(i, argc, argv, outRc)) { return outRc; } + if (wowee::editor::cli::handleProjectActions(i, argc, argv, outRc)) { + return outRc; + } } if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { dataPath = argv[++i]; @@ -590,254 +594,6 @@ int main(int argc, char* argv[]) { adtMap = argv[++i]; adtX = std::atoi(argv[++i]); adtY = std::atoi(argv[++i]); - } else if (std::strcmp(argv[i], "--copy-project") == 0 && i + 2 < argc) { - // Recursively copy an entire project tree. Refuses to - // overwrite an existing destination so a typo doesn't - // silently merge into the wrong project. - std::string fromDir = argv[++i]; - std::string toDir = argv[++i]; - namespace fs = std::filesystem; - if (!fs::exists(fromDir) || !fs::is_directory(fromDir)) { - std::fprintf(stderr, - "copy-project: %s is not a directory\n", fromDir.c_str()); - return 1; - } - if (fs::exists(toDir)) { - std::fprintf(stderr, - "copy-project: destination %s already exists " - "(delete it first if intentional)\n", toDir.c_str()); - return 1; - } - std::error_code ec; - fs::copy(fromDir, toDir, - fs::copy_options::recursive | fs::copy_options::copy_symlinks, - ec); - if (ec) { - std::fprintf(stderr, - "copy-project: copy failed (%s)\n", ec.message().c_str()); - return 1; - } - // Count what was copied for the report. - int zoneCount = 0, fileCount = 0; - uint64_t totalBytes = 0; - for (const auto& entry : fs::directory_iterator(toDir, ec)) { - if (entry.is_directory() && - fs::exists(entry.path() / "zone.json")) zoneCount++; - } - for (const auto& e : fs::recursive_directory_iterator(toDir, ec)) { - if (e.is_regular_file()) { - fileCount++; - totalBytes += e.file_size(ec); - } - } - std::printf("Copied %s -> %s\n", fromDir.c_str(), toDir.c_str()); - std::printf(" zones : %d\n", zoneCount); - std::printf(" files : %d\n", fileCount); - std::printf(" total bytes : %llu (%.1f MB)\n", - static_cast(totalBytes), - totalBytes / (1024.0 * 1024.0)); - return 0; - } else if (std::strcmp(argv[i], "--zone-summary") == 0 && i + 1 < argc) { - // One-shot zone overview: validate + creature/object/quest counts. - // Collapses the most common multi-step inspection into a single - // command; useful for CI reports and quick sanity checks. - std::string zoneDir = argv[++i]; - // Optional --json after the dir for machine-readable output. - bool jsonOut = (i + 1 < argc && - std::strcmp(argv[i + 1], "--json") == 0); - if (jsonOut) i++; - namespace fs = std::filesystem; - if (!fs::exists(zoneDir)) { - std::fprintf(stderr, "zone-summary: %s does not exist\n", zoneDir.c_str()); - return 1; - } - auto v = wowee::editor::ContentPacker::validateZone(zoneDir); - - // Read creature/object/quest data once so both human and JSON - // outputs share the same numbers. - int creatureTotal = 0, hostile = 0, qg = 0, vendor = 0; - int objectTotal = 0, m2Count = 0, wmoCount = 0; - int questTotal = 0, chainWarnings = 0; - std::string creaturesPath = zoneDir + "/creatures.json"; - if (fs::exists(creaturesPath)) { - wowee::editor::NpcSpawner sp; - if (sp.loadFromFile(creaturesPath)) { - creatureTotal = static_cast(sp.getSpawns().size()); - for (const auto& s : sp.getSpawns()) { - if (s.hostile) hostile++; - if (s.questgiver) qg++; - if (s.vendor) vendor++; - } - } - } - std::string objectsPath = zoneDir + "/objects.json"; - if (fs::exists(objectsPath)) { - wowee::editor::ObjectPlacer op; - if (op.loadFromFile(objectsPath)) { - objectTotal = static_cast(op.getObjects().size()); - for (const auto& o : op.getObjects()) { - if (o.type == wowee::editor::PlaceableType::M2) m2Count++; - else wmoCount++; - } - } - } - std::string questsPath = zoneDir + "/quests.json"; - if (fs::exists(questsPath)) { - wowee::editor::QuestEditor qe; - if (qe.loadFromFile(questsPath)) { - questTotal = static_cast(qe.getQuests().size()); - std::vector errors; - qe.validateChains(errors); - chainWarnings = static_cast(errors.size()); - } - } - - if (jsonOut) { - nlohmann::json j; - j["zone"] = zoneDir; - j["score"] = v.openFormatScore(); - j["maxScore"] = 7; - j["formats"] = v.summary(); - j["counts"] = { - {"wot", v.wotCount}, {"whm", v.whmCount}, - {"wom", v.womCount}, {"wob", v.wobCount}, - {"woc", v.wocCount}, {"png", v.pngCount}, - }; - j["creatures"] = { - {"total", creatureTotal}, - {"hostile", hostile}, - {"questgiver", qg}, - {"vendor", vendor}, - }; - j["objects"] = { - {"total", objectTotal}, - {"m2", m2Count}, - {"wmo", wmoCount}, - }; - j["quests"] = { - {"total", questTotal}, - {"chainWarnings", chainWarnings}, - }; - std::printf("%s\n", j.dump(2).c_str()); - return v.openFormatScore() == 7 ? 0 : 1; - } - std::printf("Zone: %s\n", zoneDir.c_str()); - std::printf(" open formats : %d/7 (%s)\n", - v.openFormatScore(), v.summary().c_str()); - std::printf(" WOT/WHM : %d/%d WOM: %d WOB: %d WOC: %d PNG: %d\n", - v.wotCount, v.whmCount, v.womCount, v.wobCount, - v.wocCount, v.pngCount); - if (creatureTotal > 0) { - std::printf(" creatures : %d (%d hostile, %d quest, %d vendor)\n", - creatureTotal, hostile, qg, vendor); - } - if (objectTotal > 0) { - std::printf(" objects : %d (%d M2, %d WMO)\n", - objectTotal, m2Count, wmoCount); - } - if (questTotal > 0) { - std::printf(" quests : %d (%d chain warnings)\n", - questTotal, chainWarnings); - } - return v.openFormatScore() == 7 ? 0 : 1; - } else if (std::strcmp(argv[i], "--bench-bake-project") == 0 && i + 1 < argc) { - // Time WHM/WOT load (the dominant cost in --bake-zone-glb/obj/ - // stl) per zone. The actual write side adds ~constant cost - // proportional to vertex count, so load time is a strong - // proxy. Useful for tracking 'has my latest geometry change - // made baking 3× slower?' across releases. - 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-bake-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()); - struct Timing { - std::string name; - int tiles; - double loadMs; - int chunks; - }; - std::vector timings; - double totalMs = 0; - for (const auto& zoneDir : zones) { - wowee::editor::ZoneManifest zm; - if (!zm.load(zoneDir + "/zone.json")) continue; - Timing t{fs::path(zoneDir).filename().string(), 0, 0.0, 0}; - auto t0 = std::chrono::steady_clock::now(); - 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; - wowee::pipeline::ADTTerrain terrain; - wowee::pipeline::WoweeTerrainLoader::load(base, terrain); - t.tiles++; - for (const auto& chunk : terrain.chunks) { - if (chunk.heightMap.isLoaded()) t.chunks++; - } - } - auto t1 = std::chrono::steady_clock::now(); - t.loadMs = std::chrono::duration(t1 - t0).count(); - totalMs += t.loadMs; - timings.push_back(t); - } - double avgMs = !timings.empty() ? totalMs / timings.size() : 0.0; - double minMs = 1e30, maxMs = 0; - std::string slowest; - for (const auto& t : timings) { - if (t.loadMs < minMs) minMs = t.loadMs; - if (t.loadMs > maxMs) { maxMs = t.loadMs; slowest = 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"] = slowest; - nlohmann::json arr = nlohmann::json::array(); - for (const auto& t : timings) { - arr.push_back({{"zone", t.name}, - {"loadMs", t.loadMs}, - {"tiles", t.tiles}, - {"chunks", t.chunks}}); - } - j["perZone"] = arr; - std::printf("%s\n", j.dump(2).c_str()); - return 0; - } - std::printf("Bench bake (load-only): %s\n", projectDir.c_str()); - std::printf(" zones : %zu\n", timings.size()); - std::printf(" total : %.2f ms (terrain load)\n", totalMs); - std::printf(" per zone : avg=%.2f min=%.2f max=%.2f ms\n", - avgMs, minMs, maxMs); - if (!slowest.empty()) { - std::printf(" slowest : %s (%.2f ms)\n", slowest.c_str(), maxMs); - } - std::printf("\n Per-zone:\n"); - std::printf(" zone ms tiles chunks ms/tile\n"); - for (const auto& t : timings) { - double mspt = t.tiles > 0 ? t.loadMs / t.tiles : 0.0; - std::printf(" %-26s %7.2f %5d %5d %6.2f\n", - t.name.substr(0, 26).c_str(), - t.loadMs, t.tiles, t.chunks, mspt); - } - return 0; } else if (std::strcmp(argv[i], "--export-png") == 0 && i + 1 < argc) { // Render heightmap, normal-map, and zone-map PNG previews for a // terrain. Useful for portfolio screenshots, ground-truth map