feat(editor): add --export-zone-deps-md for shareable dependency tables

Markdown counterpart to --list-zone-deps. PR reviewers see at a
glance whether every referenced model exists in either open or
proprietary form across the conventional asset roots:

  wowee_editor --export-zone-deps-md custom_zones/MyZone
  # -> custom_zones/MyZone/DEPS.md

# Dependencies — MyZone

*Auto-generated. Status is best-effort — checks zone-local, output/,
custom_zones/, Data/ roots in that order.*

## Direct M2 placements (12)

| Refs | Path | Status |
|---:|---|---|
| 8 | `World/Tree.m2`        | open + proprietary |
| 3 | `World/Lamp.m2`        | open only |
| 1 | `World/Banner.m2`      | MISSING |

Status column resolves each path against zone-local + output/ +
custom_zones/ + Data/ roots, trying both .wob/.wmo for buildings
and .wom/.m2 for models. Catches missing assets BEFORE pack-wcp
would silently include broken refs.

GitHub-Flavored Markdown — sortable by Refs column once rendered,
backtick-wrapped paths so URLs/spaces don't confuse the viewer.

Verified: scaffolded zone with 2 M2 placements (one duplicated) +
1 WMO placement → DEPS.md has 3 sections (one per category) with
correct ref counts (Tree.m2 ×2) and MISSING status for paths that
don't resolve in any root.
This commit is contained in:
Kelsi 2026-05-06 14:44:53 -07:00
parent 84f897f0ec
commit 2904fa0560

View file

@ -428,6 +428,8 @@ static void printUsage(const char* argv0) {
std::printf(" Aggregate counts across every zone in <projectDir>\n");
std::printf(" --list-zone-deps <zoneDir> [--json]\n");
std::printf(" List external M2/WMO model paths a zone references (objects + WOB doodads)\n");
std::printf(" --export-zone-deps-md <zoneDir> [out.md]\n");
std::printf(" Markdown dep table for a zone (with on-disk presence column)\n");
std::printf(" --check-zone-refs <zoneDir> [--json]\n");
std::printf(" Verify every referenced model/quest NPC actually exists; exit 1 on missing refs\n");
std::printf(" --for-each-zone <projectDir> -- <cmd...>\n");
@ -662,7 +664,7 @@ int main(int argc, char* argv[]) {
"--export-zone-csv", "--export-zone-html",
"--scaffold-zone", "--add-tile", "--remove-tile", "--list-tiles",
"--for-each-zone", "--zone-stats", "--list-zone-deps",
"--check-zone-refs",
"--check-zone-refs", "--export-zone-deps-md",
"--add-creature", "--add-object", "--add-quest",
"--add-quest-objective", "--add-quest-reward-item", "--set-quest-reward",
"--remove-quest-objective", "--clone-quest", "--clone-creature",
@ -9155,6 +9157,122 @@ int main(int argc, char* argv[]) {
emit("Direct WMO placements", directWMO);
emit("WOB doodad M2 refs", doodadM2);
return 0;
} else if (std::strcmp(argv[i], "--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
// references plus on-disk presence (so PR reviewers see at a
// glance whether dependencies are accounted for in the
// accompanying asset bundle).
std::string zoneDir = argv[++i];
std::string outPath;
if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i];
namespace fs = std::filesystem;
if (!fs::exists(zoneDir + "/zone.json")) {
std::fprintf(stderr,
"export-zone-deps-md: %s has no zone.json\n", zoneDir.c_str());
return 1;
}
wowee::editor::ZoneManifest zm;
zm.load(zoneDir + "/zone.json");
if (outPath.empty()) outPath = zoneDir + "/DEPS.md";
// Same dep-collection pass as --list-zone-deps.
std::map<std::string, int> directM2;
std::map<std::string, int> directWMO;
std::map<std::string, int> doodadM2;
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]++;
}
}
int wobCount = 0;
std::error_code ec;
for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) {
if (!e.is_regular_file() ||
e.path().extension() != ".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()) doodadM2[d.modelPath]++;
}
}
// Resolve dep on disk. Same heuristic as --check-zone-refs:
// try both open + proprietary in conventional roots.
auto stripExt = [](const std::string& p, const char* ext) {
size_t n = std::strlen(ext);
if (p.size() >= n) {
std::string tail = p.substr(p.size() - n);
std::string lower = tail;
for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c));
if (lower == ext) return p.substr(0, p.size() - n);
}
return p;
};
auto resolveStatus = [&](const std::string& path, bool isWMO) {
std::string base, openExt, propExt;
if (isWMO) {
base = stripExt(path, ".wmo");
openExt = ".wob"; propExt = ".wmo";
} else {
base = stripExt(path, ".m2");
openExt = ".wom"; propExt = ".m2";
}
std::vector<std::string> roots = {
"", zoneDir + "/", "output/", "custom_zones/", "Data/"
};
bool hasOpen = false, hasProp = false;
for (const auto& root : roots) {
if (fs::exists(root + base + openExt)) hasOpen = true;
if (fs::exists(root + base + propExt)) hasProp = true;
}
if (hasOpen && hasProp) return "open + proprietary";
if (hasOpen) return "open only";
if (hasProp) return "proprietary only";
return "MISSING";
};
std::ofstream out(outPath);
if (!out) {
std::fprintf(stderr,
"export-zone-deps-md: cannot write %s\n", outPath.c_str());
return 1;
}
out << "# Dependencies — " <<
(zm.displayName.empty() ? zm.mapName : zm.displayName) << "\n\n";
out << "*Auto-generated by `wowee_editor --export-zone-deps-md`. "
"Status is best-effort — checks zone-local, output/, "
"custom_zones/, Data/ roots in that order.*\n\n";
auto emitTable = [&](const char* heading,
const std::map<std::string,int>& m,
bool isWMO) {
out << "## " << heading << " (" << m.size() << ")\n\n";
if (m.empty()) {
out << "*None.*\n\n";
return;
}
out << "| Refs | Path | Status |\n";
out << "|---:|---|---|\n";
for (const auto& [path, count] : m) {
out << "| " << count << " | `" << path << "` | "
<< resolveStatus(path, isWMO) << " |\n";
}
out << "\n";
};
emitTable("Direct M2 placements", directM2, false);
emitTable("Direct WMO placements", directWMO, true);
emitTable("WOB doodad M2 refs", doodadM2, false);
out << "## Summary\n\n";
out << "- Zone: `" << zm.mapName << "`\n";
out << "- WOBs scanned: " << wobCount << "\n";
out << "- Unique dependencies: " <<
directM2.size() + directWMO.size() + doodadM2.size() << "\n";
out.close();
std::printf("Wrote %s\n", outPath.c_str());
std::printf(" %zu M2 placements, %zu WMO placements, %zu WOB doodad refs\n",
directM2.size(), directWMO.size(), doodadM2.size());
return 0;
} else if (std::strcmp(argv[i], "--check-zone-refs") == 0 && i + 1 < argc) {
// Cross-reference checker: every model path in objects.json
// must resolve as either an open WOM/WOB sidecar or a