Kelsidavis-WoWee/tools/editor/cli_catalog_find.cpp
Kelsi abf264abfe feat(editor): add WBAB (Buff & Aura Book) — 102nd open format
Novel replacement for the implicit rank-chain
relationships that vanilla WoW encoded by burying
nextRank/prevRank pointers inside Spell.dbc with no
explicit graph structure. Each WBAB entry is one long-
duration class buff at one specific rank, with explicit
edges to adjacent ranks via previousRankId and
nextRankId fields. The graph-shaped data is novel among
the 100+ catalog set: most catalogs have flat rows; WBAB
is genuinely a graph where rows are nodes and the rank
fields are edges.

Both directions are stored explicitly so the spellbook
UI's "upgrade to next rank" button can traverse without
scanning the full table. Helper methods walkChainBack-
ToRoot() returns the full chain root->tip for the rank-
picker widget; findChainTip() returns the highest rank
for auto-cast logic.

Three preset emitters demonstrating the pattern:
makeMage (Arcane Intellect ranks 1-4 with chain edges),
makeDruid (Mark of the Wild ranks 1-5 with chain edges),
makeRaidMax (6 max-rank standalone raid buffs — one per
buffing class — with no chain edges to show the
standalone case).

Validator catches several rank-chain-specific bugs:
self-referencing edges (entry.next == entry.id would
create a 1-element cycle), missing referenced entries
(next/prev pointing to non-existent ids), and most
importantly back-edge symmetry — if A.nextRankId=B then
B.previousRankId MUST equal A.buffId or the spellbook
upgrade traversal will derail. Symmetric back-edge check
is unique to graph-shaped catalogs.

Also fixed a crash in --catalog-find where the recursive
directory iterator threw on permission-denied subdirs
(common when walking /tmp). Now uses the
skip_permission_denied directory_options + per-step
error_code clearing for defensive resumption.

Format count 101 -> 102. CLI flag count 1134 -> 1139.
2026-05-10 01:13:42 -07:00

351 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",
};
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