feat(editor): add --tree-summary-md content inventory generator

Walks a directory recursively, identifies every Wowee
open-format file by 4-byte magic, parses the standard
catalog header, and emits a Markdown report. Useful for
content-bundle distributions to ship with a README of
what's inside, and for change-log generation when
diffing two content snapshots manually.

The report has three sections: a summary table (total
files / recognized / bytes), a per-format breakdown
(magic / ext / file count / total entries / description),
and a per-file detail table (path / magic / version /
catalog name / entries / bytes). Output to stdout if no
out path is given, otherwise written to a file.

Reuses cli_format_table.cpp so any new format added in
the future appears automatically without touching this
tool.
This commit is contained in:
Kelsi 2026-05-09 20:16:27 -07:00
parent 386afcb4ef
commit 23bb97651c
6 changed files with 213 additions and 1 deletions

View file

@ -1440,6 +1440,7 @@ add_executable(wowee_editor
tools/editor/cli_companions_catalog.cpp
tools/editor/cli_spell_mechanics_catalog.cpp
tools/editor/cli_keybindings_catalog.cpp
tools/editor/cli_tree_summary_md.cpp
tools/editor/cli_quest_objective.cpp
tools/editor/cli_quest_reward.cpp
tools/editor/cli_clone.cpp

View file

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

View file

@ -89,6 +89,7 @@
#include "cli_companions_catalog.hpp"
#include "cli_spell_mechanics_catalog.hpp"
#include "cli_keybindings_catalog.hpp"
#include "cli_tree_summary_md.hpp"
#include "cli_quest_objective.hpp"
#include "cli_quest_reward.hpp"
#include "cli_clone.hpp"
@ -219,6 +220,7 @@ constexpr DispatchFn kDispatchTable[] = {
handleCompanionsCatalog,
handleSpellMechanicsCatalog,
handleKeybindingsCatalog,
handleTreeSummaryMd,
handleQuestObjective,
handleQuestReward,
handleClone,

View file

@ -1359,6 +1359,8 @@ void printUsage(const char* argv0) {
std::printf(" Apply --rename-by-magic recursively to every file in <dir>. Conflicts are skipped without --force; exits 1 if any rename failed\n");
std::printf(" --touch-tree <dir> [--json] [--quiet]\n");
std::printf(" CI integrity check: open every recognized .w* file in <dir>, parse standard header, report PASS/FAIL + extension mismatches. Exit 1 on any failure\n");
std::printf(" --tree-summary-md <dir> [out.md]\n");
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(" --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");

View file

@ -0,0 +1,196 @@
#include "cli_tree_summary_md.hpp"
#include "cli_arg_parse.hpp"
#include "cli_format_table.hpp"
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <map>
#include <sstream>
#include <string>
#include <vector>
namespace wowee {
namespace editor {
namespace cli {
namespace {
namespace fs = std::filesystem;
struct FileRow {
fs::path relPath;
const FormatMagicEntry* fmt;
uint32_t version;
uint32_t entryCount;
std::string catalogName;
bool hasHeader;
uintmax_t bytes;
};
// Read the standard catalog header (magic+version+name+
// entryCount). World/asset formats don't have this layout —
// for those we leave the header fields blank.
bool peekHeader(const fs::path& path, char magic[4],
uint32_t& version, std::string& catalogName,
uint32_t& entryCount) {
std::ifstream is(path, std::ios::binary);
if (!is) return false;
if (!is.read(magic, 4) || is.gcount() != 4) return false;
if (!is.read(reinterpret_cast<char*>(&version), 4)) return false;
uint32_t nameLen = 0;
if (!is.read(reinterpret_cast<char*>(&nameLen), 4)) return false;
if (nameLen > (1u << 20)) return false;
catalogName.resize(nameLen);
if (nameLen > 0) {
if (!is.read(catalogName.data(), nameLen)) return false;
}
if (!is.read(reinterpret_cast<char*>(&entryCount), 4)) return false;
return true;
}
int handleSummary(int& i, int argc, char** argv) {
std::string dir = argv[++i];
std::string outPath;
if (parseOptArg(i, argc, argv)) outPath = argv[++i];
if (!fs::exists(dir) || !fs::is_directory(dir)) {
std::fprintf(stderr,
"tree-summary-md: not a directory: %s\n", dir.c_str());
return 1;
}
std::vector<FileRow> rows;
std::map<std::string, uint64_t> formatFileCounts;
std::map<std::string, uint64_t> formatEntryCounts;
uint64_t totalFiles = 0;
uint64_t totalRecognized = 0;
uint64_t totalBytes = 0;
for (const auto& entry : fs::recursive_directory_iterator(dir)) {
if (!entry.is_regular_file()) continue;
++totalFiles;
char magic[4];
std::ifstream is(entry.path(), std::ios::binary);
if (!is.read(magic, 4) || is.gcount() != 4) continue;
const FormatMagicEntry* fmt = findFormatByMagic(magic);
if (!fmt) continue;
++totalRecognized;
FileRow r;
r.relPath = fs::relative(entry.path(), dir);
r.fmt = fmt;
r.version = 0; r.entryCount = 0;
r.bytes = entry.file_size();
// Re-open and parse standard header for catalog formats.
// Skip for world/asset formats (infoFlag == nullptr).
if (fmt->infoFlag != nullptr) {
char headerMagic[4];
uint32_t v = 0, ec = 0;
std::string cn;
if (peekHeader(entry.path(), headerMagic, v, cn, ec)) {
r.version = v; r.catalogName = cn;
r.entryCount = ec; r.hasHeader = true;
formatEntryCounts[fmt->magic] += ec;
} else {
r.hasHeader = false;
}
} else {
r.hasHeader = false;
}
formatFileCounts[fmt->magic] += 1;
totalBytes += r.bytes;
rows.push_back(std::move(r));
}
// Build the markdown.
std::ostringstream md;
md << "# Wowee open-format inventory: " << dir << "\n\n";
md << "Generated by `--tree-summary-md`. "
<< "Walks the directory recursively and identifies every "
<< "Wowee open-format file by its 4-byte magic.\n\n";
md << "## Summary\n\n";
md << "| Stat | Value |\n";
md << "|------|-------|\n";
md << "| Total files scanned | " << totalFiles << " |\n";
md << "| Recognized .w* files | " << totalRecognized << " |\n";
md << "| Unrecognized files | "
<< (totalFiles - totalRecognized) << " |\n";
md << "| Total recognized bytes | " << totalBytes << " |\n\n";
if (!formatFileCounts.empty()) {
md << "## Per-format breakdown\n\n";
md << "| Magic | Extension | Files | Total entries | Description |\n";
md << "|-------|-----------|-------|---------------|-------------|\n";
for (const auto& kv : formatFileCounts) {
std::string magicStr = kv.first;
const FormatMagicEntry* fmt = nullptr;
for (const FormatMagicEntry* p = formatTableBegin();
p != formatTableEnd(); ++p) {
if (std::memcmp(p->magic, magicStr.data(), 4) == 0) {
fmt = p; break;
}
}
uint64_t entries = formatEntryCounts.count(magicStr)
? formatEntryCounts[magicStr] : 0;
md << "| `" << magicStr << "` | `"
<< (fmt ? fmt->extension : "?") << "` | "
<< kv.second << " | " << entries << " | "
<< (fmt ? fmt->description : "?") << " |\n";
}
md << "\n";
}
if (!rows.empty()) {
md << "## Per-file detail\n\n";
md << "| Path | Magic | Version | Catalog name | Entries | Bytes |\n";
md << "|------|-------|---------|--------------|---------|-------|\n";
for (const auto& r : rows) {
char ms[5] = {r.fmt->magic[0], r.fmt->magic[1],
r.fmt->magic[2], r.fmt->magic[3], 0};
md << "| `" << r.relPath.string() << "` | `"
<< ms << "` | ";
if (r.hasHeader) {
md << r.version << " | `" << r.catalogName << "` | "
<< r.entryCount << " | ";
} else {
md << "- | - | - | ";
}
md << r.bytes << " |\n";
}
md << "\n";
}
md << "## How to inspect\n\n";
md << "Use `--info-magic <path>` to identify any file by magic, "
<< "or the per-format `--info-*` flag listed in `--list-formats`.\n";
std::string output = md.str();
if (outPath.empty()) {
std::printf("%s", output.c_str());
} else {
std::ofstream out(outPath);
if (!out) {
std::fprintf(stderr,
"tree-summary-md: cannot write %s\n",
outPath.c_str());
return 1;
}
out << output;
std::printf("Wrote %s\n", outPath.c_str());
std::printf(" scanned files : %llu\n",
static_cast<unsigned long long>(totalFiles));
std::printf(" recognized .w* : %llu\n",
static_cast<unsigned long long>(totalRecognized));
std::printf(" unique formats : %zu\n",
formatFileCounts.size());
}
return 0;
}
} // namespace
bool handleTreeSummaryMd(int& i, int argc, char** argv, int& outRc) {
if (std::strcmp(argv[i], "--tree-summary-md") == 0 &&
i + 1 < argc) {
outRc = handleSummary(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 handleTreeSummaryMd(int& i, int argc, char** argv, int& outRc);
} // namespace cli
} // namespace editor
} // namespace wowee