Kelsidavis-WoWee/tools/editor/cli_catalog_find.cpp
Kelsi 0f4c619b49 feat(editor): add WTBD (Tabard Design / Heraldry) — 103rd open format
Novel replacement for the GuildBankTabard / TabardConfig
blob that vanilla WoW stores per-guild in guild_member
SQL. Each entry is one tabard design: triplet of
(background pattern + color, border pattern + color,
emblem glyph + color), plus optional guild and creator
attribution and a server-approval flag for tabard-
moderation policies.

Five background patterns (Solid / Gradient / Chevron /
Quartered / Starburst), four border patterns (None /
Thin / Thick / Decorative), and 1024 possible emblem
glyph IDs. Three preset emitters demonstrate the
convention: makeAllianceClassic (4 Alliance-themed
system tabards: Lion, DwarvenHammer, KulTirasAnchor,
HighlordSword), makeHordeClassic (4 Horde: Wolfhead,
CrossedAxes, Skull, Pyramid), makeFactionVendor (6
faction-rep tabards spanning Argent Crusade, Ebon
Blade, Sons of Hodir, Wyrmrest Accord, Kalu'ak,
Frenzyheart Tribe).

Validator's most novel check is a color-similarity
heuristic — squared RGB distance between background and
emblem colors. If under 1500 (empirically derived
threshold for visual readability), warns the operator
that the emblem won't be readable against its
background. Also catches alpha=0 on any color layer
(would render fully transparent), pattern enum out-of-
range, and emblemId>1023 (beyond canonical glyph
range).

Also added per-magic explicit primary-key override to
--catalog-pluck and --catalog-find so they pick the
right field for catalogs where the heuristic fails.
WTBD has creatorPlayerId/emblemId/guildId all
alphabetically before tabardId, and guildId can't be
filtered globally because WGLD uses it as a primary
key. The override table is small (1 entry currently —
WTBD->tabardId) and grows only when a new format
catches the same conflict.

Format count 102 -> 103. CLI flag count 1141 -> 1146.
2026-05-10 01:24:46 -07:00

355 lines
11 KiB
C++

