Kelsidavis-WoWee/tools/editor/cli_catalog_pluck.cpp
Kelsi c9b822002f feat(editor): add WEMO (Emote Definition) — 101st open format
Novel replacement for the EmotesText.dbc + EmotesTextSound
+ EmotesTextData trio that maps /slash-emote commands
(/dance, /wave, /laugh, etc.) to their visible chat text,
animation ID, and per-race voice clip. Each entry binds
one slashCommand to an animationId (refs WANI), soundId
(refs WSND), targetMessage / noTargetMessage formats,
emote kind (Social / Combat / RolePlay / System), sex
filter (Both / Male / Female), required race bit, and a
TTS hint (Talk / Whisper / Yell / Silent) for accessibility
text-to-speech engines.

Three preset emitters covering the canonical emote
buckets: makeBasic (8 universal social emotes — wave /
bow / laugh / cheer / cry / sleep / kneel / applaud),
makeCombat (5 combat-themed — roar / threaten / charge /
victory / surrender), makeRolePlay (6 RP-focused — bonk
/ ponder / soothe / plead / shoo / scoff). Animation IDs
match AnimationData.dbc convention so existing WoW client
mods continue to play the right anims.

Validator catches authoring bugs unique to slash-command
parsing: leading '/' on slashCommand (chat parser strips
it before lookup so the entry would be doubly-prefixed),
uppercase letters (parser case-folds before lookup so the
entry is unreachable), duplicate slash commands (parser
dispatches by exact match — ambiguity would crash the
chat input handler), %s token counts that don't match
target/no-target distinction.

Also expanded --catalog-pluck's foreign-key filter to
include animationId / soundId / particleId / ribbonId /
vehicleId / seatId / currencyId / trainerId / vendorId /
mailTemplateId — caught during smoke-test where pluck
mis-identified WEMO entries by animationId instead of
emoteId. Same class of bug as the WHRT areaId fix.

Format count 100 -> 101. CLI flag count 1126 -> 1131.
2026-05-10 00:53:33 -07:00

349 lines
11 KiB
C++

