feat(editor): add --diff-headers cross-file header comparison

Compares two .w* files at the standard catalog header level:
4-byte magic, version, catalog name, entry count, total file
bytes. Useful for confirming a JSON round-trip didn't drift,
checking whether two preset emissions produced equivalent
output, or quickly diagnosing when a content snapshot has
silently shifted (entry count up means content was added,
file bytes up but everything else same means entry payloads
got fatter).

Output uses = / ≠ markers per field so visual scanning is
fast. Three diagnostic summary cases: identical headers (and
same bytes — possibly byte-equal, run cmp(1) to confirm),
same shape but bytes differ (entry payloads diverged), and
different formats entirely (files are unrelated).

Returns exit 1 if any field differs, so the flag composes
into shell pipelines (`if diff-headers a.wcms b.wcms; then ...`).
World/asset formats stop after magic since their layouts
diverge from the standard catalog header.

Supports --json variant for tooling integration.
This commit is contained in:
Kelsi 2026-05-09 21:27:26 -07:00
parent 48984ca375
commit 99a952299b
6 changed files with 205 additions and 1 deletions

View file

@ -1456,6 +1456,7 @@ add_executable(wowee_editor
tools/editor/cli_spell_schools_catalog.cpp
tools/editor/cli_lfg_catalog.cpp
tools/editor/cli_catalog_grep.cpp
tools/editor/cli_diff_headers.cpp
tools/editor/cli_macros_catalog.cpp
tools/editor/cli_char_features_catalog.cpp
tools/editor/cli_pvp_catalog.cpp

View file

@ -136,7 +136,7 @@ const char* const kArgRequired[] = {
"--export-wliq-json", "--import-wliq-json",
"--info-magic", "--summary-dir", "--rename-by-magic",
"--bulk-rename-by-magic", "--touch-tree", "--tree-summary-md",
"--catalog-grep",
"--catalog-grep", "--diff-headers",
"--gen-animations", "--gen-animations-combat", "--gen-animations-movement",
"--info-wani", "--validate-wani",
"--export-wani-json", "--import-wani-json",

View file

@ -0,0 +1,188 @@
#include "cli_diff_headers.hpp"
#include "cli_arg_parse.hpp"
#include "cli_format_table.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace wowee {
namespace editor {
namespace cli {
namespace {
namespace fs = std::filesystem;
struct HeaderInfo {
fs::path path;
char magic[4];
bool magicOk = false;
const FormatMagicEntry* fmt = nullptr;
uint32_t version = 0;
bool versionOk = false;
std::string catalogName;
bool catalogNameOk = false;
uint32_t entryCount = 0;
bool entryCountOk = false;
uintmax_t fileBytes = 0;
};
bool readHeader(const fs::path& path, HeaderInfo& out) {
out.path = path;
if (!fs::exists(path) || !fs::is_regular_file(path)) return false;
out.fileBytes = fs::file_size(path);
std::ifstream is(path, std::ios::binary);
if (!is) return false;
if (!is.read(out.magic, 4) || is.gcount() != 4) return false;
out.magicOk = true;
out.fmt = findFormatByMagic(out.magic);
// Asset / world formats don't have the standard catalog
// header, so stop after magic for those.
if (!out.fmt || out.fmt->infoFlag == nullptr) return true;
if (!is.read(reinterpret_cast<char*>(&out.version), 4)) return true;
out.versionOk = true;
uint32_t nameLen = 0;
if (!is.read(reinterpret_cast<char*>(&nameLen), 4)) return true;
if (nameLen > (1u << 20)) return true;
out.catalogName.resize(nameLen);
if (nameLen > 0) {
if (!is.read(out.catalogName.data(), nameLen)) {
out.catalogName.clear();
return true;
}
}
out.catalogNameOk = true;
if (!is.read(reinterpret_cast<char*>(&out.entryCount), 4)) {
return true;
}
out.entryCountOk = true;
return true;
}
const char* sameOrDiffMarker(bool same) {
return same ? " =" : "";
}
int handleDiff(int& i, int argc, char** argv) {
std::string fileA = argv[++i];
if (i + 1 >= argc) {
std::fprintf(stderr,
"diff-headers: missing second file argument\n");
return 1;
}
std::string fileB = argv[++i];
bool jsonOut = consumeJsonFlag(i, argc, argv);
HeaderInfo a, b;
if (!readHeader(fileA, a)) {
std::fprintf(stderr,
"diff-headers: cannot read %s\n", fileA.c_str());
return 1;
}
if (!readHeader(fileB, b)) {
std::fprintf(stderr,
"diff-headers: cannot read %s\n", fileB.c_str());
return 1;
}
bool magicSame = std::memcmp(a.magic, b.magic, 4) == 0;
bool versionSame = magicSame && a.versionOk && b.versionOk &&
a.version == b.version;
bool nameSame = magicSame && a.catalogNameOk && b.catalogNameOk &&
a.catalogName == b.catalogName;
bool countSame = magicSame && a.entryCountOk && b.entryCountOk &&
a.entryCount == b.entryCount;
bool bytesSame = a.fileBytes == b.fileBytes;
bool allSame = magicSame && versionSame && nameSame &&
countSame && bytesSame;
if (jsonOut) {
nlohmann::json j;
j["fileA"] = fileA;
j["fileB"] = fileB;
j["magicSame"] = magicSame;
j["versionSame"] = versionSame;
j["catalogNameSame"] = nameSame;
j["entryCountSame"] = countSame;
j["bytesSame"] = bytesSame;
j["identicalHeaders"] = allSame;
char ma[5] = {a.magic[0], a.magic[1], a.magic[2], a.magic[3], 0};
char mb[5] = {b.magic[0], b.magic[1], b.magic[2], b.magic[3], 0};
j["a"] = {
{"magic", ma},
{"version", a.version},
{"catalogName", a.catalogName},
{"entryCount", a.entryCount},
{"fileBytes", a.fileBytes},
};
j["b"] = {
{"magic", mb},
{"version", b.version},
{"catalogName", b.catalogName},
{"entryCount", b.entryCount},
{"fileBytes", b.fileBytes},
};
std::printf("%s\n", j.dump(2).c_str());
return allSame ? 0 : 1;
}
char ma[5] = {a.magic[0], a.magic[1], a.magic[2], a.magic[3], 0};
char mb[5] = {b.magic[0], b.magic[1], b.magic[2], b.magic[3], 0};
std::printf("diff-headers:\n A: %s\n B: %s\n",
fileA.c_str(), fileB.c_str());
std::printf("\n");
std::printf(" field A B\n");
std::printf(" ---------- ------------------------ ------------------------\n");
std::printf("%s magic '%s'%s '%s'\n",
sameOrDiffMarker(magicSame), ma,
(a.fmt ? "" : " (unknown)"), mb);
if (magicSame && a.versionOk && b.versionOk) {
std::printf("%s version %-24u %u\n",
sameOrDiffMarker(versionSame),
a.version, b.version);
}
if (magicSame && a.catalogNameOk && b.catalogNameOk) {
std::printf("%s catalogName %-24s %s\n",
sameOrDiffMarker(nameSame),
a.catalogName.c_str(), b.catalogName.c_str());
}
if (magicSame && a.entryCountOk && b.entryCountOk) {
std::printf("%s entryCount %-24u %u\n",
sameOrDiffMarker(countSame),
a.entryCount, b.entryCount);
}
std::printf("%s fileBytes %-24llu %llu\n",
sameOrDiffMarker(bytesSame),
static_cast<unsigned long long>(a.fileBytes),
static_cast<unsigned long long>(b.fileBytes));
std::printf("\n ");
if (allSame) {
std::printf("identical at the header level (and same byte size — "
"possibly byte-equal, run cmp(1) to confirm)\n");
} else if (magicSame && versionSame && nameSame && countSame &&
!bytesSame) {
std::printf("same format / version / name / entry count, "
"but different byte sizes — entry payloads differ\n");
} else if (!magicSame) {
std::printf("DIFFERENT FORMATS — files are unrelated\n");
} else {
std::printf("header fields differ — see ≠ markers above\n");
}
return allSame ? 0 : 1;
}
} // namespace
bool handleDiffHeaders(int& i, int argc, char** argv, int& outRc) {
if (std::strcmp(argv[i], "--diff-headers") == 0 && i + 2 < argc) {
outRc = handleDiff(i, argc, argv); return true;
}
return false;
}
} // namespace cli
} // namespace editor
} // namespace wowee

View file

@ -0,0 +1,11 @@
#pragma once
namespace wowee {
namespace editor {
namespace cli {
bool handleDiffHeaders(int& i, int argc, char** argv, int& outRc);
} // namespace cli
} // namespace editor
} // namespace wowee

View file

@ -93,6 +93,7 @@
#include "cli_spell_schools_catalog.hpp"
#include "cli_lfg_catalog.hpp"
#include "cli_catalog_grep.hpp"
#include "cli_diff_headers.hpp"
#include "cli_macros_catalog.hpp"
#include "cli_char_features_catalog.hpp"
#include "cli_pvp_catalog.hpp"
@ -237,6 +238,7 @@ constexpr DispatchFn kDispatchTable[] = {
handleSpellSchoolsCatalog,
handleLFGCatalog,
handleCatalogGrep,
handleDiffHeaders,
handleMacrosCatalog,
handleCharFeaturesCatalog,
handlePVPCatalog,

View file

@ -1363,6 +1363,8 @@ void printUsage(const char* argv0) {
std::printf(" Emit a Markdown report of a content tree (per-format counts + per-file detail with catalog name + entry count). Stdout if no out path\n");
std::printf(" --catalog-grep <pattern> <dir> [--case-sensitive] [--json]\n");
std::printf(" Recursively search catalog NAMES (the internal name field) across .w* files in <dir>. Case-insensitive by default. Exit 1 if no match\n");
std::printf(" --diff-headers <fileA> <fileB> [--json]\n");
std::printf(" Compare two .w* files at the standard catalog header level (magic / version / name / entry count / file size). Exit 1 if any field differs\n");
std::printf(" --gen-animations <wani-base> [name]\n");
std::printf(" Emit .wani starter: 5 essential animations (Stand / Walk / Run / Death / AttackUnarmed) with fallback chains\n");
std::printf(" --gen-animations-combat <wani-base> [name]\n");