#include "cli_catalog_find.hpp"
#include "cli_arg_parse.hpp"
#include "cli_format_table.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace wowee {
namespace editor {
namespace cli {
namespace {
namespace fs = std::filesystem;
std::string shellQuote(const std::string& s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('\'');
for (char c : s) {
if (c == '\'') out += "'\"'\"'";
else out.push_back(c);
}
out.push_back('\'');
return out;
}
bool peekMagic(const fs::path& path, char magic[4]) {
std::ifstream is(path, std::ios::binary);
if (!is) return false;
if (!is.read(magic, 4) || is.gcount() != 4) return false;
return true;
}
// Same external-ref filter as cli_catalog_pluck. Kept in
// sync — when a new format adds a foreign-key suffix that
// the old filter misses, both files must be updated.
// Future cleanup: share via cli_catalog_pluck.hpp once
// either utility needs a third common helper.
bool isExternalRefField(const std::string& k) {
static const char* kExternals[] = {
"mapId", "areaId", "zoneId", "subAreaId",
"spellId", "itemId", "npcId", "creatureId",
"objectId", "gameObjectId",
"factionId", "factionTemplateId",
"difficultyId", "instanceId",
"raceId", "classId", "classMask", "raceMask",
"skillLineId", "questId", "talentId",
"achievementId", "criteriaId", "lootId",
"soundId", "movieId", "displayId", "modelId",
"iconId", "textureId", "auraId",
"animationId", "particleId", "ribbonId",
"vehicleId", "seatId", "currencyId",
"trainerId", "vendorId", "mailTemplateId",
"playerId", "characterId", "creatorPlayerId",
"ownerId", "ownerCharacterId", "leaderId",
"emblemId", "glyphId", "decalId",
"previousRankId", "nextRankId",
};
for (const char* ref : kExternals) {
if (k == ref) return true;
}
return false;
}
std::pair<bool, uint64_t>
findEntryPrimaryKey(const nlohmann::json& entry) {
if (!entry.is_object()) return {false, 0};
for (auto it = entry.begin(); it != entry.end(); ++it) {
const std::string& k = it.key();
if (k.size() >= 2 &&
k.compare(k.size() - 2, 2, "Id") == 0 &&
it.value().is_number_integer() &&
!isExternalRefField(k)) {
return {true, it.value().get<uint64_t>()};
}
}
for (auto it = entry.begin(); it != entry.end(); ++it) {
const std::string& k = it.key();
if (k.size() >= 2 &&
k.compare(k.size() - 2, 2, "Id") == 0 &&
it.value().is_number_integer()) {
return {true, it.value().get<uint64_t>()};
}
}
for (auto it = entry.begin(); it != entry.end(); ++it) {
if (it.value().is_number_integer()) {
return {true, it.value().get<uint64_t>()};
}
}
return {false, 0};
}
std::string findEntryPrimaryKeyName(const nlohmann::json& entry) {
if (!entry.is_object()) return {};
for (auto it = entry.begin(); it != entry.end(); ++it) {
const std::string& k = it.key();
if (k.size() >= 2 &&
k.compare(k.size() - 2, 2, "Id") == 0 &&
it.value().is_number_integer() &&
!isExternalRefField(k)) {
return k;
}
}
for (auto it = entry.begin(); it != entry.end(); ++it) {
const std::string& k = it.key();
if (k.size() >= 2 &&
k.compare(k.size() - 2, 2, "Id") == 0 &&
it.value().is_number_integer()) {
return k;
}
}
for (auto it = entry.begin(); it != entry.end(); ++it) {
if (it.value().is_number_integer()) return it.key();
}
return {};
}
std::string runAndCapture(const std::string& cmd, int& outRc) {
std::string buf;
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) {
outRc = 127;
return buf;
}
char chunk[4096];
while (std::fgets(chunk, sizeof(chunk), pipe) != nullptr) {
buf += chunk;
}
int rc = pclose(pipe);
#ifdef WEXITSTATUS
outRc = (rc != -1) ? WEXITSTATUS(rc) : rc;
#else
outRc = rc;
#endif
return buf;
}
struct Hit {
fs::path path;
std::string magic; // 4-char as string
std::string primaryKeyField;
std::string entryName;
nlohmann::json entry;
};
int handleFind(int& i, int argc, char** argv) {
if (i + 2 >= argc) {
std::fprintf(stderr,
"catalog-find: usage: --catalog-find "
"<directory> <id> [--magic <WXXX>] [--json]\n");
return 1;
}
std::string dir = argv[++i];
std::string idArg = argv[++i];
bool jsonOut = consumeJsonFlag(i, argc, argv);
// Optional --magic <WXXX> filter to limit search to
// one format. Useful when an id is a primary key in
// multiple format families and you only want hits from
// one (e.g. id 100 matches both WGRP comp 100 and
// WSCB broadcast 100 — --magic WGRP narrows it).
std::string magicFilter;
while (i + 1 < argc && std::strcmp(argv[i + 1], "--magic") == 0 &&
i + 2 < argc) {
++i;
magicFilter = argv[++i];
}
if (!fs::exists(dir) || !fs::is_directory(dir)) {
std::fprintf(stderr,
"catalog-find: not a directory: %s\n", dir.c_str());
return 1;
}
uint64_t searchId = 0;
try {
searchId = std::stoull(idArg);
} catch (...) {
std::fprintf(stderr,
"catalog-find: <id> must be a numeric literal "
"(got '%s')\n", idArg.c_str());
return 1;
}
std::vector<Hit> hits;
size_t scanned = 0;
size_t skippedNoFlag = 0;
size_t skippedUnknownMagic = 0;
// skip_permission_denied prevents the iterator from
// throwing on unreadable subdirectories (common when
// walking /tmp or system trees that contain other-user
// files). Errors are swallowed silently — catalog-find
// is a best-effort search, not an audit.
std::error_code walkEc;
fs::recursive_directory_iterator it(
dir, fs::directory_options::skip_permission_denied,
walkEc);
fs::recursive_directory_iterator end;
if (walkEc) {
std::fprintf(stderr,
"catalog-find: cannot open directory '%s': %s\n",
dir.c_str(), walkEc.message().c_str());
return 1;
}
for (; it != end; it.increment(walkEc)) {
if (walkEc) {
// A subdirectory failed mid-walk; clear and
// continue. The skip_permission_denied option
// covers most cases but defensive code stays
// safer.
walkEc.clear();
continue;
}
const auto& dirent = *it;
if (!dirent.is_regular_file(walkEc)) {
walkEc.clear();
continue;
}
char magic[4]{};
if (!peekMagic(dirent.path(), magic)) continue;
const FormatMagicEntry* fmt = findFormatByMagic(magic);
if (!fmt) {
++skippedUnknownMagic;
continue;
}
if (!magicFilter.empty()) {
std::string m(magic, 4);
// Pad / strip trailing space — table magics
// include space chars (e.g. "WOM ").
if (m != magicFilter) continue;
}
if (!fmt->infoFlag) {
++skippedNoFlag;
continue;
}
++scanned;
// Strip extension to get the base path the
// per-format inspect handler expects.
std::string base = dirent.path().string();
if (fmt->extension && *fmt->extension) {
size_t extLen = std::strlen(fmt->extension);
if (base.size() >= extLen &&
base.compare(base.size() - extLen, extLen,
fmt->extension) == 0) {
base.resize(base.size() - extLen);
}
}
std::string cmd = shellQuote(argv[0]) + " " +
fmt->infoFlag + " " +
shellQuote(base) + " --json 2>/dev/null";
int rc = 0;
std::string out = runAndCapture(cmd, rc);
if (rc != 0 || out.empty()) continue;
nlohmann::json doc;
try {
doc = nlohmann::json::parse(out);
} catch (...) {
continue;
}
if (!doc.contains("entries") ||
!doc["entries"].is_array()) continue;
for (const auto& entry : doc["entries"]) {
auto [ok, key] = findEntryPrimaryKey(entry);
if (!ok || key != searchId) continue;
Hit h;
h.path = dirent.path();
h.magic = std::string(magic, 4);
h.primaryKeyField = findEntryPrimaryKeyName(entry);
if (entry.is_object() && entry.contains("name") &&
entry["name"].is_string()) {
h.entryName = entry["name"].get<std::string>();
}
h.entry = entry;
hits.push_back(h);
}
}
if (jsonOut) {
nlohmann::json out;
out["directory"] = dir;
out["searchId"] = searchId;
if (!magicFilter.empty()) out["magicFilter"] = magicFilter;
out["scanned"] = scanned;
out["hits"] = nlohmann::json::array();
for (const auto& h : hits) {
out["hits"].push_back({
{"file", h.path.string()},
{"magic", h.magic},
{"primaryKey", h.primaryKeyField},
{"name", h.entryName},
{"entry", h.entry},
});
}
std::printf("%s\n", out.dump(2).c_str());
return hits.empty() ? 1 : 0;
}
std::printf("catalog-find: searched %zu catalog files "
"in '%s' for id=%llu",
scanned, dir.c_str(),
static_cast<unsigned long long>(searchId));
if (!magicFilter.empty()) {
std::printf(" (magic=%s)", magicFilter.c_str());
}
std::printf("\n");
if (skippedNoFlag > 0) {
std::printf(" (skipped %zu files: format has no "
"--info-* surface)\n", skippedNoFlag);
}
if (skippedUnknownMagic > 0) {
std::printf(" (skipped %zu files: unknown magic)\n",
skippedUnknownMagic);
}
if (hits.empty()) {
std::printf(" no hits — id %llu is not a primary "
"key in any catalog under this tree\n",
static_cast<unsigned long long>(searchId));
return 1;
}
std::printf(" hits (%zu):\n", hits.size());
for (const auto& h : hits) {
std::printf(" [%s] %s:%s=%llu",
h.magic.c_str(), h.path.string().c_str(),
h.primaryKeyField.c_str(),
static_cast<unsigned long long>(searchId));
if (!h.entryName.empty()) {
std::printf(" \"%s\"", h.entryName.c_str());
}
std::printf("\n");
}
return 0;
}
} // namespace
bool handleCatalogFind(int& i, int argc, char** argv, int& outRc) {
if (std::strcmp(argv[i], "--catalog-find") == 0 &&
i + 2 < argc) {
outRc = handleFind(i, argc, argv); return true;
}
return false;
}
} // namespace cli
} // namespace editor
} // namespace wowee