#include "cli_catalog_pluck.hpp"
#include "cli_arg_parse.hpp"
#include "cli_format_table.hpp"
#include <nlohmann/json.hpp>
#include <array>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
namespace wowee {
namespace editor {
namespace cli {
namespace {
// Same shell-quoting helper as cli_bulk_validate — single
// quote and escape embedded single quotes.
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 std::string& 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;
}
std::string normalizePathToBase(std::string base,
const char* extension) {
// Strip the format extension if present so subprocess
// calls receive the bare base path the per-format
// --info-wXXX handler expects.
if (!extension || !*extension) return base;
size_t extLen = std::strlen(extension);
if (base.size() >= extLen &&
base.compare(base.size() - extLen, extLen, extension) == 0) {
base.resize(base.size() - extLen);
}
return base;
}
// Capture the full stdout of a child process invoked via
// popen. Returns the trimmed output string and the exit
// status. On platforms without WEXITSTATUS, treat any
// nonzero return as failure.
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
if (rc != -1) {
outRc = WEXITSTATUS(rc);
} else {
outRc = rc;
}
#else
outRc = rc;
#endif
return buf;
}
// Field names that are conventionally cross-references
// to OTHER catalogs, not the primary key of THIS entry.
// nlohmann::json's default storage is std::map (alphabet-
// ically ordered), so a naive "first *Id field" picks up
// the wrong field for catalogs that mention foreign keys
// before their own (WHRT areaId/bindId, etc.). The pluck
// algorithm filters these out before falling back.
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;
}
// Walk a JSON entry object and find the value of its
// primary-key field. Convention: the primary key is the
// first field whose name ends in "Id" AND is NOT a known
// external-reference field. nlohmann::json iterates keys
// alphabetically, so we filter foreign keys before
// picking. Falls back to first numeric field if no *Id
// remains.
std::pair<bool, uint64_t>
findEntryPrimaryKey(const nlohmann::json& entry) {
if (!entry.is_object()) return {false, 0};
// First pass: *Id fields that aren't known foreign keys.
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>()};
}
}
// Second pass: any *Id (lets pluck still work on
// catalogs whose primary key happens to share a name
// with a foreign-key convention).
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>()};
}
}
// Fallback: first numeric field.
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};
}
// Same algorithm but returning the field NAME — used so
// the operator can know which field they searched
// (compId vs bindId vs broadcastId etc.) without having
// to memorize per-format conventions.
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 {};
}
int handlePluck(int& i, int argc, char** argv) {
if (i + 2 >= argc) {
std::fprintf(stderr,
"catalog-pluck: usage: --catalog-pluck "
"<wXXX-file> <id> [--json]\n");
return 1;
}
std::string fileArg = argv[++i];
std::string idArg = argv[++i];
bool jsonOut = consumeJsonFlag(i, argc, argv);
// Parse search id as unsigned integer.
uint64_t searchId = 0;
try {
searchId = std::stoull(idArg);
} catch (...) {
std::fprintf(stderr,
"catalog-pluck: <id> must be a numeric literal "
"(got '%s')\n", idArg.c_str());
return 1;
}
// Read the magic. If file lookup fails directly, try
// again after appending the format-table extension
// matched by the leading 4 bytes of any sibling file.
std::string filePath = fileArg;
char magic[4]{};
if (!peekMagic(filePath, magic)) {
// Try common extensions: scan the format table
// and attempt each ".wXXX" suffix.
for (const FormatMagicEntry* row = formatTableBegin();
row != formatTableEnd(); ++row) {
std::string with = fileArg + row->extension;
if (peekMagic(with, magic)) {
filePath = with;
break;
}
}
}
if (magic[0] == 0) {
std::fprintf(stderr,
"catalog-pluck: cannot read magic from '%s' "
"(file not found?)\n", fileArg.c_str());
return 1;
}
const FormatMagicEntry* fmt = findFormatByMagic(magic);
if (!fmt) {
std::fprintf(stderr,
"catalog-pluck: unknown magic '%c%c%c%c' in '%s'\n",
magic[0], magic[1], magic[2], magic[3],
filePath.c_str());
return 1;
}
if (!fmt->infoFlag) {
std::fprintf(stderr,
"catalog-pluck: format '%c%c%c%c' has no "
"--info-* flag in the format table — pluck "
"is only supported for catalogs with an "
"--info-* surface\n",
magic[0], magic[1], magic[2], magic[3]);
return 1;
}
// Build the subprocess invocation: the same binary
// (argv[0]) with the per-format inspect flag and JSON
// output. Strip the extension so the inspect handler
// sees the bare base path it expects.
std::string base = normalizePathToBase(filePath, fmt->extension);
std::string cmd = shellQuote(argv[0]) + " " +
fmt->infoFlag + " " +
shellQuote(base) + " --json 2>/dev/null";
int rc = 0;
std::string stdoutBuf = runAndCapture(cmd, rc);
if (rc != 0 || stdoutBuf.empty()) {
std::fprintf(stderr,
"catalog-pluck: inspect subprocess for '%s' "
"failed (rc=%d)\n", filePath.c_str(), rc);
return 1;
}
nlohmann::json doc;
try {
doc = nlohmann::json::parse(stdoutBuf);
} catch (const std::exception& ex) {
std::fprintf(stderr,
"catalog-pluck: failed to parse inspect output "
"as JSON: %s\n", ex.what());
return 1;
}
if (!doc.contains("entries") || !doc["entries"].is_array()) {
std::fprintf(stderr,
"catalog-pluck: inspect output has no "
"'entries' array\n");
return 1;
}
// Locate the entry whose primary-key field matches.
const nlohmann::json* match = nullptr;
std::string keyName;
for (const auto& entry : doc["entries"]) {
auto [ok, key] = findEntryPrimaryKey(entry);
if (ok && key == searchId) {
match = &entry;
keyName = findEntryPrimaryKeyName(entry);
break;
}
}
if (!match) {
std::fprintf(stderr,
"catalog-pluck: no entry with id %llu in '%s' "
"(searched %zu entries)\n",
static_cast<unsigned long long>(searchId),
filePath.c_str(),
doc["entries"].size());
return 1;
}
if (jsonOut) {
nlohmann::json out;
out["file"] = filePath;
out["magic"] = std::string(magic, 4);
out["primaryKey"] = keyName;
out["entry"] = *match;
std::printf("%s\n", out.dump(2).c_str());
return 0;
}
// Pretty terminal output.
std::printf("catalog-pluck: %s\n", filePath.c_str());
std::printf(" magic : '%c%c%c%c'\n",
magic[0], magic[1], magic[2], magic[3]);
std::printf(" primaryKey : %s = %llu\n",
keyName.c_str(),
static_cast<unsigned long long>(searchId));
std::printf(" entry:\n");
for (auto it = match->begin(); it != match->end(); ++it) {
const std::string& k = it.key();
const auto& v = it.value();
std::string vs;
if (v.is_string()) {
vs = v.get<std::string>();
} else if (v.is_number_integer()) {
vs = std::to_string(v.get<long long>());
} else if (v.is_number_float()) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%g",
v.get<double>());
vs = buf;
} else if (v.is_boolean()) {
vs = v.get<bool>() ? "true" : "false";
} else {
vs = v.dump();
}
std::printf(" %-22s : %s\n", k.c_str(), vs.c_str());
}
return 0;
}
} // namespace
bool handleCatalogPluck(int& i, int argc, char** argv, int& outRc) {
if (std::strcmp(argv[i], "--catalog-pluck") == 0 &&
i + 2 < argc) {
outRc = handlePluck(i, argc, argv); return true;
}
return false;
}
} // namespace cli
} // namespace editor
} // namespace wowee