feat(editor): add --diff-zone for comparing two unpacked zone dirs

The natural counterpart to --diff-wcp (which compares two .wcp
archives) — operates on the unpacked side of the workflow. Shows
exactly what changed across zone.json fields, creature roster,
object placements, and quest list:

  wowee_editor --diff-zone custom_zones/Base custom_zones/Variant

  Diff: custom_zones/Base vs custom_zones/Variant
    manifest  : 2 field diff(s)
      ~ mapName: 'Base' -> 'Variant'
      ~ displayName: 'Base' -> 'Variant'
    creatures : 2 vs 2
      - Bear
      + Tiger
    quests    : 1 vs 2
      + Defeat the Tiger

Pairs naturally with --copy-zone: template a base zone, fork a
variant, then diff to see exactly what was customized. Useful for
PR review when a designer modifies a zone — diff against the
upstream version to scope the change.

Comparison strategy: sorted set diff on stable identifying fields
(creature.name, object.path, quest.title). This intentionally hides
position/orientation changes since those are continuous and would
flag every pixel-perfect tweak as a diff — content-level changes
are the signal here.

Exit 0 if identical, 1 otherwise (so CI can gate). JSON mode emits
per-category onlyA/onlyB arrays + manifestDiffs list + totalDiffs
count for programmatic consumption.

Verified: diffed two zones forked from a common base (one with
creature swap + new quest); reported 5 diffs across manifest +
creatures + quests with exit 1. Same zone vs itself reports
IDENTICAL with exit 0.
This commit is contained in:
Kelsi 2026-05-06 12:10:22 -07:00
parent df4e0a30a7
commit fdc7ca7ee7

View file

