diff --git a/CMakeLists.txt b/CMakeLists.txt
index 81b81cb7..76ba8b31 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1431,6 +1431,7 @@ add_executable(wowee_editor
tools/editor/cli_trade_skills_catalog.cpp
tools/editor/cli_creature_equipment_catalog.cpp
tools/editor/cli_item_sets_catalog.cpp
+ tools/editor/cli_touch_tree.cpp
tools/editor/cli_quest_objective.cpp
tools/editor/cli_quest_reward.cpp
tools/editor/cli_clone.cpp
diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp
index 787f5719..7f74269e 100644
--- a/tools/editor/cli_arg_required.cpp
+++ b/tools/editor/cli_arg_required.cpp
@@ -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",
+ "--bulk-rename-by-magic", "--touch-tree",
"--gen-animations", "--gen-animations-combat", "--gen-animations-movement",
"--info-wani", "--validate-wani",
"--export-wani-json", "--import-wani-json",
diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp
index 07b5b4b1..a3bacdd8 100644
--- a/tools/editor/cli_dispatch.cpp
+++ b/tools/editor/cli_dispatch.cpp
@@ -84,6 +84,7 @@
#include "cli_trade_skills_catalog.hpp"
#include "cli_creature_equipment_catalog.hpp"
#include "cli_item_sets_catalog.hpp"
+#include "cli_touch_tree.hpp"
#include "cli_quest_objective.hpp"
#include "cli_quest_reward.hpp"
#include "cli_clone.hpp"
@@ -209,6 +210,7 @@ constexpr DispatchFn kDispatchTable[] = {
handleTradeSkillsCatalog,
handleCreatureEquipmentCatalog,
handleItemSetsCatalog,
+ handleTouchTree,
handleQuestObjective,
handleQuestReward,
handleClone,
diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp
index bfb64e2d..f1e39d0d 100644
--- a/tools/editor/cli_help.cpp
+++ b/tools/editor/cli_help.cpp
@@ -1357,6 +1357,8 @@ void printUsage(const char* argv0) {
std::printf(" Recover the correct .w* extension on a file by reading its 4-byte magic. --dry-run prints the planned move; --force overwrites\n");
std::printf(" --bulk-rename-by-magic
[--dry-run] [--force]\n");
std::printf(" Apply --rename-by-magic recursively to every file in . Conflicts are skipped without --force; exits 1 if any rename failed\n");
+ std::printf(" --touch-tree [--json] [--quiet]\n");
+ std::printf(" CI integrity check: open every recognized .w* file in , parse standard header, report PASS/FAIL + extension mismatches. Exit 1 on any failure\n");
std::printf(" --gen-animations [name]\n");
std::printf(" Emit .wani starter: 5 essential animations (Stand / Walk / Run / Death / AttackUnarmed) with fallback chains\n");
std::printf(" --gen-animations-combat [name]\n");
diff --git a/tools/editor/cli_touch_tree.cpp b/tools/editor/cli_touch_tree.cpp
new file mode 100644
index 00000000..36582e41
--- /dev/null
+++ b/tools/editor/cli_touch_tree.cpp
@@ -0,0 +1,235 @@
+#include "cli_touch_tree.hpp"
+#include "cli_arg_parse.hpp"
+#include "cli_format_table.hpp"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace wowee {
+namespace editor {
+namespace cli {
+
+namespace {
+
+namespace fs = std::filesystem;
+
+// Per-file integrity check result.
+struct TouchResult {
+ fs::path path;
+ const FormatMagicEntry* fmt;
+ bool readMagic;
+ bool readVersion;
+ bool readName;
+ bool readEntryCount;
+ bool extensionMismatch; // magic recognized but ext is wrong
+ uint32_t version;
+ uint32_t entryCount;
+ std::string catalogName;
+ std::string failReason; // populated on any failure
+};
+
+// Walk the standard catalog header and report the deepest
+// stage that succeeded. World/asset formats (WOM/WOB/WHM/WOT/
+// WOW) are recognized by magic only — the version+name+count
+// probe is skipped since their layouts differ.
+TouchResult touchOne(const fs::path& path) {
+ TouchResult r;
+ r.path = path;
+ r.fmt = nullptr;
+ r.readMagic = r.readVersion = r.readName = r.readEntryCount = false;
+ r.extensionMismatch = false;
+ r.version = 0; r.entryCount = 0;
+ std::ifstream is(path, std::ios::binary);
+ if (!is) {
+ r.failReason = "cannot open file";
+ return r;
+ }
+ char magic[4];
+ if (!is.read(magic, 4) || is.gcount() != 4) {
+ r.failReason = "file too short to read 4-byte magic";
+ return r;
+ }
+ r.readMagic = true;
+ r.fmt = findFormatByMagic(magic);
+ if (!r.fmt) {
+ char ms[5] = {magic[0], magic[1], magic[2], magic[3], 0};
+ r.failReason = std::string("unrecognized magic '") + ms + "'";
+ return r;
+ }
+ // Confirm the file's actual extension matches the format
+ // — a renamed file with magic WCMS but suffix .wlot is a
+ // bug worth flagging (likely the file was hand-renamed
+ // away from the truth).
+ std::string ext = path.extension().string();
+ if (!ext.empty() && ext != r.fmt->extension) {
+ r.extensionMismatch = true;
+ }
+ // World/asset formats stop here — their headers diverge.
+ if (r.fmt->infoFlag == nullptr) {
+ return r;
+ }
+ if (!is.read(reinterpret_cast(&r.version), 4)) {
+ r.failReason = "truncated before version field";
+ return r;
+ }
+ r.readVersion = true;
+ uint32_t nameLen = 0;
+ if (!is.read(reinterpret_cast(&nameLen), 4)) {
+ r.failReason = "truncated before catalog-name length";
+ return r;
+ }
+ if (nameLen > (1u << 20)) {
+ r.failReason = "catalog-name length implausible (> 1MB)";
+ return r;
+ }
+ r.catalogName.resize(nameLen);
+ if (nameLen > 0) {
+ if (!is.read(r.catalogName.data(), nameLen)) {
+ r.failReason = "truncated within catalog-name string";
+ return r;
+ }
+ }
+ r.readName = true;
+ if (!is.read(reinterpret_cast(&r.entryCount), 4)) {
+ r.failReason = "truncated before entryCount field";
+ return r;
+ }
+ r.readEntryCount = true;
+ if (r.entryCount > (1u << 20)) {
+ r.failReason = "entryCount implausible (> 1M entries)";
+ return r;
+ }
+ return r;
+}
+
+bool isFailure(const TouchResult& r) {
+ if (!r.failReason.empty()) return true;
+ // Extension mismatch is a warning, not a failure.
+ return false;
+}
+
+int handleTouch(int& i, int argc, char** argv) {
+ std::string dir = argv[++i];
+ bool jsonOut = consumeJsonFlag(i, argc, argv);
+ bool quiet = false;
+ while (i + 1 < argc) {
+ std::string a = argv[i + 1];
+ if (a == "--quiet") { quiet = true; ++i; }
+ else break;
+ }
+ if (!fs::exists(dir) || !fs::is_directory(dir)) {
+ std::fprintf(stderr,
+ "touch-tree: not a directory: %s\n", dir.c_str());
+ return 1;
+ }
+ std::vector results;
+ uint64_t totalFiles = 0;
+ uint64_t skippedUnknown = 0;
+ for (const auto& entry : fs::recursive_directory_iterator(dir)) {
+ if (!entry.is_regular_file()) continue;
+ ++totalFiles;
+ TouchResult r = touchOne(entry.path());
+ if (!r.fmt) {
+ ++skippedUnknown;
+ continue;
+ }
+ results.push_back(std::move(r));
+ }
+ uint64_t okCount = 0;
+ uint64_t failCount = 0;
+ uint64_t mismatchCount = 0;
+ for (const auto& r : results) {
+ if (isFailure(r)) ++failCount;
+ else ++okCount;
+ if (r.extensionMismatch) ++mismatchCount;
+ }
+ if (jsonOut) {
+ nlohmann::json j;
+ j["dir"] = dir;
+ j["totalFiles"] = totalFiles;
+ j["recognized"] = results.size();
+ j["unrecognized"] = skippedUnknown;
+ j["ok"] = okCount;
+ j["failed"] = failCount;
+ j["extensionMismatch"] = mismatchCount;
+ nlohmann::json arr = nlohmann::json::array();
+ for (const auto& r : results) {
+ char ms[5] = {r.fmt->magic[0], r.fmt->magic[1],
+ r.fmt->magic[2], r.fmt->magic[3], 0};
+ nlohmann::json row;
+ row["path"] = fs::relative(r.path, dir).string();
+ row["magic"] = ms;
+ row["extension"] = r.fmt->extension;
+ row["ok"] = !isFailure(r);
+ if (r.readVersion) row["version"] = r.version;
+ if (r.readName) row["catalogName"] = r.catalogName;
+ if (r.readEntryCount) row["entryCount"] = r.entryCount;
+ if (r.extensionMismatch) row["extensionMismatch"] = true;
+ if (!r.failReason.empty()) row["failReason"] = r.failReason;
+ arr.push_back(row);
+ }
+ j["entries"] = arr;
+ std::printf("%s\n", j.dump(2).c_str());
+ return failCount > 0 ? 1 : 0;
+ }
+ std::printf("touch-tree: %s\n", dir.c_str());
+ std::printf(" total files : %llu\n",
+ static_cast(totalFiles));
+ std::printf(" recognized .w* : %zu\n", results.size());
+ std::printf(" unrecognized : %llu (skipped)\n",
+ static_cast(skippedUnknown));
+ std::printf(" OK : %llu\n",
+ static_cast(okCount));
+ std::printf(" FAILED : %llu\n",
+ static_cast(failCount));
+ std::printf(" ext mismatch : %llu (warning, not failure)\n",
+ static_cast(mismatchCount));
+ if (failCount == 0 && mismatchCount == 0) {
+ if (!quiet) {
+ std::printf("\n all recognized files passed integrity check\n");
+ }
+ return 0;
+ }
+ if (!quiet) {
+ for (const auto& r : results) {
+ if (!isFailure(r) && !r.extensionMismatch) continue;
+ char ms[5] = {r.fmt->magic[0], r.fmt->magic[1],
+ r.fmt->magic[2], r.fmt->magic[3], 0};
+ const char* tag = isFailure(r) ? "FAIL" : "WARN";
+ std::string failPart = isFailure(r)
+ ? std::string(": ") + r.failReason
+ : std::string();
+ std::string mismatchPart = r.extensionMismatch
+ ? std::string(" (extension mismatch — expected '") +
+ r.fmt->extension + "')"
+ : std::string();
+ std::printf(" [%s] %s '%s'%s%s\n",
+ tag,
+ fs::relative(r.path, dir).string().c_str(),
+ ms,
+ failPart.c_str(),
+ mismatchPart.c_str());
+ }
+ }
+ return failCount > 0 ? 1 : 0;
+}
+
+} // namespace
+
+bool handleTouchTree(int& i, int argc, char** argv, int& outRc) {
+ if (std::strcmp(argv[i], "--touch-tree") == 0 && i + 1 < argc) {
+ outRc = handleTouch(i, argc, argv); return true;
+ }
+ return false;
+}
+
+} // namespace cli
+} // namespace editor
+} // namespace wowee
diff --git a/tools/editor/cli_touch_tree.hpp b/tools/editor/cli_touch_tree.hpp
new file mode 100644
index 00000000..ba56019b
--- /dev/null
+++ b/tools/editor/cli_touch_tree.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+namespace wowee {
+namespace editor {
+namespace cli {
+
+bool handleTouchTree(int& i, int argc, char** argv, int& outRc);
+
+} // namespace cli
+} // namespace editor
+} // namespace wowee