From 6821549856c6093ceadf4e1291274f18b5e91b4e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 09:16:26 -0700 Subject: [PATCH] refactor(editor): extract dep/orphan handlers into cli_deps.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the three asset-dependency analysis handlers (--list-zone-deps, --list-project-orphans, --remove-project-orphans) out of main.cpp into a new cli_deps.{hpp,cpp} module. All three surface the relationship between content references (objects.json + WOB doodad lists) and on-disk model files: list-deps enumerates what a zone needs, list-orphans flips that into 'what's on disk but unreferenced', and remove-orphans deletes the resulting set (with --dry-run for safe previews). main.cpp shrinks by 355 lines (3,319 to 2,964) and finally drops below 3K. The shared 'normalize basename' rule for matching path references stays duplicated between the list and remove handlers — a deliberate tradeoff, with comments flagging the sync requirement. --- CMakeLists.txt | 1 + tools/editor/cli_deps.cpp | 407 ++++++++++++++++++++++++++++++++++++++ tools/editor/cli_deps.hpp | 20 ++ tools/editor/main.cpp | 363 +--------------------------------- 4 files changed, 432 insertions(+), 359 deletions(-) create mode 100644 tools/editor/cli_deps.cpp create mode 100644 tools/editor/cli_deps.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 30c175ff..9c5515ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1352,6 +1352,7 @@ add_executable(wowee_editor tools/editor/cli_makefile.cpp tools/editor/cli_zone_list.cpp tools/editor/cli_tilemap.cpp + tools/editor/cli_deps.cpp tools/editor/editor_app.cpp tools/editor/editor_camera.cpp tools/editor/editor_viewport.cpp diff --git a/tools/editor/cli_deps.cpp b/tools/editor/cli_deps.cpp new file mode 100644 index 00000000..98bfbf9c --- /dev/null +++ b/tools/editor/cli_deps.cpp @@ -0,0 +1,407 @@ +#include "cli_deps.hpp" + +#include "object_placer.hpp" +#include "pipeline/wowee_building.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +int handleListZoneDeps(int& i, int argc, char** argv) { + // Enumerate every external model path a zone references — + // both directly placed (objects.json) and indirectly via + // doodad placements inside any WOB sitting next to the + // zone manifest. Useful when packaging a content pack to + // confirm every needed asset will ship. + 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-deps: %s has no zone.json\n", zoneDir.c_str()); + return 1; + } + // Collect with usage counts so duplicates report '×N' instead + // of cluttering the table. + std::map directM2; // m2 placements + std::map directWMO; // wmo placements + std::map doodadM2; // m2s referenced inside WOBs + wowee::editor::ObjectPlacer op; + if (op.loadFromFile(zoneDir + "/objects.json")) { + for (const auto& o : op.getObjects()) { + if (o.type == wowee::editor::PlaceableType::M2) directM2[o.path]++; + else if (o.type == wowee::editor::PlaceableType::WMO) directWMO[o.path]++; + } + } + // Walk WOBs in the zone directory recursively and pull in + // their doodad model paths. Sub-dirs caught too in case the + // user organizes buildings under a buildings/ subfolder. + int wobCount = 0; + std::error_code ec; + for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { + if (!e.is_regular_file()) continue; + std::string ext = e.path().extension().string(); + if (ext != ".wob") continue; + wobCount++; + std::string base = e.path().string(); + if (base.size() >= 4) base = base.substr(0, base.size() - 4); + auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); + for (const auto& d : bld.doodads) { + if (d.modelPath.empty()) continue; + doodadM2[d.modelPath]++; + } + } + // For each direct WMO placement, also recurse into the WOB + // sitting at that path (relative to the zone) so transitive + // doodad deps surface — this matches the runtime's actual + // load chain. + for (const auto& [path, count] : directWMO) { + // Strip extension since loader takes a base path. + std::string base = path; + if (base.size() >= 4 && base.substr(base.size() - 4) == ".wmo") + base = base.substr(0, base.size() - 4); + // Try relative-to-zone first, then absolute. + std::string trial = zoneDir + "/" + base; + if (!wowee::pipeline::WoweeBuildingLoader::exists(trial)) trial = base; + if (!wowee::pipeline::WoweeBuildingLoader::exists(trial)) continue; + auto bld = wowee::pipeline::WoweeBuildingLoader::load(trial); + for (const auto& d : bld.doodads) { + if (d.modelPath.empty()) continue; + doodadM2[d.modelPath]++; + } + } + size_t totalUnique = directM2.size() + directWMO.size() + doodadM2.size(); + if (jsonOut) { + nlohmann::json j; + j["zone"] = zoneDir; + j["wobCount"] = wobCount; + j["totalUnique"] = totalUnique; + auto toArr = [](const std::map& m) { + nlohmann::json a = nlohmann::json::array(); + for (const auto& [path, count] : m) { + a.push_back({{"path", path}, {"count", count}}); + } + return a; + }; + j["directM2"] = toArr(directM2); + j["directWMO"] = toArr(directWMO); + j["doodadM2"] = toArr(doodadM2); + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("Zone deps: %s\n", zoneDir.c_str()); + std::printf(" WOBs scanned : %d\n", wobCount); + std::printf(" unique paths total : %zu\n", totalUnique); + auto emit = [](const char* tag, const std::map& m) { + std::printf("\n %s (%zu unique):\n", tag, m.size()); + if (m.empty()) { + std::printf(" *none*\n"); + return; + } + for (const auto& [path, count] : m) { + if (count > 1) std::printf(" %s ×%d\n", path.c_str(), count); + else std::printf(" %s\n", path.c_str()); + } + }; + emit("Direct M2 placements", directM2); + emit("Direct WMO placements", directWMO); + emit("WOB doodad M2 refs", doodadM2); + return 0; +} + +int handleListProjectOrphans(int& i, int argc, char** argv) { + // Inverse of --list-zone-deps. Walks every zone in + // , collects the set of .wom/.wob files + // sitting on disk and the set of paths actually + // referenced by objects.json placements + WOB doodad + // lists. Files in the first set but not the second are + // orphans — candidates for removal before --pack-wcp so + // the archive doesn't carry dead weight. + // + // Comparison is by basename (extension stripped) since + // the reference paths sometimes include the extension and + // sometimes don't. + 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-orphans: %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()); + // Project-wide reference set. Normalize by stripping + // extension and any leading "./". + auto normalize = [](std::string p) { + while (p.size() >= 2 && p[0] == '.' && p[1] == '/') p.erase(0, 2); + std::string ext = fs::path(p).extension().string(); + if (ext == ".wom" || ext == ".wob" || ext == ".m2" || ext == ".wmo") { + p = p.substr(0, p.size() - ext.size()); + } + return p; + }; + std::set referencedBases; // normalized basenames + for (const auto& zoneDir : zones) { + wowee::editor::ObjectPlacer op; + if (op.loadFromFile(zoneDir + "/objects.json")) { + for (const auto& o : op.getObjects()) { + if (o.path.empty()) continue; + // Reference can be relative to zone or just a + // bare model name; record both forms for the + // membership test. + std::string norm = normalize(o.path); + referencedBases.insert(norm); + // Also try the leaf basename so unqualified + // refs match. + referencedBases.insert(fs::path(norm).filename().string()); + } + } + std::error_code ec; + for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { + if (!e.is_regular_file()) continue; + if (e.path().extension() != ".wob") continue; + std::string base = e.path().string(); + if (base.size() >= 4) base = base.substr(0, base.size() - 4); + auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); + for (const auto& d : bld.doodads) { + if (d.modelPath.empty()) continue; + std::string norm = normalize(d.modelPath); + referencedBases.insert(norm); + referencedBases.insert(fs::path(norm).filename().string()); + } + } + } + // Now walk every zone again and flag orphan .wom/.wob files. + struct Orphan { std::string zone, path; uint64_t bytes; }; + std::vector orphans; + uint64_t totalOrphanBytes = 0; + for (const auto& zoneDir : zones) { + std::string zoneName = fs::path(zoneDir).filename().string(); + std::error_code ec; + for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { + if (!e.is_regular_file()) continue; + std::string ext = e.path().extension().string(); + if (ext != ".wom" && ext != ".wob") continue; + std::string rel = fs::relative(e.path(), zoneDir, ec).string(); + if (ec) rel = e.path().filename().string(); + std::string normRel = rel.substr(0, rel.size() - ext.size()); + std::string leaf = e.path().stem().string(); + if (referencedBases.count(normRel) || + referencedBases.count(leaf)) { + continue; // referenced, not orphan + } + uint64_t sz = e.file_size(ec); + if (ec) sz = 0; + orphans.push_back({zoneName, rel, sz}); + totalOrphanBytes += sz; + } + } + std::sort(orphans.begin(), orphans.end(), + [](const Orphan& a, const Orphan& b) { + if (a.zone != b.zone) return a.zone < b.zone; + return a.path < b.path; + }); + if (jsonOut) { + nlohmann::json j; + j["project"] = projectDir; + j["referencedCount"] = referencedBases.size(); + j["orphanCount"] = orphans.size(); + j["orphanBytes"] = totalOrphanBytes; + nlohmann::json arr = nlohmann::json::array(); + for (const auto& o : orphans) { + arr.push_back({{"zone", o.zone}, + {"path", o.path}, + {"bytes", o.bytes}}); + } + j["orphans"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("Project orphans: %s\n", projectDir.c_str()); + std::printf(" zones scanned : %zu\n", zones.size()); + std::printf(" refs collected : %zu (normalized basenames)\n", + referencedBases.size()); + std::printf(" orphan .wom/.wob : %zu file(s), %.1f KB\n", + orphans.size(), totalOrphanBytes / 1024.0); + if (orphans.empty()) { + std::printf("\n (no orphans — every model file is referenced)\n"); + return 0; + } + std::printf("\n zone bytes path\n"); + for (const auto& o : orphans) { + std::printf(" %-20s %8llu %s\n", + o.zone.substr(0, 20).c_str(), + static_cast(o.bytes), + o.path.c_str()); + } + return 0; +} + +int handleRemoveProjectOrphans(int& i, int argc, char** argv) { + // Destructive companion to --list-project-orphans. Reuses + // the same reference-collection + orphan-detection logic + // and then deletes the resulting files. --dry-run shows + // what would be removed without touching anything. + std::string projectDir = argv[++i]; + bool dryRun = false; + if (i + 1 < argc && std::strcmp(argv[i + 1], "--dry-run") == 0) { + dryRun = true; i++; + } + namespace fs = std::filesystem; + if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { + std::fprintf(stderr, + "remove-project-orphans: %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()); + // Same normalize + reference collection as --list-project-orphans. + // Keep both functions in sync if the matching rules evolve. + auto normalize = [](std::string p) { + while (p.size() >= 2 && p[0] == '.' && p[1] == '/') p.erase(0, 2); + std::string ext = fs::path(p).extension().string(); + if (ext == ".wom" || ext == ".wob" || ext == ".m2" || ext == ".wmo") { + p = p.substr(0, p.size() - ext.size()); + } + return p; + }; + std::set referencedBases; + for (const auto& zoneDir : zones) { + wowee::editor::ObjectPlacer op; + if (op.loadFromFile(zoneDir + "/objects.json")) { + for (const auto& o : op.getObjects()) { + if (o.path.empty()) continue; + std::string norm = normalize(o.path); + referencedBases.insert(norm); + referencedBases.insert(fs::path(norm).filename().string()); + } + } + std::error_code ec; + for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { + if (!e.is_regular_file()) continue; + if (e.path().extension() != ".wob") continue; + std::string base = e.path().string(); + if (base.size() >= 4) base = base.substr(0, base.size() - 4); + auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); + for (const auto& d : bld.doodads) { + if (d.modelPath.empty()) continue; + std::string norm = normalize(d.modelPath); + referencedBases.insert(norm); + referencedBases.insert(fs::path(norm).filename().string()); + } + } + } + int removed = 0, failed = 0; + uint64_t freedBytes = 0; + for (const auto& zoneDir : zones) { + std::string zoneName = fs::path(zoneDir).filename().string(); + std::error_code ec; + std::vector toRemove; + for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { + if (!e.is_regular_file()) continue; + std::string ext = e.path().extension().string(); + if (ext != ".wom" && ext != ".wob") continue; + std::string rel = fs::relative(e.path(), zoneDir, ec).string(); + if (ec) rel = e.path().filename().string(); + std::string normRel = rel.substr(0, rel.size() - ext.size()); + std::string leaf = e.path().stem().string(); + if (referencedBases.count(normRel) || + referencedBases.count(leaf)) continue; + toRemove.push_back(e.path()); + } + // Materialize the deletion list before removing so we + // don't mutate the directory while iterating. + for (const auto& p : toRemove) { + uint64_t sz = fs::file_size(p, ec); + if (ec) sz = 0; + std::string rel = fs::relative(p, zoneDir, ec).string(); + if (ec) rel = p.filename().string(); + if (dryRun) { + std::printf(" would remove: %s/%s (%llu bytes)\n", + zoneName.c_str(), rel.c_str(), + static_cast(sz)); + removed++; + freedBytes += sz; + } else { + if (fs::remove(p, ec)) { + std::printf(" removed: %s/%s (%llu bytes)\n", + zoneName.c_str(), rel.c_str(), + static_cast(sz)); + removed++; + freedBytes += sz; + } else { + std::fprintf(stderr, + " WARN: failed to remove %s (%s)\n", + p.c_str(), ec.message().c_str()); + failed++; + } + } + } + } + std::printf("\nremove-project-orphans: %s%s\n", + projectDir.c_str(), dryRun ? " (dry-run)" : ""); + std::printf(" zones : %zu\n", zones.size()); + std::printf(" refs : %zu (normalized basenames)\n", + referencedBases.size()); + std::printf(" %s : %d file(s)\n", + dryRun ? "would remove" : "removed ", removed); + std::printf(" freed : %.1f KB\n", freedBytes / 1024.0); + if (failed > 0) { + std::printf(" FAILED : %d (see stderr)\n", failed); + } + if (dryRun && removed > 0) { + std::printf(" re-run without --dry-run to apply\n"); + } + return failed == 0 ? 0 : 1; +} + + +} // namespace + +bool handleDeps(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--list-zone-deps") == 0 && i + 1 < argc) { + outRc = handleListZoneDeps(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--list-project-orphans") == 0 && i + 1 < argc) { + outRc = handleListProjectOrphans(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--remove-project-orphans") == 0 && i + 1 < argc) { + outRc = handleRemoveProjectOrphans(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_deps.hpp b/tools/editor/cli_deps.hpp new file mode 100644 index 00000000..d0d85163 --- /dev/null +++ b/tools/editor/cli_deps.hpp @@ -0,0 +1,20 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +// Dispatch the asset-dependency analysis handlers. All three +// surface the relationship between content references +// (objects.json placements + WOB doodad lists) and on-disk +// model files, supporting --json output for CI pipelines. +// --list-zone-deps enumerate external model paths a zone needs +// --list-project-orphans on-disk .wom/.wob files no zone references +// --remove-project-orphans destructive cleanup (with --dry-run) +// +// Returns true if matched; outRc holds the exit code. +bool handleDeps(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 41eb41ce..d39954b6 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -53,6 +53,7 @@ #include "cli_makefile.hpp" #include "cli_zone_list.hpp" #include "cli_tilemap.hpp" +#include "cli_deps.hpp" #include "content_pack.hpp" #include "npc_spawner.hpp" #include "object_placer.hpp" @@ -552,6 +553,9 @@ int main(int argc, char* argv[]) { if (wowee::editor::cli::handleTilemap(i, argc, argv, outRc)) { return outRc; } + if (wowee::editor::cli::handleDeps(i, argc, argv, outRc)) { + return outRc; + } } if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { dataPath = argv[++i]; @@ -1680,365 +1684,6 @@ int main(int argc, char* argv[]) { std::printf(" next : --add-texture-to-mesh %s\n", destPath.c_str()); return 0; - } else if (std::strcmp(argv[i], "--list-zone-deps") == 0 && i + 1 < argc) { - // Enumerate every external model path a zone references — - // both directly placed (objects.json) and indirectly via - // doodad placements inside any WOB sitting next to the - // zone manifest. Useful when packaging a content pack to - // confirm every needed asset will ship. - 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-deps: %s has no zone.json\n", zoneDir.c_str()); - return 1; - } - // Collect with usage counts so duplicates report '×N' instead - // of cluttering the table. - std::map directM2; // m2 placements - std::map directWMO; // wmo placements - std::map doodadM2; // m2s referenced inside WOBs - wowee::editor::ObjectPlacer op; - if (op.loadFromFile(zoneDir + "/objects.json")) { - for (const auto& o : op.getObjects()) { - if (o.type == wowee::editor::PlaceableType::M2) directM2[o.path]++; - else if (o.type == wowee::editor::PlaceableType::WMO) directWMO[o.path]++; - } - } - // Walk WOBs in the zone directory recursively and pull in - // their doodad model paths. Sub-dirs caught too in case the - // user organizes buildings under a buildings/ subfolder. - int wobCount = 0; - std::error_code ec; - for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { - if (!e.is_regular_file()) continue; - std::string ext = e.path().extension().string(); - if (ext != ".wob") continue; - wobCount++; - std::string base = e.path().string(); - if (base.size() >= 4) base = base.substr(0, base.size() - 4); - auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); - for (const auto& d : bld.doodads) { - if (d.modelPath.empty()) continue; - doodadM2[d.modelPath]++; - } - } - // For each direct WMO placement, also recurse into the WOB - // sitting at that path (relative to the zone) so transitive - // doodad deps surface — this matches the runtime's actual - // load chain. - for (const auto& [path, count] : directWMO) { - // Strip extension since loader takes a base path. - std::string base = path; - if (base.size() >= 4 && base.substr(base.size() - 4) == ".wmo") - base = base.substr(0, base.size() - 4); - // Try relative-to-zone first, then absolute. - std::string trial = zoneDir + "/" + base; - if (!wowee::pipeline::WoweeBuildingLoader::exists(trial)) trial = base; - if (!wowee::pipeline::WoweeBuildingLoader::exists(trial)) continue; - auto bld = wowee::pipeline::WoweeBuildingLoader::load(trial); - for (const auto& d : bld.doodads) { - if (d.modelPath.empty()) continue; - doodadM2[d.modelPath]++; - } - } - size_t totalUnique = directM2.size() + directWMO.size() + doodadM2.size(); - if (jsonOut) { - nlohmann::json j; - j["zone"] = zoneDir; - j["wobCount"] = wobCount; - j["totalUnique"] = totalUnique; - auto toArr = [](const std::map& m) { - nlohmann::json a = nlohmann::json::array(); - for (const auto& [path, count] : m) { - a.push_back({{"path", path}, {"count", count}}); - } - return a; - }; - j["directM2"] = toArr(directM2); - j["directWMO"] = toArr(directWMO); - j["doodadM2"] = toArr(doodadM2); - std::printf("%s\n", j.dump(2).c_str()); - return 0; - } - std::printf("Zone deps: %s\n", zoneDir.c_str()); - std::printf(" WOBs scanned : %d\n", wobCount); - std::printf(" unique paths total : %zu\n", totalUnique); - auto emit = [](const char* tag, const std::map& m) { - std::printf("\n %s (%zu unique):\n", tag, m.size()); - if (m.empty()) { - std::printf(" *none*\n"); - return; - } - for (const auto& [path, count] : m) { - if (count > 1) std::printf(" %s ×%d\n", path.c_str(), count); - else std::printf(" %s\n", path.c_str()); - } - }; - emit("Direct M2 placements", directM2); - emit("Direct WMO placements", directWMO); - emit("WOB doodad M2 refs", doodadM2); - return 0; - } else if (std::strcmp(argv[i], "--list-project-orphans") == 0 && i + 1 < argc) { - // Inverse of --list-zone-deps. Walks every zone in - // , collects the set of .wom/.wob files - // sitting on disk and the set of paths actually - // referenced by objects.json placements + WOB doodad - // lists. Files in the first set but not the second are - // orphans — candidates for removal before --pack-wcp so - // the archive doesn't carry dead weight. - // - // Comparison is by basename (extension stripped) since - // the reference paths sometimes include the extension and - // sometimes don't. - 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-orphans: %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()); - // Project-wide reference set. Normalize by stripping - // extension and any leading "./". - auto normalize = [](std::string p) { - while (p.size() >= 2 && p[0] == '.' && p[1] == '/') p.erase(0, 2); - std::string ext = fs::path(p).extension().string(); - if (ext == ".wom" || ext == ".wob" || ext == ".m2" || ext == ".wmo") { - p = p.substr(0, p.size() - ext.size()); - } - return p; - }; - std::set referencedBases; // normalized basenames - for (const auto& zoneDir : zones) { - wowee::editor::ObjectPlacer op; - if (op.loadFromFile(zoneDir + "/objects.json")) { - for (const auto& o : op.getObjects()) { - if (o.path.empty()) continue; - // Reference can be relative to zone or just a - // bare model name; record both forms for the - // membership test. - std::string norm = normalize(o.path); - referencedBases.insert(norm); - // Also try the leaf basename so unqualified - // refs match. - referencedBases.insert(fs::path(norm).filename().string()); - } - } - std::error_code ec; - for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { - if (!e.is_regular_file()) continue; - if (e.path().extension() != ".wob") continue; - std::string base = e.path().string(); - if (base.size() >= 4) base = base.substr(0, base.size() - 4); - auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); - for (const auto& d : bld.doodads) { - if (d.modelPath.empty()) continue; - std::string norm = normalize(d.modelPath); - referencedBases.insert(norm); - referencedBases.insert(fs::path(norm).filename().string()); - } - } - } - // Now walk every zone again and flag orphan .wom/.wob files. - struct Orphan { std::string zone, path; uint64_t bytes; }; - std::vector orphans; - uint64_t totalOrphanBytes = 0; - for (const auto& zoneDir : zones) { - std::string zoneName = fs::path(zoneDir).filename().string(); - std::error_code ec; - for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { - if (!e.is_regular_file()) continue; - std::string ext = e.path().extension().string(); - if (ext != ".wom" && ext != ".wob") continue; - std::string rel = fs::relative(e.path(), zoneDir, ec).string(); - if (ec) rel = e.path().filename().string(); - std::string normRel = rel.substr(0, rel.size() - ext.size()); - std::string leaf = e.path().stem().string(); - if (referencedBases.count(normRel) || - referencedBases.count(leaf)) { - continue; // referenced, not orphan - } - uint64_t sz = e.file_size(ec); - if (ec) sz = 0; - orphans.push_back({zoneName, rel, sz}); - totalOrphanBytes += sz; - } - } - std::sort(orphans.begin(), orphans.end(), - [](const Orphan& a, const Orphan& b) { - if (a.zone != b.zone) return a.zone < b.zone; - return a.path < b.path; - }); - if (jsonOut) { - nlohmann::json j; - j["project"] = projectDir; - j["referencedCount"] = referencedBases.size(); - j["orphanCount"] = orphans.size(); - j["orphanBytes"] = totalOrphanBytes; - nlohmann::json arr = nlohmann::json::array(); - for (const auto& o : orphans) { - arr.push_back({{"zone", o.zone}, - {"path", o.path}, - {"bytes", o.bytes}}); - } - j["orphans"] = arr; - std::printf("%s\n", j.dump(2).c_str()); - return 0; - } - std::printf("Project orphans: %s\n", projectDir.c_str()); - std::printf(" zones scanned : %zu\n", zones.size()); - std::printf(" refs collected : %zu (normalized basenames)\n", - referencedBases.size()); - std::printf(" orphan .wom/.wob : %zu file(s), %.1f KB\n", - orphans.size(), totalOrphanBytes / 1024.0); - if (orphans.empty()) { - std::printf("\n (no orphans — every model file is referenced)\n"); - return 0; - } - std::printf("\n zone bytes path\n"); - for (const auto& o : orphans) { - std::printf(" %-20s %8llu %s\n", - o.zone.substr(0, 20).c_str(), - static_cast(o.bytes), - o.path.c_str()); - } - return 0; - } else if (std::strcmp(argv[i], "--remove-project-orphans") == 0 && i + 1 < argc) { - // Destructive companion to --list-project-orphans. Reuses - // the same reference-collection + orphan-detection logic - // and then deletes the resulting files. --dry-run shows - // what would be removed without touching anything. - std::string projectDir = argv[++i]; - bool dryRun = false; - if (i + 1 < argc && std::strcmp(argv[i + 1], "--dry-run") == 0) { - dryRun = true; i++; - } - namespace fs = std::filesystem; - if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { - std::fprintf(stderr, - "remove-project-orphans: %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()); - // Same normalize + reference collection as --list-project-orphans. - // Keep both functions in sync if the matching rules evolve. - auto normalize = [](std::string p) { - while (p.size() >= 2 && p[0] == '.' && p[1] == '/') p.erase(0, 2); - std::string ext = fs::path(p).extension().string(); - if (ext == ".wom" || ext == ".wob" || ext == ".m2" || ext == ".wmo") { - p = p.substr(0, p.size() - ext.size()); - } - return p; - }; - std::set referencedBases; - for (const auto& zoneDir : zones) { - wowee::editor::ObjectPlacer op; - if (op.loadFromFile(zoneDir + "/objects.json")) { - for (const auto& o : op.getObjects()) { - if (o.path.empty()) continue; - std::string norm = normalize(o.path); - referencedBases.insert(norm); - referencedBases.insert(fs::path(norm).filename().string()); - } - } - std::error_code ec; - for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { - if (!e.is_regular_file()) continue; - if (e.path().extension() != ".wob") continue; - std::string base = e.path().string(); - if (base.size() >= 4) base = base.substr(0, base.size() - 4); - auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); - for (const auto& d : bld.doodads) { - if (d.modelPath.empty()) continue; - std::string norm = normalize(d.modelPath); - referencedBases.insert(norm); - referencedBases.insert(fs::path(norm).filename().string()); - } - } - } - int removed = 0, failed = 0; - uint64_t freedBytes = 0; - for (const auto& zoneDir : zones) { - std::string zoneName = fs::path(zoneDir).filename().string(); - std::error_code ec; - std::vector toRemove; - for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { - if (!e.is_regular_file()) continue; - std::string ext = e.path().extension().string(); - if (ext != ".wom" && ext != ".wob") continue; - std::string rel = fs::relative(e.path(), zoneDir, ec).string(); - if (ec) rel = e.path().filename().string(); - std::string normRel = rel.substr(0, rel.size() - ext.size()); - std::string leaf = e.path().stem().string(); - if (referencedBases.count(normRel) || - referencedBases.count(leaf)) continue; - toRemove.push_back(e.path()); - } - // Materialize the deletion list before removing so we - // don't mutate the directory while iterating. - for (const auto& p : toRemove) { - uint64_t sz = fs::file_size(p, ec); - if (ec) sz = 0; - std::string rel = fs::relative(p, zoneDir, ec).string(); - if (ec) rel = p.filename().string(); - if (dryRun) { - std::printf(" would remove: %s/%s (%llu bytes)\n", - zoneName.c_str(), rel.c_str(), - static_cast(sz)); - removed++; - freedBytes += sz; - } else { - if (fs::remove(p, ec)) { - std::printf(" removed: %s/%s (%llu bytes)\n", - zoneName.c_str(), rel.c_str(), - static_cast(sz)); - removed++; - freedBytes += sz; - } else { - std::fprintf(stderr, - " WARN: failed to remove %s (%s)\n", - p.c_str(), ec.message().c_str()); - failed++; - } - } - } - } - std::printf("\nremove-project-orphans: %s%s\n", - projectDir.c_str(), dryRun ? " (dry-run)" : ""); - std::printf(" zones : %zu\n", zones.size()); - std::printf(" refs : %zu (normalized basenames)\n", - referencedBases.size()); - std::printf(" %s : %d file(s)\n", - dryRun ? "would remove" : "removed ", removed); - std::printf(" freed : %.1f KB\n", freedBytes / 1024.0); - if (failed > 0) { - std::printf(" FAILED : %d (see stderr)\n", failed); - } - if (dryRun && removed > 0) { - std::printf(" re-run without --dry-run to apply\n"); - } - return failed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--export-zone-deps-md") == 0 && i + 1 < argc) { // Markdown counterpart to --list-zone-deps. Writes a sortable // GitHub-rendered table of every external model the zone