@ -472,6 +472,8 @@ static void printUsage(const char* argv0) {
std::printf(" --list-wcp <wcp-path> Print every file inside a WCP archive (sorted by path) and exit\n");
std::printf(" --diff-wcp <a> <b> [--json]\n");
std::printf(" Compare two WCPs file-by-file; exit 0 if identical, 1 otherwise\n");
std::printf(" --diff-zone <a> <b> [--json]\n");
std::printf(" Compare two zone dirs (creatures/objects/quests/manifest); exit 0 if identical\n");
std::printf(" --pack-wcp <zone> [dst] Pack a zone dir/name into a .wcp archive and exit\n");
std::printf(" --unpack-wcp <wcp> [dst] Extract a WCP archive (default dst=custom_zones/) and exit\n");
std::printf(" --version Show version and format info\n\n");
@ -513,6 +515,11 @@ int main(int argc, char* argv[]) {
std::fprintf(stderr, "--adt requires <map> <x> <y>\n");
return 1;
}
if (std::strcmp(argv[i], "--diff-zone") == 0 && i + 2 >= argc) {
std::fprintf(stderr,
"--diff-zone requires <zoneA> <zoneB>\n");
return 1;
}
if (std::strcmp(argv[i], "--diff-wcp") == 0 && i + 2 >= argc) {
std::fprintf(stderr, "--diff-wcp requires two paths\n");
return 1;
@ -1272,6 +1279,145 @@ int main(int argc, char* argv[]) {
for (const auto& s : onlyAList) std::printf(" - %s\n", s.c_str());
for (const auto& s : onlyBList) std::printf(" + %s\n", s.c_str());
return (onlyA + onlyB + sizeChanged) == 0 ? 0 : 1;
} else if (std::strcmp(argv[i], "--diff-zone") == 0 && i + 2 < argc) {
// Compare two unpacked zone directories: zone.json fields,
// creature names, object paths, quest titles. Useful when a
// designer wants to see what changed between an upstream
// template (--copy-zone source) and their customized variant,
// or to verify a refactor only touched what it claimed to.
std::string aDir = argv[++i];
std::string bDir = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) i++;
namespace fs = std::filesystem;
for (const auto& d : {aDir, bDir}) {
if (!fs::exists(d + "/zone.json")) {
std::fprintf(stderr,
"diff-zone: %s has no zone.json — not a zone dir\n",
d.c_str());
return 1;
}
}
wowee::editor::ZoneManifest aZ, bZ;
aZ.load(aDir + "/zone.json");
bZ.load(bDir + "/zone.json");
// Helper: load a sub-file if present, returning empty container
// when missing — both sides may legitimately omit a content
// file (e.g. a quest-free zone) without that being a diff per se.
auto loadCreatures = [](const std::string& dir) {
std::vector<std::string> names;
wowee::editor::NpcSpawner sp;
if (sp.loadFromFile(dir + "/creatures.json")) {
for (const auto& s : sp.getSpawns()) names.push_back(s.name);
}
std::sort(names.begin(), names.end());
return names;
};
auto loadObjectPaths = [](const std::string& dir) {
std::vector<std::string> paths;
wowee::editor::ObjectPlacer op;
if (op.loadFromFile(dir + "/objects.json")) {
for (const auto& o : op.getObjects()) paths.push_back(o.path);
}
std::sort(paths.begin(), paths.end());
return paths;
};
auto loadQuestTitles = [](const std::string& dir) {
std::vector<std::string> titles;
wowee::editor::QuestEditor qe;
if (qe.loadFromFile(dir + "/quests.json")) {
for (const auto& q : qe.getQuests()) titles.push_back(q.title);
}
std::sort(titles.begin(), titles.end());
return titles;
};
auto aCreatures = loadCreatures(aDir);
auto bCreatures = loadCreatures(bDir);
auto aObjects = loadObjectPaths(aDir);
auto bObjects = loadObjectPaths(bDir);
auto aQuests = loadQuestTitles(aDir);
auto bQuests = loadQuestTitles(bDir);
// Set diff: returns (onlyA, onlyB) where each is a sorted list.
auto setDiff = [](const std::vector<std::string>& a,
const std::vector<std::string>& b) {
std::vector<std::string> onlyA, onlyB;
std::set_difference(a.begin(), a.end(), b.begin(), b.end(),
std::back_inserter(onlyA));
std::set_difference(b.begin(), b.end(), a.begin(), a.end(),
std::back_inserter(onlyB));
return std::pair{onlyA, onlyB};
};
auto [creatOnlyA, creatOnlyB] = setDiff(aCreatures, bCreatures);
auto [objOnlyA, objOnlyB] = setDiff(aObjects, bObjects);
auto [questOnlyA, questOnlyB] = setDiff(aQuests, bQuests);
// Manifest field diffs.
std::vector<std::string> manifestDiffs;
auto cmp = [&](const char* field, const std::string& a,
const std::string& b) {
if (a != b) {
manifestDiffs.push_back(std::string(field) + ": '" +
a + "' -> '" + b + "'");
}
};
cmp("mapName", aZ.mapName, bZ.mapName);
cmp("displayName", aZ.displayName, bZ.displayName);
cmp("biome", aZ.biome, bZ.biome);
cmp("musicTrack", aZ.musicTrack, bZ.musicTrack);
if (aZ.mapId != bZ.mapId) {
manifestDiffs.push_back("mapId: " + std::to_string(aZ.mapId) +
" -> " + std::to_string(bZ.mapId));
}
if (aZ.tiles.size() != bZ.tiles.size()) {
manifestDiffs.push_back("tile count: " + std::to_string(aZ.tiles.size()) +
" -> " + std::to_string(bZ.tiles.size()));
}
int diffs = manifestDiffs.size() +
creatOnlyA.size() + creatOnlyB.size() +
objOnlyA.size() + objOnlyB.size() +
questOnlyA.size() + questOnlyB.size();
if (jsonOut) {
nlohmann::json j;
j["a"] = aDir;
j["b"] = bDir;
j["identical"] = (diffs == 0);
j["manifestDiffs"] = manifestDiffs;
j["creatures"] = {{"a", aCreatures.size()},
{"b", bCreatures.size()},
{"onlyA", creatOnlyA},
{"onlyB", creatOnlyB}};
j["objects"] = {{"a", aObjects.size()},
{"b", bObjects.size()},
{"onlyA", objOnlyA},
{"onlyB", objOnlyB}};
j["quests"] = {{"a", aQuests.size()},
{"b", bQuests.size()},
{"onlyA", questOnlyA},
{"onlyB", questOnlyB}};
j["totalDiffs"] = diffs;
std::printf("%s\n", j.dump(2).c_str());
return diffs == 0 ? 0 : 1;
}
std::printf("Diff: %s vs %s\n", aDir.c_str(), bDir.c_str());
if (diffs == 0) {
std::printf(" IDENTICAL\n");
return 0;
}
std::printf(" manifest : %zu field diff(s)\n", manifestDiffs.size());
for (const auto& d : manifestDiffs) std::printf(" ~ %s\n", d.c_str());
std::printf(" creatures : %zu vs %zu\n",
aCreatures.size(), bCreatures.size());
for (const auto& s : creatOnlyA) std::printf(" - %s\n", s.c_str());
for (const auto& s : creatOnlyB) std::printf(" + %s\n", s.c_str());
std::printf(" objects : %zu vs %zu\n",
aObjects.size(), bObjects.size());
for (const auto& s : objOnlyA) std::printf(" - %s\n", s.c_str());
for (const auto& s : objOnlyB) std::printf(" + %s\n", s.c_str());
std::printf(" quests : %zu vs %zu\n",
aQuests.size(), bQuests.size());
for (const auto& s : questOnlyA) std::printf(" - %s\n", s.c_str());
for (const auto& s : questOnlyB) std::printf(" + %s\n", s.c_str());
return 1;
} else if (std::strcmp(argv[i], "--list-wcp") == 0 && i + 1 < argc) {
// Like --info-wcp but prints every file path. Useful for spotting
// missing or unexpected entries before unpacking.