#include "editor_app.hpp" #include "cli_gen_audio.hpp" #include "cli_zone_packs.hpp" #include "cli_audits.hpp" #include "cli_readmes.hpp" #include "cli_zone_inventory.hpp" #include "cli_project_inventory.hpp" #include "cli_help.hpp" #include "cli_gen_texture.hpp" #include "cli_gen_mesh.hpp" #include "cli_mesh_io.hpp" #include "cli_mesh_edit.hpp" #include "cli_wom_info.hpp" #include "cli_format_validate.hpp" #include "cli_convert.hpp" #include "cli_format_info.hpp" #include "cli_pack.hpp" #include "cli_content_info.hpp" #include "cli_zone_info.hpp" #include "cli_data_tree.hpp" #include "cli_diff.hpp" #include "cli_spawn_audit.hpp" #include "cli_items.hpp" #include "cli_extract_info.hpp" #include "cli_export.hpp" #include "cli_bake.hpp" #include "cli_migrate.hpp" #include "cli_convert_single.hpp" #include "cli_validate_interop.hpp" #include "cli_glb_inspect.hpp" #include "cli_wom_io.hpp" #include "content_pack.hpp" #include "npc_spawner.hpp" #include "object_placer.hpp" #include "quest_editor.hpp" #include "wowee_terrain.hpp" #include "zone_manifest.hpp" #include "terrain_editor.hpp" #include "terrain_biomes.hpp" #include #include #include #include #include "pipeline/wowee_model.hpp" #include "pipeline/wowee_building.hpp" #include "pipeline/wowee_collision.hpp" #include "pipeline/wowee_terrain_loader.hpp" #include "pipeline/wmo_loader.hpp" #include "pipeline/m2_loader.hpp" #include "pipeline/adt_loader.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/custom_zone_discovery.hpp" #include "core/logger.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "stb_image_write.h" #include "stb_image.h" // implementation in stb_image_impl.cpp // ─── Open-format consistency checks ───────────────────────────── // Both validators are called from the per-file CLI commands AND // from --validate-all which walks a zone dir. Returning a vector // of error strings (empty == passed) keeps callers simple. // Minimal SHA-256 implementation (FIPS 180-4) used by --export-zone-checksum // to produce hashes that interoperate with `sha256sum -c`. Not exposed beyond // this file — about 90 LoC, no external deps. See RFC 6234 for the algorithm. int main(int argc, char* argv[]) { std::string dataPath; std::string adtMap; int adtX = -1, adtY = -1; // Detect non-GUI options that are missing their argument and bail out // with a helpful message instead of silently dropping into the GUI. static const char* kArgRequired[] = { "--data", "--info", "--info-batches", "--info-textures", "--info-doodads", "--info-attachments", "--info-particles", "--info-sequences", "--info-bones", "--export-bones-dot", "--list-zone-meshes", "--list-zone-audio", "--list-zone-textures", "--list-project-meshes", "--list-project-audio", "--list-project-textures", "--info-zone-models-total", "--info-project-models-total", "--list-zone-meshes-detail", "--list-project-meshes-detail", "--info-mesh", "--info-mesh-storage-budget", "--info-wob", "--info-woc", "--info-wot", "--info-creatures", "--info-objects", "--info-quests", "--info-extract", "--info-extract-tree", "--info-extract-budget", "--list-missing-sidecars", "--info-png", "--info-jsondbc", "--info-blp", "--info-pack-budget", "--info-pack-tree", "--info-m2", "--info-wmo", "--info-adt", "--info-zone", "--info-zone-overview", "--info-project-overview", "--copy-project", "--info-wcp", "--list-wcp", "--list-creatures", "--list-objects", "--list-quests", "--list-quest-objectives", "--list-quest-rewards", "--info-creature", "--info-quest", "--info-object", "--info-quest-graph-stats", "--info-creatures-by-faction", "--info-creatures-by-level", "--info-objects-by-path", "--info-objects-by-type", "--info-quests-by-level", "--info-quests-by-xp", "--unpack-wcp", "--pack-wcp", "--validate", "--validate-wom", "--validate-wob", "--validate-woc", "--validate-whm", "--validate-all", "--validate-project", "--validate-project-open-only", "--audit-project", "--bench-audit-project", "--bench-validate-project", "--bench-bake-project", "--bench-migrate-data-tree", "--list-data-tree-largest", "--export-data-tree-md", "--gen-texture", "--gen-mesh", "--gen-mesh-textured", "--add-texture-to-mesh", "--add-texture-to-zone", "--gen-mesh-stairs", "--gen-mesh-grid", "--gen-mesh-disc", "--gen-mesh-tube", "--gen-mesh-capsule", "--gen-mesh-arch", "--gen-mesh-pyramid", "--gen-mesh-fence", "--gen-mesh-tree", "--gen-mesh-rock", "--gen-mesh-pillar", "--gen-mesh-bridge", "--gen-mesh-tower", "--gen-mesh-house", "--gen-mesh-fountain", "--gen-mesh-statue", "--gen-mesh-altar", "--gen-mesh-portal", "--gen-mesh-archway", "--gen-mesh-barrel", "--gen-mesh-chest", "--gen-mesh-anvil", "--gen-mesh-mushroom", "--gen-mesh-cart", "--gen-mesh-banner", "--gen-mesh-grave", "--gen-mesh-bench", "--gen-mesh-shrine", "--gen-mesh-totem", "--gen-mesh-cage", "--gen-mesh-throne", "--gen-mesh-coffin", "--gen-mesh-bookshelf", "--gen-mesh-table", "--gen-texture-gradient", "--gen-mesh-from-heightmap", "--export-mesh-heightmap", "--displace-mesh", "--scale-mesh", "--translate-mesh", "--strip-mesh", "--gen-texture-noise", "--gen-texture-noise-color", "--rotate-mesh", "--center-mesh", "--flip-mesh-normals", "--mirror-mesh", "--smooth-mesh-normals", "--merge-meshes", "--gen-texture-radial", "--gen-texture-stripes", "--gen-texture-dots", "--gen-texture-rings", "--gen-texture-checker", "--gen-texture-brick", "--gen-texture-wood", "--gen-texture-grass", "--gen-texture-fabric", "--gen-texture-cobble", "--gen-texture-marble", "--gen-texture-metal", "--gen-texture-leather", "--gen-texture-sand", "--gen-texture-snow", "--gen-texture-lava", "--gen-texture-tile", "--gen-texture-bark", "--gen-texture-clouds", "--gen-texture-stars", "--gen-texture-vines", "--gen-texture-mosaic", "--gen-texture-rust", "--gen-texture-circuit", "--gen-texture-coral", "--gen-texture-flame", "--gen-texture-tartan", "--gen-texture-argyle", "--gen-texture-herringbone", "--validate-glb", "--info-glb", "--info-glb-tree", "--info-glb-bytes", "--validate-jsondbc", "--check-glb-bounds", "--validate-stl", "--validate-png", "--validate-blp", "--zone-summary", "--info-zone-tree", "--info-project-tree", "--info-zone-bytes", "--info-project-bytes", "--info-zone-extents", "--info-project-extents", "--info-zone-water", "--info-project-water", "--info-zone-density", "--info-project-density", "--export-zone-summary-md", "--export-quest-graph", "--export-zone-csv", "--export-zone-html", "--export-project-html", "--export-project-md", "--export-zone-checksum", "--export-project-checksum", "--validate-project-checksum", "--scaffold-zone", "--mvp-zone", "--add-tile", "--remove-tile", "--list-tiles", "--for-each-zone", "--for-each-tile", "--zone-stats", "--info-tilemap", "--list-zone-deps", "--list-project-orphans", "--remove-project-orphans", "--check-zone-refs", "--check-zone-content", "--check-project-content", "--check-project-refs", "--export-zone-deps-md", "--export-zone-spawn-png", "--add-creature", "--add-object", "--add-quest", "--add-item", "--random-populate-zone", "--random-populate-items", "--info-zone-audio", "--snap-zone-to-ground", "--audit-zone-spawns", "--info-project-audio", "--snap-project-to-ground", "--audit-project-spawns", "--list-zone-spawns", "--list-project-spawns", "--gen-random-zone", "--gen-random-project", "--gen-zone-texture-pack", "--gen-zone-mesh-pack", "--gen-zone-starter-pack", "--gen-project-starter-pack", "--gen-audio-tone", "--gen-audio-noise", "--gen-audio-sweep", "--gen-zone-audio-pack", "--info-zone-summary", "--info-project-summary", "--info-zone-deps", "--info-project-deps", "--gen-zone-readme", "--gen-project-readme", "--validate-zone-pack", "--validate-project-packs", "--info-spawn", "--diff-zone-spawns", "--list-items", "--info-item", "--set-item", "--export-zone-items-md", "--export-project-items-md", "--export-project-items-csv", "--add-quest-objective", "--add-quest-reward-item", "--set-quest-reward", "--remove-quest-objective", "--clone-quest", "--clone-creature", "--clone-item", "--validate-items", "--validate-project-items", "--info-project-items", "--clone-object", "--remove-creature", "--remove-object", "--remove-quest", "--remove-item", "--copy-zone-items", "--copy-zone", "--rename-zone", "--remove-zone", "--clear-zone-content", "--strip-zone", "--strip-project", "--repair-zone", "--repair-project", "--gen-makefile", "--gen-project-makefile", "--build-woc", "--regen-collision", "--fix-zone", "--export-png", "--export-obj", "--import-obj", "--export-wob-obj", "--import-wob-obj", "--export-woc-obj", "--export-whm-obj", "--export-glb", "--export-wob-glb", "--export-whm-glb", "--export-stl", "--import-stl", "--bake-zone-glb", "--bake-zone-stl", "--bake-zone-obj", "--bake-project-obj", "--bake-project-stl", "--bake-project-glb", "--convert-m2", "--convert-m2-batch", "--convert-wmo", "--convert-wmo-batch", "--convert-dbc-json", "--convert-dbc-batch", "--convert-json-dbc", "--convert-blp-png", "--convert-blp-batch", "--migrate-wom", "--migrate-zone", "--migrate-project", "--migrate-data-tree", "--info-data-tree", "--strip-data-tree", "--audit-data-tree", "--migrate-jsondbc", }; for (int i = 1; i < argc; i++) { for (const char* opt : kArgRequired) { if (std::strcmp(argv[i], opt) == 0 && i + 1 >= argc) { std::fprintf(stderr, "%s requires an argument\n", opt); return 1; } } if (std::strcmp(argv[i], "--adt") == 0 && i + 3 >= argc) { std::fprintf(stderr, "--adt requires \n"); return 1; } if (std::strcmp(argv[i], "--diff-zone") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-zone requires \n"); return 1; } if (std::strcmp(argv[i], "--diff-glb") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-glb requires \n"); return 1; } if (std::strcmp(argv[i], "--diff-wom") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-wom requires \n"); return 1; } if (std::strcmp(argv[i], "--diff-wob") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-wob requires \n"); return 1; } if (std::strcmp(argv[i], "--diff-whm") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-whm requires \n"); return 1; } if (std::strcmp(argv[i], "--diff-woc") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-woc requires \n"); return 1; } if (std::strcmp(argv[i], "--diff-jsondbc") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-jsondbc requires \n"); return 1; } if (std::strcmp(argv[i], "--diff-extract") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-extract requires \n"); return 1; } if (std::strcmp(argv[i], "--diff-checksum") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-checksum requires \n"); return 1; } if (std::strcmp(argv[i], "--diff-wcp") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-wcp requires two paths\n"); return 1; } if (std::strcmp(argv[i], "--add-creature") == 0 && i + 5 >= argc) { std::fprintf(stderr, "--add-creature requires \n"); return 1; } if (std::strcmp(argv[i], "--add-object") == 0 && i + 6 >= argc) { std::fprintf(stderr, "--add-object requires \n"); return 1; } if (std::strcmp(argv[i], "--add-quest") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--add-quest requires \n"); return 1; } if (std::strcmp(argv[i], "--add-quest-objective") == 0 && i + 4 >= argc) { std::fprintf(stderr, "--add-quest-objective requires <zoneDir> <questIdx> <type> <targetName>\n"); return 1; } if (std::strcmp(argv[i], "--remove-quest-objective") == 0 && i + 3 >= argc) { std::fprintf(stderr, "--remove-quest-objective requires <zoneDir> <questIdx> <objIdx>\n"); return 1; } if (std::strcmp(argv[i], "--clone-quest") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--clone-quest requires <zoneDir> <questIdx>\n"); return 1; } if (std::strcmp(argv[i], "--clone-creature") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--clone-creature requires <zoneDir> <idx>\n"); return 1; } if (std::strcmp(argv[i], "--clone-object") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--clone-object requires <zoneDir> <idx>\n"); return 1; } if (std::strcmp(argv[i], "--add-quest-reward-item") == 0 && i + 3 >= argc) { std::fprintf(stderr, "--add-quest-reward-item requires <zoneDir> <questIdx> <itemPath>\n"); return 1; } if (std::strcmp(argv[i], "--set-quest-reward") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--set-quest-reward requires <zoneDir> <questIdx> [--xp N] [--gold N] [--silver N] [--copper N]\n"); return 1; } if (std::strcmp(argv[i], "--add-tile") == 0 && i + 3 >= argc) { std::fprintf(stderr, "--add-tile requires <zoneDir> <tx> <ty>\n"); return 1; } if (std::strcmp(argv[i], "--remove-tile") == 0 && i + 3 >= argc) { std::fprintf(stderr, "--remove-tile requires <zoneDir> <tx> <ty>\n"); return 1; } if (std::strcmp(argv[i], "--copy-zone") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--copy-zone requires <srcDir> <newName>\n"); return 1; } if (std::strcmp(argv[i], "--rename-zone") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--rename-zone requires <srcDir> <newName>\n"); return 1; } for (const char* opt : {"--remove-creature", "--remove-object", "--remove-quest"}) { if (std::strcmp(argv[i], opt) == 0 && i + 2 >= argc) { std::fprintf(stderr, "%s requires <zoneDir> <index>\n", opt); return 1; } } } for (int i = 1; i < argc; i++) { // Modular handler families: extracted from the in-line if/else // chain below to keep main.cpp from sprawling further. Each // family lives in its own .cpp; if it matches argv[i] it // sets outRc and we exit. Otherwise fall through to the // legacy in-line dispatch. { int outRc = 0; if (wowee::editor::cli::handleGenAudio(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleZonePacks(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleAudits(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleReadmes(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleZoneInventory(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleProjectInventory(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleGenTexture(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleGenMesh(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleMeshIO(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleMeshEdit(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleWomInfo(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleFormatValidate(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleConvert(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleFormatInfo(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handlePack(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleContentInfo(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleZoneInfo(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleDataTree(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleDiff(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleSpawnAudit(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleItems(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleExtractInfo(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleExport(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleBake(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleMigrate(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleConvertSingle(i, argc, argv, dataPath, outRc)) { return outRc; } if (wowee::editor::cli::handleValidateInterop(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleGlbInspect(i, argc, argv, outRc)) { return outRc; } if (wowee::editor::cli::handleWomIo(i, argc, argv, outRc)) { return outRc; } } if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { dataPath = argv[++i]; } else if (std::strcmp(argv[i], "--adt") == 0 && i + 3 < argc) { adtMap = argv[++i]; adtX = std::atoi(argv[++i]); adtY = std::atoi(argv[++i]); } else if (std::strcmp(argv[i], "--info-zone-models-total") == 0 && i + 1 < argc) { // Aggregate WOM/WOB stats across every model in a zone. // Useful for capacity planning ('how many bones across all // my creatures?') and perf budgeting ('total triangles // per frame if all loaded?'). std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "info-zone-models-total: %s does not exist\n", zoneDir.c_str()); return 1; } int womCount = 0, wobCount = 0; uint64_t womVerts = 0, womIndices = 0; uint64_t womBones = 0, womAnims = 0, womBatches = 0; uint64_t wobGroups = 0, wobVerts = 0, wobIndices = 0; uint64_t wobDoodads = 0, wobPortals = 0; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::string base = e.path().string(); if (base.size() > ext.size()) base = base.substr(0, base.size() - ext.size()); if (ext == ".wom") { womCount++; auto wom = wowee::pipeline::WoweeModelLoader::load(base); womVerts += wom.vertices.size(); womIndices += wom.indices.size(); womBones += wom.bones.size(); womAnims += wom.animations.size(); womBatches += wom.batches.size(); } else if (ext == ".wob") { wobCount++; auto wob = wowee::pipeline::WoweeBuildingLoader::load(base); wobGroups += wob.groups.size(); for (const auto& g : wob.groups) { wobVerts += g.vertices.size(); wobIndices += g.indices.size(); } wobDoodads += wob.doodads.size(); wobPortals += wob.portals.size(); } } if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["wom"] = {{"count", womCount}, {"vertices", womVerts}, {"indices", womIndices}, {"triangles", womIndices / 3}, {"bones", womBones}, {"animations", womAnims}, {"batches", womBatches}}; j["wob"] = {{"count", wobCount}, {"groups", wobGroups}, {"vertices", wobVerts}, {"indices", wobIndices}, {"triangles", wobIndices / 3}, {"doodads", wobDoodads}, {"portals", wobPortals}}; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone models total: %s\n", zoneDir.c_str()); std::printf("\n WOM (open M2):\n"); std::printf(" files : %d\n", womCount); std::printf(" vertices : %llu\n", static_cast<unsigned long long>(womVerts)); std::printf(" triangles : %llu\n", static_cast<unsigned long long>(womIndices / 3)); std::printf(" bones : %llu\n", static_cast<unsigned long long>(womBones)); std::printf(" anims : %llu\n", static_cast<unsigned long long>(womAnims)); std::printf(" batches : %llu\n", static_cast<unsigned long long>(womBatches)); std::printf("\n WOB (open WMO):\n"); std::printf(" files : %d\n", wobCount); std::printf(" groups : %llu\n", static_cast<unsigned long long>(wobGroups)); std::printf(" vertices : %llu\n", static_cast<unsigned long long>(wobVerts)); std::printf(" triangles : %llu\n", static_cast<unsigned long long>(wobIndices / 3)); std::printf(" doodads : %llu\n", static_cast<unsigned long long>(wobDoodads)); std::printf(" portals : %llu\n", static_cast<unsigned long long>(wobPortals)); std::printf("\n Combined :\n"); std::printf(" vertices : %llu\n", static_cast<unsigned long long>(womVerts + wobVerts)); std::printf(" triangles : %llu\n", static_cast<unsigned long long>((womIndices + wobIndices) / 3)); return 0; } else if (std::strcmp(argv[i], "--list-zone-meshes-detail") == 0 && i + 1 < argc) { // Per-mesh breakdown of every .wom file in <zoneDir>, // sorted by triangle count descending so the heaviest // meshes float to the top. Complements // --list-zone-meshes (per-zone summary) by surfacing // individual mesh metrics — useful for spotting // outliers ("which mesh is using 80% of my triangle // budget?") and for content audits. std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "list-zone-meshes-detail: %s does not exist\n", zoneDir.c_str()); return 1; } struct Row { std::string path; size_t verts; size_t tris; size_t bones; size_t batches; size_t textures; uint64_t bytes; uint32_t version; }; std::vector<Row> rows; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; if (e.path().extension() != ".wom") continue; std::string base = e.path().string(); if (base.size() >= 4) base = base.substr(0, base.size() - 4); auto wom = wowee::pipeline::WoweeModelLoader::load(base); Row r; r.path = fs::relative(e.path(), zoneDir, ec).string(); if (ec) r.path = e.path().filename().string(); r.verts = wom.vertices.size(); r.tris = wom.indices.size() / 3; r.bones = wom.bones.size(); r.batches = wom.batches.size(); r.textures = wom.texturePaths.size(); r.bytes = e.file_size(ec); if (ec) r.bytes = 0; r.version = wom.version; rows.push_back(r); } std::sort(rows.begin(), rows.end(), [](const Row& a, const Row& b) { return a.tris > b.tris; }); uint64_t totVerts = 0, totTris = 0, totBones = 0, totBytes = 0; for (const auto& r : rows) { totVerts += r.verts; totTris += r.tris; totBones += r.bones; totBytes += r.bytes; } if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["meshCount"] = rows.size(); j["totals"] = {{"vertices", totVerts}, {"triangles", totTris}, {"bones", totBones}, {"bytes", totBytes}}; nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({{"path", r.path}, {"version", r.version}, {"vertices", r.verts}, {"triangles", r.tris}, {"bones", r.bones}, {"batches", r.batches}, {"textures", r.textures}, {"bytes", r.bytes}}); } j["meshes"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone meshes: %s\n", zoneDir.c_str()); std::printf(" meshes : %zu\n", rows.size()); std::printf(" totals : %llu verts, %llu tris, %llu bones, %.1f KB\n", static_cast<unsigned long long>(totVerts), static_cast<unsigned long long>(totTris), static_cast<unsigned long long>(totBones), totBytes / 1024.0); if (rows.empty()) { std::printf("\n *no .wom files in this zone*\n"); return 0; } std::printf("\n v verts tris bones batch tex bytes path\n"); for (const auto& r : rows) { std::printf(" v%u %6zu %6zu %5zu %5zu %3zu %7llu %s\n", r.version, r.verts, r.tris, r.bones, r.batches, r.textures, static_cast<unsigned long long>(r.bytes), r.path.c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-mesh") == 0 && i + 1 < argc) { // Single-mesh detail view aggregating bounds, version, // batches, bones, animations, and texture slots into one // report. Composite of what --info-batches / --info-bones // / --info-batches show separately. Useful authoring // command: pass a WOM and see everything about it without // running three sub-commands. std::string base = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom") { base = base.substr(0, base.size() - 4); } if (!wowee::pipeline::WoweeModelLoader::exists(base)) { std::fprintf(stderr, "info-mesh: %s.wom does not exist\n", base.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(base); if (!wom.isValid()) { std::fprintf(stderr, "info-mesh: failed to load %s.wom\n", base.c_str()); return 1; } // Per-batch material summary. static const char* blendNames[] = { "opaque", "alpha-test", "alpha", "additive", "?", "?", "?", "?" }; if (jsonOut) { nlohmann::json j; j["base"] = base; j["name"] = wom.name; j["version"] = wom.version; j["bounds"] = {{"min", {wom.boundMin.x, wom.boundMin.y, wom.boundMin.z}}, {"max", {wom.boundMax.x, wom.boundMax.y, wom.boundMax.z}}, {"radius", wom.boundRadius}}; j["counts"] = {{"vertices", wom.vertices.size()}, {"indices", wom.indices.size()}, {"triangles", wom.indices.size() / 3}, {"bones", wom.bones.size()}, {"animations", wom.animations.size()}, {"batches", wom.batches.size()}, {"textures", wom.texturePaths.size()}}; nlohmann::json bs = nlohmann::json::array(); for (const auto& b : wom.batches) { std::string tex; if (b.textureIndex < wom.texturePaths.size()) tex = wom.texturePaths[b.textureIndex]; bs.push_back({{"indexStart", b.indexStart}, {"indexCount", b.indexCount}, {"triangles", b.indexCount / 3}, {"textureIndex", b.textureIndex}, {"texture", tex}, {"blendMode", b.blendMode}, {"flags", b.flags}}); } j["batchDetail"] = bs; j["texturePaths"] = wom.texturePaths; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Mesh: %s.wom\n", base.c_str()); std::printf(" name : %s\n", wom.name.c_str()); std::printf(" version : v%u\n", wom.version); std::printf("\n Counts:\n"); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); std::printf(" bones : %zu\n", wom.bones.size()); std::printf(" anims : %zu\n", wom.animations.size()); std::printf(" batches : %zu\n", wom.batches.size()); std::printf(" textures : %zu\n", wom.texturePaths.size()); std::printf("\n Bounds:\n"); std::printf(" min : (%.3f, %.3f, %.3f)\n", wom.boundMin.x, wom.boundMin.y, wom.boundMin.z); std::printf(" max : (%.3f, %.3f, %.3f)\n", wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); std::printf(" radius : %.3f\n", wom.boundRadius); if (!wom.batches.empty()) { std::printf("\n Batches:\n"); std::printf(" idx iStart iCount tris blend texture\n"); for (size_t k = 0; k < wom.batches.size(); ++k) { const auto& b = wom.batches[k]; std::string tex = "<oob>"; if (b.textureIndex < wom.texturePaths.size()) tex = wom.texturePaths[b.textureIndex]; if (tex.empty()) tex = "(empty)"; int blend = b.blendMode < 8 ? b.blendMode : 0; std::printf(" %3zu %6u %6u %4u %-10s %s\n", k, b.indexStart, b.indexCount, b.indexCount / 3, blendNames[blend], tex.c_str()); } } if (!wom.texturePaths.empty()) { std::printf("\n Texture slots:\n"); for (size_t k = 0; k < wom.texturePaths.size(); ++k) { std::printf(" [%zu] %s\n", k, wom.texturePaths[k].empty() ? "(empty placeholder)" : wom.texturePaths[k].c_str()); } } return 0; } else if (std::strcmp(argv[i], "--info-mesh-storage-budget") == 0 && i + 1 < argc) { // Estimated bytes-per-category breakdown for a WOM. // Numbers are based on the in-memory struct sizes, not // the actual on-disk encoding (which has framing // overhead) — but the relative shares are accurate and // help users decide where shrinking efforts pay off. // // For example: a heightmap mesh's bytes are dominated by // vertices, so reducing vertex count is the lever to // pull. A skeletal mesh's animation keyframes can dwarf // the geometry itself — surfacing that lets the user // know to consider --strip-mesh --anims. std::string base = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom") { base = base.substr(0, base.size() - 4); } if (!wowee::pipeline::WoweeModelLoader::exists(base)) { std::fprintf(stderr, "info-mesh-storage-budget: %s.wom does not exist\n", base.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(base); if (!wom.isValid()) { std::fprintf(stderr, "info-mesh-storage-budget: failed to load %s.wom\n", base.c_str()); return 1; } // Per-category byte estimates. Vertex is 12+12+8+4+4=40 // bytes (pos/normal/uv/4 weights/4 indices). Index is // 4 bytes. Bone is 4+2+12+4=22 bytes. Batch is 4+4+4+2+ // 2=16. Animation keyframe is 4+12+16+12=44 bytes. // Texture path is summed length plus a small per-string // overhead. uint64_t vertBytes = wom.vertices.size() * 40; uint64_t idxBytes = wom.indices.size() * 4; uint64_t boneBytes = wom.bones.size() * 22; uint64_t batchBytes = wom.batches.size() * 16; uint64_t animBytes = 0; size_t totalKeyframes = 0; for (const auto& a : wom.animations) { animBytes += 12; // id + duration + movingSpeed for (const auto& bone : a.boneKeyframes) { animBytes += bone.size() * 44; totalKeyframes += bone.size(); } } uint64_t texBytes = 0; for (const auto& t : wom.texturePaths) texBytes += t.size() + 8; namespace fs = std::filesystem; uint64_t actualBytes = fs::file_size(base + ".wom"); uint64_t estBytes = vertBytes + idxBytes + boneBytes + batchBytes + animBytes + texBytes; struct Row { const char* name; uint64_t bytes; }; std::vector<Row> rows = { {"vertices ", vertBytes}, {"indices ", idxBytes}, {"bones ", boneBytes}, {"animations", animBytes}, {"batches ", batchBytes}, {"textures ", texBytes}, }; if (jsonOut) { nlohmann::json j; j["base"] = base; j["fileBytes"] = actualBytes; j["estimatedBytes"] = estBytes; j["categories"] = nlohmann::json::object(); for (const auto& r : rows) { double share = estBytes > 0 ? 100.0 * r.bytes / estBytes : 0.0; j["categories"][r.name] = {{"bytes", r.bytes}, {"share", share}}; } j["counts"] = {{"vertices", wom.vertices.size()}, {"indices", wom.indices.size()}, {"bones", wom.bones.size()}, {"animations", wom.animations.size()}, {"keyframes", totalKeyframes}, {"batches", wom.batches.size()}, {"textures", wom.texturePaths.size()}}; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Mesh storage budget: %s.wom\n", base.c_str()); std::printf(" on-disk : %llu bytes (%.1f KB)\n", static_cast<unsigned long long>(actualBytes), actualBytes / 1024.0); std::printf(" estimated : %llu bytes (sum of in-memory parts)\n", static_cast<unsigned long long>(estBytes)); std::printf("\n Per-category (estimated):\n"); for (const auto& r : rows) { if (r.bytes == 0) continue; double share = estBytes > 0 ? 100.0 * r.bytes / estBytes : 0.0; std::printf(" %s : %10llu bytes (%5.1f%%)\n", r.name, static_cast<unsigned long long>(r.bytes), share); } std::printf("\n Tips:\n"); if (animBytes > vertBytes && wom.animations.size() > 0) { std::printf(" - animations dominate; --strip-mesh " "--anims would save %.1f KB\n", animBytes / 1024.0); } if (boneBytes > vertBytes / 2 && wom.bones.size() > 0) { std::printf(" - bones non-trivial; consider " "--strip-mesh --bones for static placement\n"); } if (vertBytes > estBytes / 2) { std::printf(" - vertices dominate; check if a " "lower-poly variant works for placement\n"); } return 0; } else if (std::strcmp(argv[i], "--info-project-models-total") == 0 && i + 1 < argc) { // Multi-zone aggregate. Walks every zone in <projectDir>, // sums the same WOM/WOB metrics --info-zone-models-total // emits, and prints a per-zone breakdown table followed // by project-wide totals. Useful for capacity planning // across an entire content project. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "info-project-models-total: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); struct ZRow { std::string name; int womCount = 0, wobCount = 0; uint64_t womVerts = 0, womIndices = 0, womBones = 0; uint64_t womAnims = 0, womBatches = 0; uint64_t wobGroups = 0, wobVerts = 0, wobIndices = 0; uint64_t wobDoodads = 0, wobPortals = 0; }; std::vector<ZRow> rows; ZRow tot; tot.name = "TOTAL"; for (const auto& zoneDir : zones) { ZRow r; r.name = fs::path(zoneDir).filename().string(); std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::string base = e.path().string(); if (base.size() > ext.size()) base = base.substr(0, base.size() - ext.size()); if (ext == ".wom") { r.womCount++; auto wom = wowee::pipeline::WoweeModelLoader::load(base); r.womVerts += wom.vertices.size(); r.womIndices += wom.indices.size(); r.womBones += wom.bones.size(); r.womAnims += wom.animations.size(); r.womBatches += wom.batches.size(); } else if (ext == ".wob") { r.wobCount++; auto wob = wowee::pipeline::WoweeBuildingLoader::load(base); r.wobGroups += wob.groups.size(); for (const auto& g : wob.groups) { r.wobVerts += g.vertices.size(); r.wobIndices += g.indices.size(); } r.wobDoodads += wob.doodads.size(); r.wobPortals += wob.portals.size(); } } tot.womCount += r.womCount; tot.wobCount += r.wobCount; tot.womVerts += r.womVerts; tot.womIndices += r.womIndices; tot.womBones += r.womBones; tot.womAnims += r.womAnims; tot.womBatches += r.womBatches; tot.wobGroups += r.wobGroups; tot.wobVerts += r.wobVerts; tot.wobIndices += r.wobIndices; tot.wobDoodads += r.wobDoodads; tot.wobPortals += r.wobPortals; rows.push_back(r); } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zones"] = nlohmann::json::array(); auto rowJson = [](const ZRow& r) { nlohmann::json z; z["name"] = r.name; z["wom"] = {{"count", r.womCount}, {"vertices", r.womVerts}, {"indices", r.womIndices}, {"triangles", r.womIndices / 3}, {"bones", r.womBones}, {"animations", r.womAnims}, {"batches", r.womBatches}}; z["wob"] = {{"count", r.wobCount}, {"groups", r.wobGroups}, {"vertices", r.wobVerts}, {"indices", r.wobIndices}, {"triangles", r.wobIndices / 3}, {"doodads", r.wobDoodads}, {"portals", r.wobPortals}}; return z; }; for (const auto& r : rows) j["zones"].push_back(rowJson(r)); j["total"] = rowJson(tot); std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project models total: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n\n", zones.size()); std::printf(" zone WOMs WOMtri bones WOBs WOBtri doodads\n"); for (const auto& r : rows) { std::printf(" %-20s %5d %7llu %6llu %5d %7llu %8llu\n", r.name.substr(0, 20).c_str(), r.womCount, static_cast<unsigned long long>(r.womIndices / 3), static_cast<unsigned long long>(r.womBones), r.wobCount, static_cast<unsigned long long>(r.wobIndices / 3), static_cast<unsigned long long>(r.wobDoodads)); } std::printf(" %-20s %5d %7llu %6llu %5d %7llu %8llu\n", tot.name.c_str(), tot.womCount, static_cast<unsigned long long>(tot.womIndices / 3), static_cast<unsigned long long>(tot.womBones), tot.wobCount, static_cast<unsigned long long>(tot.wobIndices / 3), static_cast<unsigned long long>(tot.wobDoodads)); std::printf("\n Combined verts/tris (WOM+WOB): %llu / %llu\n", static_cast<unsigned long long>(tot.womVerts + tot.wobVerts), static_cast<unsigned long long>((tot.womIndices + tot.wobIndices) / 3)); return 0; } else if (std::strcmp(argv[i], "--info-wob") == 0 && i + 1 < argc) { std::string base = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob") base = base.substr(0, base.size() - 4); if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) { std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str()); return 1; } auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); size_t totalVerts = 0, totalIdx = 0, totalMats = 0; for (const auto& g : bld.groups) { totalVerts += g.vertices.size(); totalIdx += g.indices.size(); totalMats += g.materials.size(); } if (jsonOut) { nlohmann::json j; j["wob"] = base + ".wob"; j["name"] = bld.name; j["groups"] = bld.groups.size(); j["portals"] = bld.portals.size(); j["doodads"] = bld.doodads.size(); j["boundRadius"] = bld.boundRadius; j["totalVerts"] = totalVerts; j["totalTris"] = totalIdx / 3; j["totalMats"] = totalMats; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("WOB: %s.wob\n", base.c_str()); std::printf(" name : %s\n", bld.name.c_str()); std::printf(" groups : %zu\n", bld.groups.size()); std::printf(" portals : %zu\n", bld.portals.size()); std::printf(" doodads : %zu\n", bld.doodads.size()); std::printf(" boundRadius : %.2f\n", bld.boundRadius); std::printf(" total verts : %zu\n", totalVerts); std::printf(" total tris : %zu\n", totalIdx / 3); std::printf(" total mats : %zu (across all groups)\n", totalMats); return 0; } else if (std::strcmp(argv[i], "--copy-project") == 0 && i + 2 < argc) { // Recursively copy an entire project tree. Refuses to // overwrite an existing destination so a typo doesn't // silently merge into the wrong project. std::string fromDir = argv[++i]; std::string toDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(fromDir) || !fs::is_directory(fromDir)) { std::fprintf(stderr, "copy-project: %s is not a directory\n", fromDir.c_str()); return 1; } if (fs::exists(toDir)) { std::fprintf(stderr, "copy-project: destination %s already exists " "(delete it first if intentional)\n", toDir.c_str()); return 1; } std::error_code ec; fs::copy(fromDir, toDir, fs::copy_options::recursive | fs::copy_options::copy_symlinks, ec); if (ec) { std::fprintf(stderr, "copy-project: copy failed (%s)\n", ec.message().c_str()); return 1; } // Count what was copied for the report. int zoneCount = 0, fileCount = 0; uint64_t totalBytes = 0; for (const auto& entry : fs::directory_iterator(toDir, ec)) { if (entry.is_directory() && fs::exists(entry.path() / "zone.json")) zoneCount++; } for (const auto& e : fs::recursive_directory_iterator(toDir, ec)) { if (e.is_regular_file()) { fileCount++; totalBytes += e.file_size(ec); } } std::printf("Copied %s -> %s\n", fromDir.c_str(), toDir.c_str()); std::printf(" zones : %d\n", zoneCount); std::printf(" files : %d\n", fileCount); std::printf(" total bytes : %llu (%.1f MB)\n", static_cast<unsigned long long>(totalBytes), totalBytes / (1024.0 * 1024.0)); return 0; } else if (std::strcmp(argv[i], "--info-wot") == 0 && i + 1 < argc) { std::string base = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; // Accept "/path/file.wot", "/path/file.whm", or "/path/file"; the // loader pairs both extensions from the same base path. for (const char* ext : {".wot", ".whm"}) { if (base.size() >= 4 && base.substr(base.size() - 4) == ext) { base = base.substr(0, base.size() - 4); break; } } if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) { std::fprintf(stderr, "WOT/WHM not found at base: %s\n", base.c_str()); return 1; } wowee::pipeline::ADTTerrain terrain; if (!wowee::pipeline::WoweeTerrainLoader::load(base, terrain)) { std::fprintf(stderr, "Failed to load WOT/WHM: %s\n", base.c_str()); return 1; } int chunksWithHeights = 0, chunksWithLayers = 0, chunksWithWater = 0; float minH = 1e30f, maxH = -1e30f; for (int ci = 0; ci < 256; ci++) { const auto& c = terrain.chunks[ci]; if (c.hasHeightMap()) { chunksWithHeights++; for (float h : c.heightMap.heights) { float total = c.position[2] + h; if (total < minH) minH = total; if (total > maxH) maxH = total; } } if (!c.layers.empty()) chunksWithLayers++; if (terrain.waterData[ci].hasWater()) chunksWithWater++; } if (jsonOut) { nlohmann::json j; j["base"] = base; j["tileX"] = terrain.coord.x; j["tileY"] = terrain.coord.y; j["chunks"] = {{"withHeightmap", chunksWithHeights}, {"withLayers", chunksWithLayers}, {"withWater", chunksWithWater}}; j["textures"] = terrain.textures.size(); j["doodads"] = terrain.doodadPlacements.size(); j["wmos"] = terrain.wmoPlacements.size(); if (chunksWithHeights > 0) { j["heightMin"] = minH; j["heightMax"] = maxH; } std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("WOT/WHM: %s\n", base.c_str()); std::printf(" tile : (%d, %d)\n", terrain.coord.x, terrain.coord.y); std::printf(" chunks : %d/256 with heightmap\n", chunksWithHeights); std::printf(" layers : %d/256 chunks with texture layers\n", chunksWithLayers); std::printf(" water : %d/256 chunks with water\n", chunksWithWater); std::printf(" textures : %zu\n", terrain.textures.size()); std::printf(" doodads : %zu\n", terrain.doodadPlacements.size()); std::printf(" WMOs : %zu\n", terrain.wmoPlacements.size()); if (chunksWithHeights > 0) { std::printf(" height range : [%.2f, %.2f]\n", minH, maxH); } return 0; } else if (std::strcmp(argv[i], "--info-woc") == 0 && i + 1 < argc) { std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; if (path.size() < 4 || path.substr(path.size() - 4) != ".woc") path += ".woc"; auto col = wowee::pipeline::WoweeCollisionBuilder::load(path); if (!col.isValid()) { std::fprintf(stderr, "WOC not found or invalid: %s\n", path.c_str()); return 1; } if (jsonOut) { nlohmann::json j; j["woc"] = path; j["tileX"] = col.tileX; j["tileY"] = col.tileY; j["triangles"] = col.triangles.size(); j["walkable"] = col.walkableCount(); j["steep"] = col.steepCount(); j["boundsMin"] = {col.bounds.min.x, col.bounds.min.y, col.bounds.min.z}; j["boundsMax"] = {col.bounds.max.x, col.bounds.max.y, col.bounds.max.z}; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("WOC: %s\n", path.c_str()); std::printf(" tile : (%u, %u)\n", col.tileX, col.tileY); std::printf(" triangles : %zu\n", col.triangles.size()); std::printf(" walkable : %zu\n", col.walkableCount()); std::printf(" steep : %zu\n", col.steepCount()); std::printf(" bounds.min : (%.1f, %.1f, %.1f)\n", col.bounds.min.x, col.bounds.min.y, col.bounds.min.z); std::printf(" bounds.max : (%.1f, %.1f, %.1f)\n", col.bounds.max.x, col.bounds.max.y, col.bounds.max.z); return 0; } else if (std::strcmp(argv[i], "--zone-summary") == 0 && i + 1 < argc) { // One-shot zone overview: validate + creature/object/quest counts. // Collapses the most common multi-step inspection into a single // command; useful for CI reports and quick sanity checks. std::string zoneDir = argv[++i]; // Optional --json after the dir for machine-readable output. bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "zone-summary: %s does not exist\n", zoneDir.c_str()); return 1; } auto v = wowee::editor::ContentPacker::validateZone(zoneDir); // Read creature/object/quest data once so both human and JSON // outputs share the same numbers. int creatureTotal = 0, hostile = 0, qg = 0, vendor = 0; int objectTotal = 0, m2Count = 0, wmoCount = 0; int questTotal = 0, chainWarnings = 0; std::string creaturesPath = zoneDir + "/creatures.json"; if (fs::exists(creaturesPath)) { wowee::editor::NpcSpawner sp; if (sp.loadFromFile(creaturesPath)) { creatureTotal = static_cast<int>(sp.getSpawns().size()); for (const auto& s : sp.getSpawns()) { if (s.hostile) hostile++; if (s.questgiver) qg++; if (s.vendor) vendor++; } } } std::string objectsPath = zoneDir + "/objects.json"; if (fs::exists(objectsPath)) { wowee::editor::ObjectPlacer op; if (op.loadFromFile(objectsPath)) { objectTotal = static_cast<int>(op.getObjects().size()); for (const auto& o : op.getObjects()) { if (o.type == wowee::editor::PlaceableType::M2) m2Count++; else wmoCount++; } } } std::string questsPath = zoneDir + "/quests.json"; if (fs::exists(questsPath)) { wowee::editor::QuestEditor qe; if (qe.loadFromFile(questsPath)) { questTotal = static_cast<int>(qe.getQuests().size()); std::vector<std::string> errors; qe.validateChains(errors); chainWarnings = static_cast<int>(errors.size()); } } if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["score"] = v.openFormatScore(); j["maxScore"] = 7; j["formats"] = v.summary(); j["counts"] = { {"wot", v.wotCount}, {"whm", v.whmCount}, {"wom", v.womCount}, {"wob", v.wobCount}, {"woc", v.wocCount}, {"png", v.pngCount}, }; j["creatures"] = { {"total", creatureTotal}, {"hostile", hostile}, {"questgiver", qg}, {"vendor", vendor}, }; j["objects"] = { {"total", objectTotal}, {"m2", m2Count}, {"wmo", wmoCount}, }; j["quests"] = { {"total", questTotal}, {"chainWarnings", chainWarnings}, }; std::printf("%s\n", j.dump(2).c_str()); return v.openFormatScore() == 7 ? 0 : 1; } std::printf("Zone: %s\n", zoneDir.c_str()); std::printf(" open formats : %d/7 (%s)\n", v.openFormatScore(), v.summary().c_str()); std::printf(" WOT/WHM : %d/%d WOM: %d WOB: %d WOC: %d PNG: %d\n", v.wotCount, v.whmCount, v.womCount, v.wobCount, v.wocCount, v.pngCount); if (creatureTotal > 0) { std::printf(" creatures : %d (%d hostile, %d quest, %d vendor)\n", creatureTotal, hostile, qg, vendor); } if (objectTotal > 0) { std::printf(" objects : %d (%d M2, %d WMO)\n", objectTotal, m2Count, wmoCount); } if (questTotal > 0) { std::printf(" quests : %d (%d chain warnings)\n", questTotal, chainWarnings); } return v.openFormatScore() == 7 ? 0 : 1; } else if (std::strcmp(argv[i], "--info-zone-tree") == 0 && i + 1 < argc) { // Pretty `tree`-style hierarchical view of a zone's contents. // Designed for at-a-glance comprehension — what creatures, // what objects, what quests, what tiles, what files. No // --json flag because the structured equivalent is just // running --info-* per category and concatenating. std::string zoneDir = argv[++i]; namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "info-zone-tree: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "info-zone-tree: parse failed\n"); return 1; } wowee::editor::NpcSpawner sp; sp.loadFromFile(zoneDir + "/creatures.json"); wowee::editor::ObjectPlacer op; op.loadFromFile(zoneDir + "/objects.json"); wowee::editor::QuestEditor qe; qe.loadFromFile(zoneDir + "/quests.json"); // Walk on-disk files for the 'Files' branch. std::vector<std::string> diskFiles; std::error_code ec; for (const auto& e : fs::directory_iterator(zoneDir, ec)) { if (e.is_regular_file()) { diskFiles.push_back(e.path().filename().string()); } } std::sort(diskFiles.begin(), diskFiles.end()); // Tree-drawing helpers — Unix box characters since most // terminals support UTF-8 by default. Pre-compute prefix // strings so leaf vs branch alignment looks right. auto branch = [](bool last) { return last ? "└─ " : "├─ "; }; auto cont = [](bool last) { return last ? " " : "│ "; }; std::printf("%s/\n", zm.displayName.empty() ? zm.mapName.c_str() : zm.displayName.c_str()); // Manifest section std::printf("├─ Manifest\n"); std::printf("│ ├─ mapName : %s\n", zm.mapName.c_str()); std::printf("│ ├─ mapId : %u\n", zm.mapId); std::printf("│ ├─ baseHeight : %.1f\n", zm.baseHeight); std::printf("│ ├─ biome : %s\n", zm.biome.empty() ? "(unset)" : zm.biome.c_str()); std::printf("│ └─ flags : %s%s%s%s\n", zm.allowFlying ? "fly " : "", zm.pvpEnabled ? "pvp " : "", zm.isIndoor ? "indoor " : "", zm.isSanctuary ? "sanctuary " : ""); // Tiles std::printf("├─ Tiles (%zu)\n", zm.tiles.size()); for (size_t k = 0; k < zm.tiles.size(); ++k) { bool last = (k == zm.tiles.size() - 1); std::printf("│ %s(%d, %d)\n", branch(last), zm.tiles[k].first, zm.tiles[k].second); } // Creatures std::printf("├─ Creatures (%zu)\n", sp.spawnCount()); for (size_t k = 0; k < sp.spawnCount(); ++k) { bool last = (k == sp.spawnCount() - 1); const auto& s = sp.getSpawns()[k]; std::printf("│ %slvl %u %s%s\n", branch(last), s.level, s.name.c_str(), s.hostile ? " [hostile]" : ""); } // Objects std::printf("├─ Objects (%zu)\n", op.getObjects().size()); for (size_t k = 0; k < op.getObjects().size(); ++k) { bool last = (k == op.getObjects().size() - 1); const auto& o = op.getObjects()[k]; std::printf("│ %s%s %s\n", branch(last), o.type == wowee::editor::PlaceableType::M2 ? "m2 " : "wmo", o.path.c_str()); } // Quests with sub-tree of objectives std::printf("├─ Quests (%zu)\n", qe.questCount()); using OT = wowee::editor::QuestObjectiveType; auto typeName = [](OT t) { switch (t) { case OT::KillCreature: return "kill"; case OT::CollectItem: return "collect"; case OT::TalkToNPC: return "talk"; case OT::ExploreArea: return "explore"; case OT::EscortNPC: return "escort"; case OT::UseObject: return "use"; } return "?"; }; for (size_t k = 0; k < qe.questCount(); ++k) { bool lastQ = (k == qe.questCount() - 1); const auto& q = qe.getQuests()[k]; std::printf("│ %s[%u] %s (lvl %u, %u XP)\n", branch(lastQ), q.id, q.title.c_str(), q.requiredLevel, q.reward.xp); // Objectives indented under the quest. Use 'cont' for // the prior column so vertical bars align. for (size_t o = 0; o < q.objectives.size(); ++o) { bool lastO = (o == q.objectives.size() - 1 && q.reward.itemRewards.empty()); const auto& obj = q.objectives[o]; std::printf("│ %s%s%s ×%u %s\n", cont(lastQ), branch(lastO), typeName(obj.type), obj.targetCount, obj.targetName.c_str()); } for (size_t r = 0; r < q.reward.itemRewards.size(); ++r) { bool lastR = (r == q.reward.itemRewards.size() - 1); std::printf("│ %s%sreward: %s\n", cont(lastQ), branch(lastR), q.reward.itemRewards[r].c_str()); } } // Files (last top-level branch — uses └─) std::printf("└─ Files (%zu)\n", diskFiles.size()); for (size_t k = 0; k < diskFiles.size(); ++k) { bool last = (k == diskFiles.size() - 1); std::printf(" %s%s\n", branch(last), diskFiles[k].c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-project-tree") == 0 && i + 1 < argc) { // Project-level tree view: every zone with quick counts + // bake/viewer status. --info-zone-tree drills into one zone; // this gives the bird's-eye view across the whole project. std::string projectDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "info-project-tree: %s is not a directory\n", projectDir.c_str()); return 1; } struct ZE { std::string name, dir, mapName; int tiles = 0, creatures = 0, objects = 0, quests = 0; bool hasGlb = false, hasObj = false, hasStl = false; bool hasHtml = false, hasZoneMd = false; }; std::vector<ZE> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; wowee::editor::ZoneManifest zm; if (!zm.load((entry.path() / "zone.json").string())) continue; ZE z; z.name = zm.displayName.empty() ? zm.mapName : zm.displayName; z.dir = entry.path().filename().string(); z.mapName = zm.mapName; z.tiles = static_cast<int>(zm.tiles.size()); wowee::editor::NpcSpawner sp; if (sp.loadFromFile((entry.path() / "creatures.json").string())) { z.creatures = static_cast<int>(sp.spawnCount()); } wowee::editor::ObjectPlacer op; if (op.loadFromFile((entry.path() / "objects.json").string())) { z.objects = static_cast<int>(op.getObjects().size()); } wowee::editor::QuestEditor qe; if (qe.loadFromFile((entry.path() / "quests.json").string())) { z.quests = static_cast<int>(qe.questCount()); } z.hasGlb = fs::exists(entry.path() / (zm.mapName + ".glb")); z.hasObj = fs::exists(entry.path() / (zm.mapName + ".obj")); z.hasStl = fs::exists(entry.path() / (zm.mapName + ".stl")); z.hasHtml = fs::exists(entry.path() / (zm.mapName + ".html")); z.hasZoneMd = fs::exists(entry.path() / "ZONE.md"); zones.push_back(std::move(z)); } std::sort(zones.begin(), zones.end(), [](const ZE& a, const ZE& b) { return a.name < b.name; }); int totalTiles = 0, totalCreat = 0, totalObj = 0, totalQuest = 0; for (const auto& z : zones) { totalTiles += z.tiles; totalCreat += z.creatures; totalObj += z.objects; totalQuest += z.quests; } std::printf("%s/ (%zu zones, %d tiles, %d creatures, %d objects, %d quests)\n", projectDir.c_str(), zones.size(), totalTiles, totalCreat, totalObj, totalQuest); for (size_t k = 0; k < zones.size(); ++k) { bool lastZ = (k == zones.size() - 1); const auto& z = zones[k]; const char* zBranch = lastZ ? "└─ " : "├─ "; const char* zCont = lastZ ? " " : "│ "; std::printf("%s%s/ (tiles=%d, creat=%d, obj=%d, quest=%d)\n", zBranch, z.dir.c_str(), z.tiles, z.creatures, z.objects, z.quests); // Artifact status row — quick visual of what's been baked. std::printf("%s├─ name : %s\n", zCont, z.name.c_str()); std::printf("%s├─ mapName : %s\n", zCont, z.mapName.c_str()); std::printf("%s├─ artifacts : %s%s%s%s%s%s\n", zCont, z.hasGlb ? ".glb " : "", z.hasObj ? ".obj " : "", z.hasStl ? ".stl " : "", z.hasHtml ? ".html " : "", z.hasZoneMd ? "ZONE.md " : "", (!z.hasGlb && !z.hasObj && !z.hasStl && !z.hasHtml && !z.hasZoneMd) ? "(none)" : ""); std::printf("%s└─ status : %s\n", zCont, (z.creatures || z.objects || z.quests) ? "populated" : "empty (only terrain)"); } return 0; } else if (std::strcmp(argv[i], "--info-zone-bytes") == 0 && i + 1 < argc) { // Per-file size breakdown grouped by category, sorted by size // descending. Useful for capacity planning ('which file is // 80% of my zone?') and pre-strip-zone audits ('how much // would --strip-zone free?'). --zone-stats aggregates across // multiple zones; this drills into one zone's contents. std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "info-zone-bytes: %s does not exist\n", zoneDir.c_str()); return 1; } // Categorize by extension into source vs derived buckets so // the breakdown surfaces what would be stripped. struct Entry { std::string path; // relative to zoneDir uint64_t bytes; std::string category; }; std::vector<Entry> entries; uint64_t totalBytes = 0; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::string name = e.path().filename().string(); std::string rel = fs::relative(e.path(), zoneDir, ec).string(); if (ec) rel = e.path().string(); std::string cat; if (ext == ".whm" || ext == ".wot" || ext == ".woc") cat = "terrain"; else if (ext == ".wom") cat = "model (open)"; else if (ext == ".wob") cat = "building (open)"; else if (ext == ".m2" || ext == ".skin") cat = "model (proprietary)"; else if (ext == ".wmo") cat = "building (proprietary)"; else if (ext == ".blp") cat = "texture (proprietary)"; else if (ext == ".png") cat = "texture (open/derived)"; else if (ext == ".dbc") cat = "DBC (proprietary)"; else if (ext == ".json") cat = "json (source)"; else if (ext == ".glb" || ext == ".obj" || ext == ".stl") cat = "3D export (derived)"; else if (ext == ".html" || ext == ".dot" || ext == ".csv") cat = "doc (derived)"; else if (name == "ZONE.md" || name == "DEPS.md") cat = "doc (derived)"; else cat = "other"; uint64_t sz = e.file_size(ec); if (ec) continue; totalBytes += sz; entries.push_back({rel, sz, cat}); } // Sort largest first so the heaviest contributors are at the // top of the table. std::sort(entries.begin(), entries.end(), [](const Entry& a, const Entry& b) { return a.bytes > b.bytes; }); // Aggregate per-category for the summary footer. std::map<std::string, std::pair<uint64_t, int>> byCategory; for (const auto& e : entries) { byCategory[e.category].first += e.bytes; byCategory[e.category].second++; } if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["totalBytes"] = totalBytes; j["fileCount"] = entries.size(); nlohmann::json arr = nlohmann::json::array(); for (const auto& e : entries) { arr.push_back({{"path", e.path}, {"bytes", e.bytes}, {"category", e.category}}); } j["files"] = arr; nlohmann::json catObj; for (const auto& [c, p] : byCategory) { catObj[c] = {{"bytes", p.first}, {"count", p.second}}; } j["byCategory"] = catObj; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone bytes: %s\n", zoneDir.c_str()); std::printf(" total: %llu bytes (%.1f KB) across %zu file(s)\n", static_cast<unsigned long long>(totalBytes), totalBytes / 1024.0, entries.size()); std::printf("\n Per-file (largest first):\n"); std::printf(" %-50s %12s category\n", "path", "bytes"); for (const auto& e : entries) { std::printf(" %-50s %12llu %s\n", e.path.substr(0, 50).c_str(), static_cast<unsigned long long>(e.bytes), e.category.c_str()); } std::printf("\n Per-category:\n"); for (const auto& [c, p] : byCategory) { std::printf(" %-26s %4d files %12llu bytes (%5.1f%%)\n", c.c_str(), p.second, static_cast<unsigned long long>(p.first), totalBytes ? (100.0 * p.first / totalBytes) : 0.0); } return 0; } else if (std::strcmp(argv[i], "--info-project-bytes") == 0 && i + 1 < argc) { // Project-wide byte audit. Walks every zone in projectDir, // re-uses --info-zone-bytes' categorization, and prints a // per-zone breakdown table plus aggregated category totals. // The headline number is the proprietary-vs-open size split // — surfaces how much disk a project still spends on .m2/ // .wmo/.blp/.dbc payloads vs the open WOM/WOB/PNG/JSON // replacements. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "info-project-bytes: %s is not a directory\n", projectDir.c_str()); return 1; } // Same categorizer used by --info-zone-bytes — keep in sync // if categories evolve there. auto categorize = [](const fs::path& p) -> std::string { std::string ext = p.extension().string(); std::string name = p.filename().string(); if (ext == ".whm" || ext == ".wot" || ext == ".woc") return "terrain"; if (ext == ".wom") return "model (open)"; if (ext == ".wob") return "building (open)"; if (ext == ".m2" || ext == ".skin") return "model (proprietary)"; if (ext == ".wmo") return "building (proprietary)"; if (ext == ".blp") return "texture (proprietary)"; if (ext == ".png") return "texture (open/derived)"; if (ext == ".dbc") return "DBC (proprietary)"; if (ext == ".json") return "json (source)"; if (ext == ".glb" || ext == ".obj" || ext == ".stl") return "3D export (derived)"; if (ext == ".html" || ext == ".dot" || ext == ".csv") return "doc (derived)"; if (name == "ZONE.md" || name == "DEPS.md") return "doc (derived)"; return "other"; }; // The proprietary-vs-open split is a key quality metric for // the open-format migration push. Anything tagged "(open)" // or "(open/derived)" counts toward open; anything tagged // "(proprietary)" counts toward proprietary; everything // else ("terrain" / "json (source)" / derived docs) is // neutral. auto isOpen = [](const std::string& cat) { return cat.find("(open") != std::string::npos; }; auto isProprietary = [](const std::string& cat) { return cat.find("(proprietary)") != std::string::npos; }; std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); struct ZRow { std::string name; uint64_t totalBytes = 0; int fileCount = 0; uint64_t openBytes = 0; uint64_t propBytes = 0; }; std::vector<ZRow> rows; std::map<std::string, std::pair<uint64_t, int>> globalCat; uint64_t projectBytes = 0; int projectFiles = 0; for (const auto& zoneDir : zones) { ZRow r; r.name = fs::path(zoneDir).filename().string(); std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; uint64_t sz = e.file_size(ec); if (ec) continue; std::string cat = categorize(e.path()); r.totalBytes += sz; r.fileCount++; if (isOpen(cat)) r.openBytes += sz; else if (isProprietary(cat)) r.propBytes += sz; globalCat[cat].first += sz; globalCat[cat].second++; } projectBytes += r.totalBytes; projectFiles += r.fileCount; rows.push_back(r); } uint64_t globalOpen = 0, globalProp = 0; for (const auto& [c, p] : globalCat) { if (isOpen(c)) globalOpen += p.first; else if (isProprietary(c)) globalProp += p.first; } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["totalBytes"] = projectBytes; j["fileCount"] = projectFiles; j["openBytes"] = globalOpen; j["proprietaryBytes"] = globalProp; nlohmann::json zarr = nlohmann::json::array(); for (const auto& r : rows) { zarr.push_back({{"name", r.name}, {"totalBytes", r.totalBytes}, {"fileCount", r.fileCount}, {"openBytes", r.openBytes}, {"proprietaryBytes", r.propBytes}}); } j["zones"] = zarr; nlohmann::json catObj; for (const auto& [c, p] : globalCat) { catObj[c] = {{"bytes", p.first}, {"count", p.second}}; } j["byCategory"] = catObj; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project bytes: %s\n", projectDir.c_str()); std::printf(" total : %llu bytes (%.1f KB) across %d file(s) in %zu zone(s)\n", static_cast<unsigned long long>(projectBytes), projectBytes / 1024.0, projectFiles, zones.size()); std::printf("\n zone files bytes open(B) prop(B)\n"); for (const auto& r : rows) { std::printf(" %-22s %5d %10llu %8llu %7llu\n", r.name.substr(0, 22).c_str(), r.fileCount, static_cast<unsigned long long>(r.totalBytes), static_cast<unsigned long long>(r.openBytes), static_cast<unsigned long long>(r.propBytes)); } std::printf("\n Per-category (project-wide):\n"); for (const auto& [c, p] : globalCat) { std::printf(" %-26s %4d files %12llu bytes (%5.1f%%)\n", c.c_str(), p.second, static_cast<unsigned long long>(p.first), projectBytes ? (100.0 * p.first / projectBytes) : 0.0); } std::printf("\n Open-vs-proprietary split:\n"); std::printf(" open : %12llu bytes\n", static_cast<unsigned long long>(globalOpen)); std::printf(" proprietary : %12llu bytes\n", static_cast<unsigned long long>(globalProp)); uint64_t denom = globalOpen + globalProp; if (denom > 0) { std::printf(" open share : %5.1f%%\n", 100.0 * globalOpen / denom); } return 0; } else if (std::strcmp(argv[i], "--info-zone-extents") == 0 && i + 1 < argc) { // Compute the zone's spatial bounding box. XY from manifest // tile coords (each tile is 533.33 yards); Z from height // range across all loaded chunks. Useful for sizing the // camera frustum, planning where new tiles can fit // contiguously, or quick sanity-checks ('this zone is 4km // across? that seems wrong'). std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "info-zone-extents: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "info-zone-extents: parse failed\n"); return 1; } // Tile XY range — straightforward integer min/max. int tileMinX = 64, tileMaxX = -1; int tileMinY = 64, tileMaxY = -1; for (const auto& [tx, ty] : zm.tiles) { tileMinX = std::min(tileMinX, tx); tileMaxX = std::max(tileMaxX, tx); tileMinY = std::min(tileMinY, ty); tileMaxY = std::max(tileMaxY, ty); } // Z range from loaded chunks. Walk every WHM tile; this is // the same scan --info-whm does per-tile but rolled up. float zMin = 1e30f, zMax = -1e30f; int loadedTiles = 0, missingTiles = 0; for (const auto& [tx, ty] : zm.tiles) { std::string tileBase = zoneDir + "/" + zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty); if (!wowee::pipeline::WoweeTerrainLoader::exists(tileBase)) { missingTiles++; continue; } wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); loadedTiles++; for (const auto& chunk : terrain.chunks) { if (!chunk.heightMap.isLoaded()) continue; float baseZ = chunk.position[2]; for (float h : chunk.heightMap.heights) { if (!std::isfinite(h)) continue; zMin = std::min(zMin, baseZ + h); zMax = std::max(zMax, baseZ + h); } } } if (zMin > zMax) { zMin = 0; zMax = 0; } // Convert tile coords to world-space yards. WoW grid centers // tile (32, 32) at world origin; +X tile = -X world (north), // +Y tile = -Y world (west). constexpr float kTileSize = 533.33333f; float worldMinX = (32.0f - tileMaxY - 1) * kTileSize; float worldMaxX = (32.0f - tileMinY) * kTileSize; float worldMinY = (32.0f - tileMaxX - 1) * kTileSize; float worldMaxY = (32.0f - tileMinX) * kTileSize; float widthX = worldMaxX - worldMinX; float widthY = worldMaxY - worldMinY; float heightZ = zMax - zMin; if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["tileCount"] = zm.tiles.size(); j["loadedTiles"] = loadedTiles; j["missingTiles"] = missingTiles; j["tileRange"] = {{"x", {tileMinX, tileMaxX}}, {"y", {tileMinY, tileMaxY}}}; j["worldBox"] = {{"min", {worldMinX, worldMinY, zMin}}, {"max", {worldMaxX, worldMaxY, zMax}}}; j["sizeYards"] = {widthX, widthY, heightZ}; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone extents: %s\n", zoneDir.c_str()); std::printf(" tile count : %zu (%d loaded, %d missing on disk)\n", zm.tiles.size(), loadedTiles, missingTiles); if (zm.tiles.empty()) { std::printf(" *no tiles in manifest*\n"); return 0; } std::printf(" tile range : x=[%d, %d] y=[%d, %d]\n", tileMinX, tileMaxX, tileMinY, tileMaxY); std::printf(" world box : (%.1f, %.1f, %.1f) - (%.1f, %.1f, %.1f) yards\n", worldMinX, worldMinY, zMin, worldMaxX, worldMaxY, zMax); std::printf(" size : %.1f x %.1f x %.1f yards (%.0fm x %.0fm x %.1fm)\n", widthX, widthY, heightZ, widthX * 0.9144f, widthY * 0.9144f, heightZ * 0.9144f); return 0; } else if (std::strcmp(argv[i], "--info-project-extents") == 0 && i + 1 < argc) { // Combined spatial bounding box across every zone in // <projectDir>. Per-zone XY tile range + Z height range, // unioned into a project-wide world box. Useful for // understanding total project area, sizing the world map // overview, or sanity-checking that zones don't overlap // (the union should equal the sum of disjoint per-zone // boxes). std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "info-project-extents: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); constexpr float kTileSize = 533.33333f; struct ZBox { std::string name; int tileCount = 0; float wMinX = 1e30f, wMaxX = -1e30f; float wMinY = 1e30f, wMaxY = -1e30f; float zMin = 1e30f, zMax = -1e30f; }; std::vector<ZBox> rows; float gMinX = 1e30f, gMaxX = -1e30f; float gMinY = 1e30f, gMaxY = -1e30f; float gZMin = 1e30f, gZMax = -1e30f; int totalTiles = 0; for (const auto& zoneDir : zones) { ZBox b; b.name = fs::path(zoneDir).filename().string(); wowee::editor::ZoneManifest zm; if (!zm.load(zoneDir + "/zone.json")) { rows.push_back(b); continue; } b.tileCount = static_cast<int>(zm.tiles.size()); if (zm.tiles.empty()) { rows.push_back(b); continue; } int tMinX = 64, tMaxX = -1, tMinY = 64, tMaxY = -1; for (const auto& [tx, ty] : zm.tiles) { tMinX = std::min(tMinX, tx); tMaxX = std::max(tMaxX, tx); tMinY = std::min(tMinY, ty); tMaxY = std::max(tMaxY, ty); } b.wMinX = (32.0f - tMaxY - 1) * kTileSize; b.wMaxX = (32.0f - tMinY) * kTileSize; b.wMinY = (32.0f - tMaxX - 1) * kTileSize; b.wMaxY = (32.0f - tMinX) * kTileSize; for (const auto& [tx, ty] : zm.tiles) { std::string tileBase = zoneDir + "/" + zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty); if (!wowee::pipeline::WoweeTerrainLoader::exists(tileBase)) continue; wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); for (const auto& chunk : terrain.chunks) { if (!chunk.heightMap.isLoaded()) continue; float baseZ = chunk.position[2]; for (float h : chunk.heightMap.heights) { if (!std::isfinite(h)) continue; b.zMin = std::min(b.zMin, baseZ + h); b.zMax = std::max(b.zMax, baseZ + h); } } } if (b.zMin > b.zMax) { b.zMin = 0; b.zMax = 0; } gMinX = std::min(gMinX, b.wMinX); gMaxX = std::max(gMaxX, b.wMaxX); gMinY = std::min(gMinY, b.wMinY); gMaxY = std::max(gMaxY, b.wMaxY); gZMin = std::min(gZMin, b.zMin); gZMax = std::max(gZMax, b.zMax); totalTiles += b.tileCount; rows.push_back(b); } if (totalTiles == 0) { gMinX = gMaxX = gMinY = gMaxY = gZMin = gZMax = 0.0f; } float gWidthX = gMaxX - gMinX; float gWidthY = gMaxY - gMinY; float gHeightZ = gZMax - gZMin; if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = zones.size(); j["totalTiles"] = totalTiles; j["worldBox"] = {{"min", {gMinX, gMinY, gZMin}}, {"max", {gMaxX, gMaxY, gZMax}}}; j["sizeYards"] = {gWidthX, gWidthY, gHeightZ}; nlohmann::json zarr = nlohmann::json::array(); for (const auto& b : rows) { zarr.push_back({{"name", b.name}, {"tileCount", b.tileCount}, {"worldBox", {{"min", {b.wMinX, b.wMinY, b.zMin}}, {"max", {b.wMaxX, b.wMaxY, b.zMax}}}}}); } j["zones"] = zarr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project extents: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" total tiles : %d\n", totalTiles); if (totalTiles == 0) { std::printf(" *no tiles in any zone manifest*\n"); return 0; } std::printf(" world union : (%.1f, %.1f, %.1f) - (%.1f, %.1f, %.1f) yards\n", gMinX, gMinY, gZMin, gMaxX, gMaxY, gZMax); std::printf(" total size : %.1f x %.1f x %.1f yards (%.0fm x %.0fm x %.1fm)\n", gWidthX, gWidthY, gHeightZ, gWidthX * 0.9144f, gWidthY * 0.9144f, gHeightZ * 0.9144f); std::printf("\n zone tiles worldX (min..max) worldY (min..max)\n"); for (const auto& b : rows) { if (b.tileCount == 0) { std::printf(" %-20s %5d (no tiles)\n", b.name.substr(0, 20).c_str(), b.tileCount); continue; } std::printf(" %-20s %5d %9.1f .. %9.1f %9.1f .. %9.1f\n", b.name.substr(0, 20).c_str(), b.tileCount, b.wMinX, b.wMaxX, b.wMinY, b.wMaxY); } return 0; } else if (std::strcmp(argv[i], "--info-zone-water") == 0 && i + 1 < argc) { // Aggregate water-layer stats across all tiles in a zone. // Useful for confirming a 'lake zone' actually has water, // or for budget planning ('how many MH2O cells does my // archipelago zone carry?'). Liquid types: 0=water, // 1=ocean, 2=magma, 3=slime. std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "info-zone-water: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "info-zone-water: parse failed\n"); return 1; } int waterChunks = 0, totalLayers = 0; std::map<uint16_t, int> typeHist; // liquidType -> chunk count float minH = 1e30f, maxH = -1e30f; int loadedTiles = 0; for (const auto& [tx, ty] : zm.tiles) { std::string tileBase = zoneDir + "/" + zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty); if (!wowee::pipeline::WoweeTerrainLoader::exists(tileBase)) continue; wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); loadedTiles++; for (size_t c = 0; c < terrain.waterData.size(); ++c) { const auto& w = terrain.waterData[c]; if (!w.hasWater()) continue; waterChunks++; totalLayers += static_cast<int>(w.layers.size()); for (const auto& layer : w.layers) { typeHist[layer.liquidType]++; minH = std::min(minH, layer.minHeight); maxH = std::max(maxH, layer.maxHeight); } } } if (waterChunks == 0) { minH = 0; maxH = 0; } auto typeName = [](uint16_t t) { switch (t) { case 0: return "water"; case 1: return "ocean"; case 2: return "magma"; case 3: return "slime"; } return "?"; }; if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["loadedTiles"] = loadedTiles; j["waterChunks"] = waterChunks; j["totalLayers"] = totalLayers; j["heightRange"] = {minH, maxH}; nlohmann::json types = nlohmann::json::array(); for (const auto& [t, c] : typeHist) { types.push_back({{"type", t}, {"name", typeName(t)}, {"layerCount", c}}); } j["types"] = types; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone water: %s\n", zoneDir.c_str()); std::printf(" loaded tiles : %d\n", loadedTiles); std::printf(" water chunks : %d (out of %d possible)\n", waterChunks, loadedTiles * 256); std::printf(" total layers : %d\n", totalLayers); if (waterChunks > 0) { std::printf(" height range : %.2f to %.2f\n", minH, maxH); std::printf("\n By liquid type:\n"); for (const auto& [t, c] : typeHist) { std::printf(" %s (%u): %d layer(s)\n", typeName(t), t, c); } } else { std::printf(" (no water in this zone)\n"); } return 0; } else if (std::strcmp(argv[i], "--info-project-water") == 0 && i + 1 < argc) { // Project-wide water rollup. Walks every zone in projectDir, // sums water chunks/layers/types per zone, then totals // across the project. Useful for "do my coastal zones // actually carry ocean data" sanity checks and for budget // planning when many zones share liquid types. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "info-project-water: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); auto typeName = [](uint16_t t) { switch (t) { case 0: return "water"; case 1: return "ocean"; case 2: return "magma"; case 3: return "slime"; } return "?"; }; struct ZRow { std::string name; int loadedTiles = 0, waterChunks = 0, totalLayers = 0; std::map<uint16_t, int> typeHist; }; std::vector<ZRow> rows; int gLoadedTiles = 0, gWaterChunks = 0, gTotalLayers = 0; std::map<uint16_t, int> gTypeHist; float gMinH = 1e30f, gMaxH = -1e30f; for (const auto& zoneDir : zones) { ZRow r; r.name = fs::path(zoneDir).filename().string(); wowee::editor::ZoneManifest zm; if (!zm.load(zoneDir + "/zone.json")) { rows.push_back(r); continue; } for (const auto& [tx, ty] : zm.tiles) { std::string tileBase = zoneDir + "/" + zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty); if (!wowee::pipeline::WoweeTerrainLoader::exists(tileBase)) continue; wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); r.loadedTiles++; for (const auto& w : terrain.waterData) { if (!w.hasWater()) continue; r.waterChunks++; r.totalLayers += static_cast<int>(w.layers.size()); for (const auto& layer : w.layers) { r.typeHist[layer.liquidType]++; gMinH = std::min(gMinH, layer.minHeight); gMaxH = std::max(gMaxH, layer.maxHeight); } } } gLoadedTiles += r.loadedTiles; gWaterChunks += r.waterChunks; gTotalLayers += r.totalLayers; for (const auto& [t, c] : r.typeHist) gTypeHist[t] += c; rows.push_back(r); } if (gWaterChunks == 0) { gMinH = 0; gMaxH = 0; } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = zones.size(); j["loadedTiles"] = gLoadedTiles; j["waterChunks"] = gWaterChunks; j["totalLayers"] = gTotalLayers; j["heightRange"] = {gMinH, gMaxH}; nlohmann::json zarr = nlohmann::json::array(); for (const auto& r : rows) { nlohmann::json types = nlohmann::json::array(); for (const auto& [t, c] : r.typeHist) { types.push_back({{"type", t}, {"name", typeName(t)}, {"layerCount", c}}); } zarr.push_back({{"name", r.name}, {"loadedTiles", r.loadedTiles}, {"waterChunks", r.waterChunks}, {"totalLayers", r.totalLayers}, {"types", types}}); } j["zones"] = zarr; nlohmann::json gtypes = nlohmann::json::array(); for (const auto& [t, c] : gTypeHist) { gtypes.push_back({{"type", t}, {"name", typeName(t)}, {"layerCount", c}}); } j["types"] = gtypes; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project water: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" loaded tiles : %d\n", gLoadedTiles); std::printf(" water chunks : %d (out of %d possible)\n", gWaterChunks, gLoadedTiles * 256); std::printf(" total layers : %d\n", gTotalLayers); if (gWaterChunks > 0) { std::printf(" height range : %.2f to %.2f\n", gMinH, gMaxH); std::printf("\n By liquid type (project-wide):\n"); for (const auto& [t, c] : gTypeHist) { std::printf(" %s (%u): %d layer(s)\n", typeName(t), t, c); } } std::printf("\n zone tiles water-chunks layers\n"); for (const auto& r : rows) { std::printf(" %-20s %5d %12d %6d\n", r.name.substr(0, 20).c_str(), r.loadedTiles, r.waterChunks, r.totalLayers); } return 0; } else if (std::strcmp(argv[i], "--info-zone-density") == 0 && i + 1 < argc) { // Per-tile content density. Catches sparse zones (5 mobs // across 16 tiles → boring) and over-stuffed ones (200 mobs // in 1 tile → frame-rate bomb). Per-tile bucket uses tile // (tx, ty) computed from world position by reversing the // WoW grid transform. std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "info-zone-density: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "info-zone-density: parse failed\n"); return 1; } // Per-(tx, ty) bucket of counts. struct TileBucket { int creatures = 0, objects = 0; }; std::map<std::pair<int,int>, TileBucket> tiles; for (const auto& [tx, ty] : zm.tiles) tiles[{tx, ty}] = {}; // Reverse the WoW grid transform: world (X, Y) -> tile (tx, ty). // From --info-zone-extents: // worldX = (32 - tileY) * 533.33 - subX // worldY = (32 - tileX) * 533.33 - subY // So: // tileX = floor(32 - worldY / 533.33) // tileY = floor(32 - worldX / 533.33) constexpr float kTileSize = 533.33333f; auto worldToTile = [](float wx, float wy) -> std::pair<int,int> { int tx = static_cast<int>(std::floor(32.0f - wy / kTileSize)); int ty = static_cast<int>(std::floor(32.0f - wx / kTileSize)); return {tx, ty}; }; wowee::editor::NpcSpawner sp; int totalCreat = 0; if (sp.loadFromFile(zoneDir + "/creatures.json")) { totalCreat = static_cast<int>(sp.spawnCount()); for (const auto& s : sp.getSpawns()) { auto t = worldToTile(s.position.x, s.position.y); auto it = tiles.find(t); if (it != tiles.end()) it->second.creatures++; // Out-of-zone spawns silently dropped — they'll // surface in --check-zone-refs / --check-zone-content. } } wowee::editor::ObjectPlacer op; int totalObj = 0; if (op.loadFromFile(zoneDir + "/objects.json")) { totalObj = static_cast<int>(op.getObjects().size()); for (const auto& o : op.getObjects()) { auto t = worldToTile(o.position.x, o.position.y); auto it = tiles.find(t); if (it != tiles.end()) it->second.objects++; } } wowee::editor::QuestEditor qe; int totalQ = 0; if (qe.loadFromFile(zoneDir + "/quests.json")) { totalQ = static_cast<int>(qe.questCount()); } int tileCount = static_cast<int>(tiles.size()); double avgCreatPerTile = tileCount > 0 ? double(totalCreat) / tileCount : 0.0; double avgObjPerTile = tileCount > 0 ? double(totalObj) / tileCount : 0.0; double questsPerTile = tileCount > 0 ? double(totalQ) / tileCount : 0.0; if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["tileCount"] = tileCount; j["totals"] = {{"creatures", totalCreat}, {"objects", totalObj}, {"quests", totalQ}}; j["averages"] = {{"creaturesPerTile", avgCreatPerTile}, {"objectsPerTile", avgObjPerTile}, {"questsPerTile", questsPerTile}}; nlohmann::json arr = nlohmann::json::array(); for (const auto& [coord, b] : tiles) { arr.push_back({{"tile", {coord.first, coord.second}}, {"creatures", b.creatures}, {"objects", b.objects}}); } j["perTile"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone density: %s\n", zoneDir.c_str()); std::printf(" tiles : %d\n", tileCount); std::printf(" totals : %d creatures, %d objects, %d quests\n", totalCreat, totalObj, totalQ); std::printf(" per-tile : %.2f creatures, %.2f objects, %.2f quests\n", avgCreatPerTile, avgObjPerTile, questsPerTile); std::printf("\n Per-tile breakdown:\n"); std::printf(" tile creatures objects\n"); for (const auto& [coord, b] : tiles) { std::printf(" (%2d, %2d) %5d %5d\n", coord.first, coord.second, b.creatures, b.objects); } return 0; } else if (std::strcmp(argv[i], "--info-project-density") == 0 && i + 1 < argc) { // Project-wide content density. Sums creatures/objects/ // quests across every zone, computes per-tile averages // both per-zone and project-wide. Helps spot zones that // are abnormally sparse vs the project median, and // surfaces the project's overall content footprint. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "info-project-density: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); struct ZRow { std::string name; int tileCount = 0; int creatures = 0, objects = 0, quests = 0; }; std::vector<ZRow> rows; int gTiles = 0, gCreat = 0, gObj = 0, gQ = 0; for (const auto& zoneDir : zones) { ZRow r; r.name = fs::path(zoneDir).filename().string(); wowee::editor::ZoneManifest zm; if (zm.load(zoneDir + "/zone.json")) { r.tileCount = static_cast<int>(zm.tiles.size()); } wowee::editor::NpcSpawner sp; if (sp.loadFromFile(zoneDir + "/creatures.json")) { r.creatures = static_cast<int>(sp.spawnCount()); } wowee::editor::ObjectPlacer op; if (op.loadFromFile(zoneDir + "/objects.json")) { r.objects = static_cast<int>(op.getObjects().size()); } wowee::editor::QuestEditor qe; if (qe.loadFromFile(zoneDir + "/quests.json")) { r.quests = static_cast<int>(qe.questCount()); } gTiles += r.tileCount; gCreat += r.creatures; gObj += r.objects; gQ += r.quests; rows.push_back(r); } double gAvgCreat = gTiles > 0 ? double(gCreat) / gTiles : 0.0; double gAvgObj = gTiles > 0 ? double(gObj) / gTiles : 0.0; double gAvgQ = gTiles > 0 ? double(gQ) / gTiles : 0.0; if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = zones.size(); j["totalTiles"] = gTiles; j["totals"] = {{"creatures", gCreat}, {"objects", gObj}, {"quests", gQ}}; j["averages"] = {{"creaturesPerTile", gAvgCreat}, {"objectsPerTile", gAvgObj}, {"questsPerTile", gAvgQ}}; nlohmann::json zarr = nlohmann::json::array(); for (const auto& r : rows) { double zCreat = r.tileCount > 0 ? double(r.creatures) / r.tileCount : 0.0; double zObj = r.tileCount > 0 ? double(r.objects) / r.tileCount : 0.0; zarr.push_back({{"name", r.name}, {"tileCount", r.tileCount}, {"creatures", r.creatures}, {"objects", r.objects}, {"quests", r.quests}, {"creaturesPerTile", zCreat}, {"objectsPerTile", zObj}}); } j["zones"] = zarr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project density: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" total tiles : %d\n", gTiles); std::printf(" totals : %d creatures, %d objects, %d quests\n", gCreat, gObj, gQ); std::printf(" per-tile : %.2f creatures, %.2f objects, %.2f quests\n", gAvgCreat, gAvgObj, gAvgQ); std::printf("\n zone tiles creat obj quest creat/tile obj/tile\n"); for (const auto& r : rows) { double zCreat = r.tileCount > 0 ? double(r.creatures) / r.tileCount : 0.0; double zObj = r.tileCount > 0 ? double(r.objects) / r.tileCount : 0.0; std::printf(" %-20s %5d %5d %4d %5d %9.2f %7.2f\n", r.name.substr(0, 20).c_str(), r.tileCount, r.creatures, r.objects, r.quests, zCreat, zObj); } return 0; } else if (std::strcmp(argv[i], "--bench-bake-project") == 0 && i + 1 < argc) { // Time WHM/WOT load (the dominant cost in --bake-zone-glb/obj/ // stl) per zone. The actual write side adds ~constant cost // proportional to vertex count, so load time is a strong // proxy. Useful for tracking 'has my latest geometry change // made baking 3× slower?' across releases. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "bench-bake-project: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); struct Timing { std::string name; int tiles; double loadMs; int chunks; }; std::vector<Timing> timings; double totalMs = 0; for (const auto& zoneDir : zones) { wowee::editor::ZoneManifest zm; if (!zm.load(zoneDir + "/zone.json")) continue; Timing t{fs::path(zoneDir).filename().string(), 0, 0.0, 0}; auto t0 = std::chrono::steady_clock::now(); for (const auto& [tx, ty] : zm.tiles) { std::string base = zoneDir + "/" + zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty); if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) continue; wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(base, terrain); t.tiles++; for (const auto& chunk : terrain.chunks) { if (chunk.heightMap.isLoaded()) t.chunks++; } } auto t1 = std::chrono::steady_clock::now(); t.loadMs = std::chrono::duration<double, std::milli>(t1 - t0).count(); totalMs += t.loadMs; timings.push_back(t); } double avgMs = !timings.empty() ? totalMs / timings.size() : 0.0; double minMs = 1e30, maxMs = 0; std::string slowest; for (const auto& t : timings) { if (t.loadMs < minMs) minMs = t.loadMs; if (t.loadMs > maxMs) { maxMs = t.loadMs; slowest = t.name; } } if (timings.empty()) { minMs = 0; maxMs = 0; } if (jsonOut) { nlohmann::json j; j["projectDir"] = projectDir; j["totalMs"] = totalMs; j["zoneCount"] = timings.size(); j["avgMs"] = avgMs; j["minMs"] = minMs; j["maxMs"] = maxMs; j["slowestZone"] = slowest; nlohmann::json arr = nlohmann::json::array(); for (const auto& t : timings) { arr.push_back({{"zone", t.name}, {"loadMs", t.loadMs}, {"tiles", t.tiles}, {"chunks", t.chunks}}); } j["perZone"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Bench bake (load-only): %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", timings.size()); std::printf(" total : %.2f ms (terrain load)\n", totalMs); std::printf(" per zone : avg=%.2f min=%.2f max=%.2f ms\n", avgMs, minMs, maxMs); if (!slowest.empty()) { std::printf(" slowest : %s (%.2f ms)\n", slowest.c_str(), maxMs); } std::printf("\n Per-zone:\n"); std::printf(" zone ms tiles chunks ms/tile\n"); for (const auto& t : timings) { double mspt = t.tiles > 0 ? t.loadMs / t.tiles : 0.0; std::printf(" %-26s %7.2f %5d %5d %6.2f\n", t.name.substr(0, 26).c_str(), t.loadMs, t.tiles, t.chunks, mspt); } return 0; } else if (std::strcmp(argv[i], "--export-wob-glb") == 0 && i + 1 < argc) { // glTF 2.0 binary export for WOB. Same purpose as --export-glb // for WOM but adapted for buildings: each WOB group becomes // one primitive in a single mesh, sharing one big vertex // pool concatenated from per-group vertex arrays. std::string base = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') { outPath = argv[++i]; } if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob") base = base.substr(0, base.size() - 4); if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) { std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str()); return 1; } if (outPath.empty()) outPath = base + ".glb"; auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); if (!bld.isValid()) { std::fprintf(stderr, "WOB has no groups: %s.wob\n", base.c_str()); return 1; } // Total counts + per-group offsets needed before allocating // the BIN buffer. Index buffer is uint32 so groups can each // index into the global pool by offset. uint32_t totalV = 0, totalI = 0; std::vector<uint32_t> groupVertOff(bld.groups.size(), 0); std::vector<uint32_t> groupIdxOff(bld.groups.size(), 0); for (size_t g = 0; g < bld.groups.size(); ++g) { groupVertOff[g] = totalV; groupIdxOff[g] = totalI; totalV += static_cast<uint32_t>(bld.groups[g].vertices.size()); totalI += static_cast<uint32_t>(bld.groups[g].indices.size()); } if (totalV == 0 || totalI == 0) { std::fprintf(stderr, "WOB has no vertex data\n"); return 1; } const uint32_t posOff = 0; const uint32_t nrmOff = posOff + totalV * 12; const uint32_t uvOff = nrmOff + totalV * 12; const uint32_t idxOff = uvOff + totalV * 8; const uint32_t binSize = idxOff + totalI * 4; std::vector<uint8_t> bin(binSize); // Pack per-group geometry into the global pool. Indices get // offset by the group's starting vertex index so they // continue to reference the right vertices in the merged pool. uint32_t vCursor = 0, iCursor = 0; glm::vec3 bMin{1e30f}, bMax{-1e30f}; for (size_t g = 0; g < bld.groups.size(); ++g) { const auto& grp = bld.groups[g]; for (const auto& v : grp.vertices) { std::memcpy(&bin[posOff + vCursor * 12 + 0], &v.position.x, 4); std::memcpy(&bin[posOff + vCursor * 12 + 4], &v.position.y, 4); std::memcpy(&bin[posOff + vCursor * 12 + 8], &v.position.z, 4); std::memcpy(&bin[nrmOff + vCursor * 12 + 0], &v.normal.x, 4); std::memcpy(&bin[nrmOff + vCursor * 12 + 4], &v.normal.y, 4); std::memcpy(&bin[nrmOff + vCursor * 12 + 8], &v.normal.z, 4); std::memcpy(&bin[uvOff + vCursor * 8 + 0], &v.texCoord.x, 4); std::memcpy(&bin[uvOff + vCursor * 8 + 4], &v.texCoord.y, 4); bMin = glm::min(bMin, v.position); bMax = glm::max(bMax, v.position); vCursor++; } // Offset indices by group's vertex base so merged pool // indexing still works. uint32 indices, written LE. for (uint32_t idx : grp.indices) { uint32_t off = idx + groupVertOff[g]; std::memcpy(&bin[idxOff + iCursor * 4], &off, 4); iCursor++; } } // Build glTF JSON. nlohmann::json gj; gj["asset"] = {{"version", "2.0"}, {"generator", "wowee_editor --export-wob-glb"}}; gj["scene"] = 0; gj["scenes"] = nlohmann::json::array({nlohmann::json{{"nodes", {0}}}}); gj["nodes"] = nlohmann::json::array({nlohmann::json{ {"name", bld.name.empty() ? "WoweeBuilding" : bld.name}, {"mesh", 0} }}); gj["buffers"] = nlohmann::json::array({nlohmann::json{ {"byteLength", binSize} }}); nlohmann::json bufferViews = nlohmann::json::array(); bufferViews.push_back({{"buffer", 0}, {"byteOffset", posOff}, {"byteLength", totalV * 12}, {"target", 34962}}); bufferViews.push_back({{"buffer", 0}, {"byteOffset", nrmOff}, {"byteLength", totalV * 12}, {"target", 34962}}); bufferViews.push_back({{"buffer", 0}, {"byteOffset", uvOff}, {"byteLength", totalV * 8}, {"target", 34962}}); bufferViews.push_back({{"buffer", 0}, {"byteOffset", idxOff}, {"byteLength", totalI * 4}, {"target", 34963}}); gj["bufferViews"] = bufferViews; nlohmann::json accessors = nlohmann::json::array(); accessors.push_back({ {"bufferView", 0}, {"componentType", 5126}, {"count", totalV}, {"type", "VEC3"}, {"min", {bMin.x, bMin.y, bMin.z}}, {"max", {bMax.x, bMax.y, bMax.z}} }); accessors.push_back({{"bufferView", 1}, {"componentType", 5126}, {"count", totalV}, {"type", "VEC3"}}); accessors.push_back({{"bufferView", 2}, {"componentType", 5126}, {"count", totalV}, {"type", "VEC2"}}); // Per-group primitives — each gets its own indices accessor // sliced from the shared index bufferView via byteOffset. nlohmann::json primitives = nlohmann::json::array(); for (size_t g = 0; g < bld.groups.size(); ++g) { uint32_t accIdx = static_cast<uint32_t>(accessors.size()); accessors.push_back({ {"bufferView", 3}, {"byteOffset", groupIdxOff[g] * 4}, {"componentType", 5125}, {"count", bld.groups[g].indices.size()}, {"type", "SCALAR"} }); primitives.push_back({ {"attributes", {{"POSITION", 0}, {"NORMAL", 1}, {"TEXCOORD_0", 2}}}, {"indices", accIdx}, {"mode", 4} }); } gj["accessors"] = accessors; gj["meshes"] = nlohmann::json::array({nlohmann::json{ {"primitives", primitives} }}); std::string jsonStr = gj.dump(); while (jsonStr.size() % 4 != 0) jsonStr += ' '; uint32_t jsonLen = static_cast<uint32_t>(jsonStr.size()); uint32_t binLen = binSize; uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen; std::ofstream out(outPath, std::ios::binary); if (!out) { std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str()); return 1; } uint32_t magic = 0x46546C67; uint32_t version = 2; out.write(reinterpret_cast<const char*>(&magic), 4); out.write(reinterpret_cast<const char*>(&version), 4); out.write(reinterpret_cast<const char*>(&totalLen), 4); uint32_t jsonChunkType = 0x4E4F534A; out.write(reinterpret_cast<const char*>(&jsonLen), 4); out.write(reinterpret_cast<const char*>(&jsonChunkType), 4); out.write(jsonStr.data(), jsonLen); uint32_t binChunkType = 0x004E4942; out.write(reinterpret_cast<const char*>(&binLen), 4); out.write(reinterpret_cast<const char*>(&binChunkType), 4); out.write(reinterpret_cast<const char*>(bin.data()), binLen); out.close(); std::printf("Exported %s.wob -> %s\n", base.c_str(), outPath.c_str()); std::printf(" %zu groups -> %zu primitives, %u verts, %u tris, %u-byte BIN\n", bld.groups.size(), primitives.size(), totalV, totalI / 3, binLen); return 0; } else if (std::strcmp(argv[i], "--export-whm-glb") == 0 && i + 1 < argc) { // glTF 2.0 binary export for WHM/WOT terrain. Mirrors // --export-whm-obj's mesh layout (9x9 outer grid per chunk // → 8x8 quads → 2 tris each), but ships as a single .glb // viewable in any modern web 3D tool. Per-chunk primitives // so designers can hide individual chunks in three.js. std::string base = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') { outPath = argv[++i]; } for (const char* ext : {".wot", ".whm"}) { if (base.size() >= 4 && base.substr(base.size() - 4) == ext) { base = base.substr(0, base.size() - 4); break; } } if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) { std::fprintf(stderr, "WHM/WOT not found: %s.{whm,wot}\n", base.c_str()); return 1; } if (outPath.empty()) outPath = base + ".glb"; wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(base, terrain); // Same coord constants as --export-whm-obj so the .glb and // .obj of the same source align spatially. constexpr float kTileSize = 533.33333f; constexpr float kChunkSize = kTileSize / 16.0f; constexpr float kVertSpacing = kChunkSize / 8.0f; // Walk the 16x16 chunk grid, build per-chunk vertex + index // arrays. Hole bits respected (cave-entrance quads dropped). struct ChunkMesh { uint32_t vertOff, vertCount, idxOff, idxCount; }; std::vector<ChunkMesh> chunkMeshes; std::vector<glm::vec3> positions; // packed sequentially std::vector<uint32_t> indices; int loadedChunks = 0; glm::vec3 bMin{1e30f}, bMax{-1e30f}; for (int cx = 0; cx < 16; ++cx) { for (int cy = 0; cy < 16; ++cy) { const auto& chunk = terrain.getChunk(cx, cy); if (!chunk.heightMap.isLoaded()) continue; loadedChunks++; ChunkMesh cm{}; cm.vertOff = static_cast<uint32_t>(positions.size()); cm.idxOff = static_cast<uint32_t>(indices.size()); float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; // 9x9 outer verts (skip 8x8 inner fan-center verts). for (int row = 0; row < 9; ++row) { for (int col = 0; col < 9; ++col) { glm::vec3 p{ chunkBaseX - row * kVertSpacing, chunkBaseY - col * kVertSpacing, chunk.position[2] + chunk.heightMap.heights[row * 17 + col] }; positions.push_back(p); bMin = glm::min(bMin, p); bMax = glm::max(bMax, p); } } cm.vertCount = 81; bool isHoleChunk = (chunk.holes != 0); auto idx = [&](int r, int c) { return cm.vertOff + r * 9 + c; }; for (int row = 0; row < 8; ++row) { for (int col = 0; col < 8; ++col) { if (isHoleChunk) { int hx = col / 2, hy = row / 2; if (chunk.holes & (1 << (hy * 4 + hx))) continue; } indices.push_back(idx(row, col)); indices.push_back(idx(row, col + 1)); indices.push_back(idx(row + 1, col + 1)); indices.push_back(idx(row, col)); indices.push_back(idx(row + 1, col + 1)); indices.push_back(idx(row + 1, col)); } } cm.idxCount = static_cast<uint32_t>(indices.size()) - cm.idxOff; chunkMeshes.push_back(cm); } } if (loadedChunks == 0) { std::fprintf(stderr, "WHM has no loaded chunks\n"); return 1; } // Synthesize normals as +Z (terrain is Z-up). Real per-vertex // normals would need a smoothing pass across chunk boundaries // — skip for v1, viewers can compute their own from positions. const uint32_t totalV = static_cast<uint32_t>(positions.size()); const uint32_t totalI = static_cast<uint32_t>(indices.size()); const uint32_t posOff = 0; const uint32_t nrmOff = posOff + totalV * 12; const uint32_t idxOff = nrmOff + totalV * 12; const uint32_t binSize = idxOff + totalI * 4; std::vector<uint8_t> bin(binSize); for (uint32_t v = 0; v < totalV; ++v) { std::memcpy(&bin[posOff + v * 12 + 0], &positions[v].x, 4); std::memcpy(&bin[posOff + v * 12 + 4], &positions[v].y, 4); std::memcpy(&bin[posOff + v * 12 + 8], &positions[v].z, 4); float nx = 0, ny = 0, nz = 1; std::memcpy(&bin[nrmOff + v * 12 + 0], &nx, 4); std::memcpy(&bin[nrmOff + v * 12 + 4], &ny, 4); std::memcpy(&bin[nrmOff + v * 12 + 8], &nz, 4); } std::memcpy(&bin[idxOff], indices.data(), totalI * 4); // Build glTF JSON. nlohmann::json gj; gj["asset"] = {{"version", "2.0"}, {"generator", "wowee_editor --export-whm-glb"}}; gj["scene"] = 0; gj["scenes"] = nlohmann::json::array({nlohmann::json{{"nodes", {0}}}}); std::string nodeName = "WoweeTerrain_" + std::to_string(terrain.coord.x) + "_" + std::to_string(terrain.coord.y); gj["nodes"] = nlohmann::json::array({nlohmann::json{ {"name", nodeName}, {"mesh", 0} }}); gj["buffers"] = nlohmann::json::array({nlohmann::json{ {"byteLength", binSize} }}); nlohmann::json bufferViews = nlohmann::json::array(); bufferViews.push_back({{"buffer", 0}, {"byteOffset", posOff}, {"byteLength", totalV * 12}, {"target", 34962}}); bufferViews.push_back({{"buffer", 0}, {"byteOffset", nrmOff}, {"byteLength", totalV * 12}, {"target", 34962}}); bufferViews.push_back({{"buffer", 0}, {"byteOffset", idxOff}, {"byteLength", totalI * 4}, {"target", 34963}}); gj["bufferViews"] = bufferViews; nlohmann::json accessors = nlohmann::json::array(); accessors.push_back({ {"bufferView", 0}, {"componentType", 5126}, {"count", totalV}, {"type", "VEC3"}, {"min", {bMin.x, bMin.y, bMin.z}}, {"max", {bMax.x, bMax.y, bMax.z}} }); accessors.push_back({{"bufferView", 1}, {"componentType", 5126}, {"count", totalV}, {"type", "VEC3"}}); // Per-chunk primitive — sliced from shared index bufferView. nlohmann::json primitives = nlohmann::json::array(); for (const auto& cm : chunkMeshes) { if (cm.idxCount == 0) continue; // all-hole chunk uint32_t accIdx = static_cast<uint32_t>(accessors.size()); accessors.push_back({ {"bufferView", 2}, {"byteOffset", cm.idxOff * 4}, {"componentType", 5125}, {"count", cm.idxCount}, {"type", "SCALAR"} }); primitives.push_back({ {"attributes", {{"POSITION", 0}, {"NORMAL", 1}}}, {"indices", accIdx}, {"mode", 4} }); } gj["accessors"] = accessors; gj["meshes"] = nlohmann::json::array({nlohmann::json{ {"primitives", primitives} }}); std::string jsonStr = gj.dump(); while (jsonStr.size() % 4 != 0) jsonStr += ' '; uint32_t jsonLen = static_cast<uint32_t>(jsonStr.size()); uint32_t binLen = binSize; uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen; std::ofstream out(outPath, std::ios::binary); if (!out) { std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str()); return 1; } uint32_t magic = 0x46546C67, version = 2; out.write(reinterpret_cast<const char*>(&magic), 4); out.write(reinterpret_cast<const char*>(&version), 4); out.write(reinterpret_cast<const char*>(&totalLen), 4); uint32_t jsonChunkType = 0x4E4F534A; out.write(reinterpret_cast<const char*>(&jsonLen), 4); out.write(reinterpret_cast<const char*>(&jsonChunkType), 4); out.write(jsonStr.data(), jsonLen); uint32_t binChunkType = 0x004E4942; out.write(reinterpret_cast<const char*>(&binLen), 4); out.write(reinterpret_cast<const char*>(&binChunkType), 4); out.write(reinterpret_cast<const char*>(bin.data()), binLen); out.close(); std::printf("Exported %s.whm -> %s\n", base.c_str(), outPath.c_str()); std::printf(" %d chunks loaded, %u verts, %u tris, %zu primitives, %u-byte BIN\n", loadedChunks, totalV, totalI / 3, primitives.size(), binLen); return 0; } else if (std::strcmp(argv[i], "--export-wob-obj") == 0 && i + 1 < argc) { // WOB is the WMO replacement; like --export-obj for WOM, this // bridges WOB into the universal-3D-tool ecosystem. Each WOB // group becomes one OBJ 'g' block, preserving the room/floor // structure for downstream selection in Blender/MeshLab. std::string base = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') { outPath = argv[++i]; } if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob") base = base.substr(0, base.size() - 4); if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) { std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str()); return 1; } if (outPath.empty()) outPath = base + ".obj"; auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); if (!bld.isValid()) { std::fprintf(stderr, "WOB has no groups to export: %s.wob\n", base.c_str()); return 1; } std::ofstream obj(outPath); if (!obj) { std::fprintf(stderr, "Failed to open output file: %s\n", outPath.c_str()); return 1; } // Total verts/tris across all groups for the header. size_t totalV = 0, totalI = 0; for (const auto& g : bld.groups) { totalV += g.vertices.size(); totalI += g.indices.size(); } obj << "# Wavefront OBJ generated by wowee_editor --export-wob-obj\n"; obj << "# Source: " << base << ".wob\n"; obj << "# Groups: " << bld.groups.size() << " Verts: " << totalV << " Tris: " << totalI / 3 << " Portals: " << bld.portals.size() << " Doodads: " << bld.doodads.size() << "\n\n"; obj << "o " << (bld.name.empty() ? "WoweeBuilding" : bld.name) << "\n"; // OBJ uses a single global vertex pool, so we offset each group's // local indices by the running total of verts written so far. uint32_t vertOffset = 0; for (size_t g = 0; g < bld.groups.size(); ++g) { const auto& grp = bld.groups[g]; if (grp.vertices.empty()) continue; for (const auto& v : grp.vertices) { obj << "v " << v.position.x << " " << v.position.y << " " << v.position.z << "\n"; } for (const auto& v : grp.vertices) { obj << "vt " << v.texCoord.x << " " << (1.0f - v.texCoord.y) << "\n"; } for (const auto& v : grp.vertices) { obj << "vn " << v.normal.x << " " << v.normal.y << " " << v.normal.z << "\n"; } std::string groupName = grp.name.empty() ? "group_" + std::to_string(g) : grp.name; if (grp.isOutdoor) groupName += "_outdoor"; obj << "g " << groupName << "\n"; for (size_t k = 0; k + 2 < grp.indices.size(); k += 3) { uint32_t i0 = grp.indices[k] + 1 + vertOffset; uint32_t i1 = grp.indices[k + 1] + 1 + vertOffset; uint32_t i2 = grp.indices[k + 2] + 1 + vertOffset; obj << "f " << i0 << "/" << i0 << "/" << i0 << " " << i1 << "/" << i1 << "/" << i1 << " " << i2 << "/" << i2 << "/" << i2 << "\n"; } vertOffset += static_cast<uint32_t>(grp.vertices.size()); } // Doodad placements as a separate informational block — emit // each as a comment line so OBJ stays valid but the data is // recoverable for tools that want to re-create the placements. if (!bld.doodads.empty()) { obj << "\n# Doodad placements (model, position, rotation, scale):\n"; for (const auto& d : bld.doodads) { obj << "# doodad " << d.modelPath << " pos " << d.position.x << "," << d.position.y << "," << d.position.z << " rot " << d.rotation.x << "," << d.rotation.y << "," << d.rotation.z << " scale " << d.scale << "\n"; } } obj.close(); std::printf("Exported %s.wob -> %s\n", base.c_str(), outPath.c_str()); std::printf(" %zu groups, %zu verts, %zu tris, %zu doodad placements\n", bld.groups.size(), totalV, totalI / 3, bld.doodads.size()); return 0; } else if (std::strcmp(argv[i], "--import-wob-obj") == 0 && i + 1 < argc) { // Round-trip companion to --export-wob-obj. Each OBJ 'g' block // becomes one WoweeBuilding::Group; geometry under that group // is deduped into the group's local vertex array. Faces // before any 'g' directive land in a default 'imported' group. // Doodad placements written as # comment lines by --export-wob-obj // ARE recognized and re-instanced into bld.doodads. std::string objPath = argv[++i]; std::string wobBase; if (i + 1 < argc && argv[i + 1][0] != '-') { wobBase = argv[++i]; } if (!std::filesystem::exists(objPath)) { std::fprintf(stderr, "OBJ not found: %s\n", objPath.c_str()); return 1; } if (wobBase.empty()) { wobBase = objPath; if (wobBase.size() >= 4 && wobBase.substr(wobBase.size() - 4) == ".obj") { wobBase = wobBase.substr(0, wobBase.size() - 4); } } std::ifstream in(objPath); if (!in) { std::fprintf(stderr, "Failed to open OBJ: %s\n", objPath.c_str()); return 1; } // Global pools (OBJ vertex/uv/normal indices reference these // across all groups). std::vector<glm::vec3> positions; std::vector<glm::vec2> texcoords; std::vector<glm::vec3> normals; wowee::pipeline::WoweeBuilding bld; // Active group bookkeeping: dedupe table is per-group since // each WOB group has its own local vertex buffer. std::string activeGroup = "imported"; std::unordered_map<std::string, uint32_t> groupDedupe; int activeGroupIdx = -1; int badFaces = 0; int triangulatedNgons = 0; std::string objectName; auto ensureActiveGroup = [&]() { if (activeGroupIdx >= 0) return; wowee::pipeline::WoweeBuilding::Group g; g.name = activeGroup; if (g.name.size() >= 8 && g.name.substr(g.name.size() - 8) == "_outdoor") { g.name = g.name.substr(0, g.name.size() - 8); g.isOutdoor = true; } bld.groups.push_back(g); activeGroupIdx = static_cast<int>(bld.groups.size()) - 1; groupDedupe.clear(); }; auto resolveCorner = [&](const std::string& token) -> int { int v = 0, t = 0, n = 0; { const char* p = token.c_str(); char* endp = nullptr; v = std::strtol(p, &endp, 10); if (*endp == '/') { ++endp; if (*endp != '/') t = std::strtol(endp, &endp, 10); if (*endp == '/') { ++endp; n = std::strtol(endp, &endp, 10); } } } auto absIdx = [](int idx, size_t pool) { if (idx < 0) return static_cast<int>(pool) + idx; return idx - 1; }; int vi = absIdx(v, positions.size()); int ti = (t == 0) ? -1 : absIdx(t, texcoords.size()); int ni = (n == 0) ? -1 : absIdx(n, normals.size()); if (vi < 0 || vi >= static_cast<int>(positions.size())) return -1; ensureActiveGroup(); std::string key = std::to_string(vi) + "/" + std::to_string(ti) + "/" + std::to_string(ni); auto it = groupDedupe.find(key); if (it != groupDedupe.end()) return static_cast<int>(it->second); wowee::pipeline::WoweeBuilding::Vertex vert; vert.position = positions[vi]; if (ti >= 0 && ti < static_cast<int>(texcoords.size())) { vert.texCoord = texcoords[ti]; // Reverse the V-flip from --export-wob-obj. vert.texCoord.y = 1.0f - vert.texCoord.y; } else { vert.texCoord = {0, 0}; } if (ni >= 0 && ni < static_cast<int>(normals.size())) { vert.normal = normals[ni]; } else { vert.normal = {0, 0, 1}; } vert.color = {1, 1, 1, 1}; auto& grp = bld.groups[activeGroupIdx]; uint32_t newIdx = static_cast<uint32_t>(grp.vertices.size()); grp.vertices.push_back(vert); groupDedupe[key] = newIdx; return static_cast<int>(newIdx); }; std::string line; while (std::getline(in, line)) { while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back(); if (line.empty()) continue; // Recognize doodad placement comment lines emitted by // --export-wob-obj so the round-trip preserves them. if (line[0] == '#') { if (line.find("# doodad ") == 0) { std::istringstream ss(line); std::string hash, doodadKw, modelPath, posKw, posStr, rotKw, rotStr, scaleKw; float scale = 1.0f; ss >> hash >> doodadKw >> modelPath >> posKw >> posStr >> rotKw >> rotStr >> scaleKw >> scale; auto parse3 = [](const std::string& s, glm::vec3& out) { int got = std::sscanf(s.c_str(), "%f,%f,%f", &out.x, &out.y, &out.z); return got == 3; }; wowee::pipeline::WoweeBuilding::DoodadPlacement d; d.modelPath = modelPath; if (parse3(posStr, d.position) && parse3(rotStr, d.rotation) && std::isfinite(scale) && scale > 0.0f) { d.scale = scale; bld.doodads.push_back(d); } } continue; } std::istringstream ss(line); std::string tag; ss >> tag; if (tag == "v") { glm::vec3 p; ss >> p.x >> p.y >> p.z; positions.push_back(p); } else if (tag == "vt") { glm::vec2 t; ss >> t.x >> t.y; texcoords.push_back(t); } else if (tag == "vn") { glm::vec3 n; ss >> n.x >> n.y >> n.z; normals.push_back(n); } else if (tag == "o") { if (objectName.empty()) ss >> objectName; } else if (tag == "g") { // New group — flush dedupe table so the next batch of // verts is local to this group. std::string name; ss >> name; activeGroup = name.empty() ? "group" : name; activeGroupIdx = -1; groupDedupe.clear(); } else if (tag == "f") { std::vector<std::string> corners; std::string c; while (ss >> c) corners.push_back(c); if (corners.size() < 3) { badFaces++; continue; } std::vector<int> resolved; resolved.reserve(corners.size()); bool ok = true; for (const auto& cc : corners) { int idx = resolveCorner(cc); if (idx < 0) { ok = false; break; } resolved.push_back(idx); } if (!ok) { badFaces++; continue; } if (resolved.size() > 3) triangulatedNgons++; auto& grp = bld.groups[activeGroupIdx]; for (size_t k = 1; k + 1 < resolved.size(); ++k) { grp.indices.push_back(static_cast<uint32_t>(resolved[0])); grp.indices.push_back(static_cast<uint32_t>(resolved[k])); grp.indices.push_back(static_cast<uint32_t>(resolved[k + 1])); } } // mtllib/usemtl/s lines silently skipped. } // Compute per-group bounds + global building bound. if (bld.groups.empty()) { std::fprintf(stderr, "import-wob-obj: no geometry found in %s\n", objPath.c_str()); return 1; } glm::vec3 bMin{1e30f}, bMax{-1e30f}; for (auto& grp : bld.groups) { if (grp.vertices.empty()) continue; grp.boundMin = grp.vertices[0].position; grp.boundMax = grp.boundMin; for (const auto& v : grp.vertices) { grp.boundMin = glm::min(grp.boundMin, v.position); grp.boundMax = glm::max(grp.boundMax, v.position); } bMin = glm::min(bMin, grp.boundMin); bMax = glm::max(bMax, grp.boundMax); } glm::vec3 center = (bMin + bMax) * 0.5f; float r2 = 0; for (const auto& grp : bld.groups) { for (const auto& v : grp.vertices) { glm::vec3 d = v.position - center; r2 = std::max(r2, glm::dot(d, d)); } } bld.boundRadius = std::sqrt(r2); bld.name = objectName.empty() ? std::filesystem::path(objPath).stem().string() : objectName; if (!wowee::pipeline::WoweeBuildingLoader::save(bld, wobBase)) { std::fprintf(stderr, "import-wob-obj: failed to write %s.wob\n", wobBase.c_str()); return 1; } size_t totalV = 0, totalI = 0; for (const auto& g : bld.groups) { totalV += g.vertices.size(); totalI += g.indices.size(); } std::printf("Imported %s -> %s.wob\n", objPath.c_str(), wobBase.c_str()); std::printf(" %zu groups, %zu verts, %zu tris, %zu doodad placements\n", bld.groups.size(), totalV, totalI / 3, bld.doodads.size()); if (triangulatedNgons > 0) { std::printf(" fan-triangulated %d n-gon(s)\n", triangulatedNgons); } if (badFaces > 0) { std::printf(" warning: skipped %d malformed face(s)\n", badFaces); } return 0; } else if (std::strcmp(argv[i], "--export-woc-obj") == 0 && i + 1 < argc) { // Visualize a WOC collision mesh in any 3D tool. Each // walkability class becomes its own OBJ group (walkable / // steep / water / indoor) so designers can hide categories // independently in Blender to debug 'why can the player // walk here?' or 'why can't they walk there?'. std::string path = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') { outPath = argv[++i]; } if (!std::filesystem::exists(path)) { std::fprintf(stderr, "WOC not found: %s\n", path.c_str()); return 1; } if (outPath.empty()) { outPath = path; if (outPath.size() >= 4 && outPath.substr(outPath.size() - 4) == ".woc") { outPath = outPath.substr(0, outPath.size() - 4); } outPath += ".obj"; } auto woc = wowee::pipeline::WoweeCollisionBuilder::load(path); if (!woc.isValid()) { std::fprintf(stderr, "WOC has no triangles: %s\n", path.c_str()); return 1; } std::ofstream obj(outPath); if (!obj) { std::fprintf(stderr, "Failed to open output file: %s\n", outPath.c_str()); return 1; } // Bucket triangles by flag combination so the OBJ can split // them into named groups. Flag bits: walkable=0x01, water=0x02, // steep=0x04, indoor=0x08 (per WoweeCollision::Triangle). // Triangles can have multiple flags set so a per-flag group // would over-count; instead we bucket by exact flag value. std::unordered_map<uint8_t, std::vector<size_t>> byFlag; for (size_t t = 0; t < woc.triangles.size(); ++t) { byFlag[woc.triangles[t].flags].push_back(t); } obj << "# Wavefront OBJ generated by wowee_editor --export-woc-obj\n"; obj << "# Source: " << path << "\n"; obj << "# Triangles: " << woc.triangles.size() << " (walkable=" << woc.walkableCount() << " steep=" << woc.steepCount() << ")\n"; obj << "# Tile: (" << woc.tileX << ", " << woc.tileY << ")\n\n"; obj << "o WoweeCollision\n"; // Emit ALL vertices first (3 per triangle, no dedupe — the // collision mesh has triangle-soup topology where shared // verts often have different flags, so deduping would // actually merge categories). for (const auto& tri : woc.triangles) { obj << "v " << tri.v0.x << " " << tri.v0.y << " " << tri.v0.z << "\n"; obj << "v " << tri.v1.x << " " << tri.v1.y << " " << tri.v1.z << "\n"; obj << "v " << tri.v2.x << " " << tri.v2.y << " " << tri.v2.z << "\n"; } // Emit faces grouped by flag class. OBJ index of triangle t // vertex k is (t * 3 + k + 1) — 1-based, three verts per tri. auto flagName = [](uint8_t f) { if (f == 0) return std::string("nonwalkable"); std::string s; if (f & 0x01) s += "walkable"; if (f & 0x02) { if (!s.empty()) s += "_"; s += "water"; } if (f & 0x04) { if (!s.empty()) s += "_"; s += "steep"; } if (f & 0x08) { if (!s.empty()) s += "_"; s += "indoor"; } if (s.empty()) s = "flag" + std::to_string(int(f)); return s; }; for (const auto& [flag, tris] : byFlag) { obj << "g " << flagName(flag) << "\n"; for (size_t t : tris) { uint32_t base = static_cast<uint32_t>(t * 3 + 1); obj << "f " << base << " " << (base + 1) << " " << (base + 2) << "\n"; } } obj.close(); std::printf("Exported %s -> %s\n", path.c_str(), outPath.c_str()); std::printf(" %zu triangles in %zu flag class(es), tile (%u, %u)\n", woc.triangles.size(), byFlag.size(), woc.tileX, woc.tileY); return 0; } else if (std::strcmp(argv[i], "--export-whm-obj") == 0 && i + 1 < argc) { // Convert a WHM/WOT terrain pair to OBJ for visualization in // Blender / MeshLab. Emits the 9x9 outer vertex grid per // chunk (skipping the 8x8 inner verts the engine uses for // 4-tri fans) — that's the canonical 'heightmap as mesh' // view, 256 chunks × 81 verts = 20736 verts, 32768 tris. // Geometry mirrors WoweeCollisionBuilder's outer-grid layout // exactly so the OBJ aligns with the corresponding WOC. std::string base = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') { outPath = argv[++i]; } for (const char* ext : {".wot", ".whm"}) { if (base.size() >= 4 && base.substr(base.size() - 4) == ext) { base = base.substr(0, base.size() - 4); break; } } if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) { std::fprintf(stderr, "WHM/WOT not found: %s.{whm,wot}\n", base.c_str()); return 1; } if (outPath.empty()) outPath = base + ".obj"; wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(base, terrain); std::ofstream obj(outPath); if (!obj) { std::fprintf(stderr, "Failed to open output file: %s\n", outPath.c_str()); return 1; } // Tile + chunk constants — must match WoweeCollisionBuilder so // exports of the same source align in space when overlaid. constexpr float kTileSize = 533.33333f; constexpr float kChunkSize = kTileSize / 16.0f; constexpr float kVertSpacing = kChunkSize / 8.0f; obj << "# Wavefront OBJ generated by wowee_editor --export-whm-obj\n"; obj << "# Source: " << base << ".whm\n"; obj << "# Tile coord: (" << terrain.coord.x << ", " << terrain.coord.y << ")\n"; obj << "# Layout: 9x9 outer vertex grid per chunk, 8x8 quads -> 2 tris each\n\n"; obj << "o WoweeTerrain_" << terrain.coord.x << "_" << terrain.coord.y << "\n"; int loadedChunks = 0; uint32_t vertOffset = 0; for (int cx = 0; cx < 16; ++cx) { for (int cy = 0; cy < 16; ++cy) { const auto& chunk = terrain.getChunk(cx, cy); if (!chunk.heightMap.isLoaded()) continue; loadedChunks++; // Same XY origin formula as collision builder so // overlaid OBJ exports line up exactly. float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; // Emit 9x9 outer verts. Layout: heights[row*17 + col] // for col in [0,8] (the inner 8 verts at col 9..16 // are skipped — they're the quad-center verts). for (int row = 0; row < 9; ++row) { for (int col = 0; col < 9; ++col) { float x = chunkBaseX - row * kVertSpacing; float y = chunkBaseY - col * kVertSpacing; float z = chunk.position[2] + chunk.heightMap.heights[row * 17 + col]; obj << "v " << x << " " << y << " " << z << "\n"; } } // Per-vertex UV: just the row/col in 0..1 — Blender // can use this to slap a checker texture for scale. for (int row = 0; row < 9; ++row) { for (int col = 0; col < 9; ++col) { obj << "vt " << (col / 8.0f) << " " << (row / 8.0f) << "\n"; } } // 8x8 quads — two tris each, respecting hole bits so // cave-entrance quads correctly disappear from the mesh. bool isHoleChunk = (chunk.holes != 0); obj << "g chunk_" << cx << "_" << cy << "\n"; auto idx = [&](int r, int c) { return vertOffset + r * 9 + c + 1; // 1-based }; for (int row = 0; row < 8; ++row) { for (int col = 0; col < 8; ++col) { if (isHoleChunk) { int hx = col / 2, hy = row / 2; if (chunk.holes & (1 << (hy * 4 + hx))) continue; } uint32_t i00 = idx(row, col); uint32_t i10 = idx(row, col + 1); uint32_t i01 = idx(row + 1, col); uint32_t i11 = idx(row + 1, col + 1); obj << "f " << i00 << "/" << i00 << " " << i10 << "/" << i10 << " " << i11 << "/" << i11 << "\n"; obj << "f " << i00 << "/" << i00 << " " << i11 << "/" << i11 << " " << i01 << "/" << i01 << "\n"; } } vertOffset += 81; // 9x9 verts per chunk } } obj.close(); // Estimated tri count: chunks × 128 (8x8 quads × 2 tris). // Holes reduce this but counting exactly would mean walking // the bitmask again — the rough estimate is the user-visible // useful number anyway. std::printf("Exported %s.whm -> %s\n", base.c_str(), outPath.c_str()); std::printf(" %d chunks loaded, ~%d verts, ~%d tris\n", loadedChunks, loadedChunks * 81, loadedChunks * 128); return 0; } else if (std::strcmp(argv[i], "--import-obj") == 0 && i + 1 < argc) { // Convert a Wavefront OBJ back into WOM. Round-trips with // --export-obj for the geometry/UV/normal data; bones, // animations, and material flags are not in OBJ and stay // empty (the resulting WOM is WOM1, static-only). The intent // is "edit a static prop in Blender, ship it". std::string objPath = argv[++i]; std::string womBase; if (i + 1 < argc && argv[i + 1][0] != '-') { womBase = argv[++i]; } if (!std::filesystem::exists(objPath)) { std::fprintf(stderr, "OBJ not found: %s\n", objPath.c_str()); return 1; } if (womBase.empty()) { womBase = objPath; if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".obj") { womBase = womBase.substr(0, womBase.size() - 4); } } std::ifstream in(objPath); if (!in) { std::fprintf(stderr, "Failed to open OBJ: %s\n", objPath.c_str()); return 1; } // Pools — OBJ stores positions/UVs/normals in independent // arrays and references them by index in face lines, so we // collect each pool first then expand into WOM vertices on // the fly (one WOM vertex per (vIdx, vtIdx, vnIdx) triple // since WOM has interleaved vertex data, not pooled). std::vector<glm::vec3> positions; std::vector<glm::vec2> texcoords; std::vector<glm::vec3> normals; wowee::pipeline::WoweeModel wom; wom.version = 1; std::unordered_map<std::string, uint32_t> dedupe; int badFaces = 0; int triangulatedNgons = 0; std::string objectName; std::string line; // Convert a single OBJ vertex token like "3/4/5" or "3//5" or // "3/4" or "3" into a WOM vertex index, deduping identical // (pos, uv, normal) triples to keep the buffer compact. auto resolveCorner = [&](const std::string& token) -> int { int v = 0, t = 0, n = 0; { const char* p = token.c_str(); char* endp = nullptr; v = std::strtol(p, &endp, 10); if (*endp == '/') { ++endp; if (*endp != '/') { t = std::strtol(endp, &endp, 10); } if (*endp == '/') { ++endp; n = std::strtol(endp, &endp, 10); } } } // Translate negative (relative) indices to absolute. auto absIdx = [](int idx, size_t poolSize) -> int { if (idx < 0) return static_cast<int>(poolSize) + idx; return idx - 1; // OBJ is 1-based }; int vi = absIdx(v, positions.size()); int ti = (t == 0) ? -1 : absIdx(t, texcoords.size()); int ni = (n == 0) ? -1 : absIdx(n, normals.size()); if (vi < 0 || vi >= static_cast<int>(positions.size())) return -1; std::string key = std::to_string(vi) + "/" + std::to_string(ti) + "/" + std::to_string(ni); auto it = dedupe.find(key); if (it != dedupe.end()) return static_cast<int>(it->second); wowee::pipeline::WoweeModel::Vertex vert; vert.position = positions[vi]; if (ti >= 0 && ti < static_cast<int>(texcoords.size())) { vert.texCoord = texcoords[ti]; // Reverse the V-flip from --export-obj so a round-trip // returns the original UVs unchanged. vert.texCoord.y = 1.0f - vert.texCoord.y; } else { vert.texCoord = {0, 0}; } if (ni >= 0 && ni < static_cast<int>(normals.size())) { vert.normal = normals[ni]; } else { vert.normal = {0, 0, 1}; } uint32_t newIdx = static_cast<uint32_t>(wom.vertices.size()); wom.vertices.push_back(vert); dedupe[key] = newIdx; return static_cast<int>(newIdx); }; while (std::getline(in, line)) { // Strip CR for CRLF files. while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back(); if (line.empty() || line[0] == '#') continue; std::istringstream ss(line); std::string tag; ss >> tag; if (tag == "v") { glm::vec3 p; ss >> p.x >> p.y >> p.z; positions.push_back(p); } else if (tag == "vt") { glm::vec2 t; ss >> t.x >> t.y; texcoords.push_back(t); } else if (tag == "vn") { glm::vec3 n; ss >> n.x >> n.y >> n.z; normals.push_back(n); } else if (tag == "o") { if (objectName.empty()) ss >> objectName; } else if (tag == "f") { std::vector<std::string> corners; std::string c; while (ss >> c) corners.push_back(c); if (corners.size() < 3) { badFaces++; continue; } std::vector<int> resolved; resolved.reserve(corners.size()); bool ok = true; for (const auto& cc : corners) { int idx = resolveCorner(cc); if (idx < 0) { ok = false; break; } resolved.push_back(idx); } if (!ok) { badFaces++; continue; } // Fan-triangulate (works for triangles, quads, and // n-gons; assumes the polygon is convex which is the // common case from DCC exporters). if (resolved.size() > 3) triangulatedNgons++; for (size_t k = 1; k + 1 < resolved.size(); ++k) { wom.indices.push_back(static_cast<uint32_t>(resolved[0])); wom.indices.push_back(static_cast<uint32_t>(resolved[k])); wom.indices.push_back(static_cast<uint32_t>(resolved[k + 1])); } } // mtllib/usemtl/g/s lines are silently skipped — material // info doesn't survive the round-trip but groups would // (left as future work; current import keeps it simple). } if (wom.vertices.empty() || wom.indices.empty()) { std::fprintf(stderr, "import-obj: no geometry found in %s\n", objPath.c_str()); return 1; } wom.name = objectName.empty() ? std::filesystem::path(objPath).stem().string() : objectName; // Compute bounds from positions — the renderer culls by these // so wrong values cause the model to disappear at distance. wom.boundMin = wom.vertices[0].position; wom.boundMax = wom.boundMin; for (const auto& v : wom.vertices) { wom.boundMin = glm::min(wom.boundMin, v.position); wom.boundMax = glm::max(wom.boundMax, v.position); } glm::vec3 center = (wom.boundMin + wom.boundMax) * 0.5f; float r2 = 0; for (const auto& v : wom.vertices) { glm::vec3 d = v.position - center; r2 = std::max(r2, glm::dot(d, d)); } wom.boundRadius = std::sqrt(r2); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "import-obj: failed to write %s.wom\n", womBase.c_str()); return 1; } std::printf("Imported %s -> %s.wom\n", objPath.c_str(), womBase.c_str()); std::printf(" %zu verts, %zu tris, bounds [%.2f, %.2f, %.2f] - [%.2f, %.2f, %.2f]\n", wom.vertices.size(), wom.indices.size() / 3, wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); if (triangulatedNgons > 0) { std::printf(" fan-triangulated %d n-gon(s)\n", triangulatedNgons); } if (badFaces > 0) { std::printf(" warning: skipped %d malformed face(s)\n", badFaces); } return 0; } else if (std::strcmp(argv[i], "--export-png") == 0 && i + 1 < argc) { // Render heightmap, normal-map, and zone-map PNG previews for a // terrain. Useful for portfolio screenshots, ground-truth map // comparison, and quick visual validation without launching GUI. std::string base = argv[++i]; for (const char* ext : {".wot", ".whm"}) { if (base.size() >= 4 && base.substr(base.size() - 4) == ext) { base = base.substr(0, base.size() - 4); break; } } if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) { std::fprintf(stderr, "WOT/WHM not found at base: %s\n", base.c_str()); return 1; } wowee::pipeline::ADTTerrain terrain; if (!wowee::pipeline::WoweeTerrainLoader::load(base, terrain)) { std::fprintf(stderr, "Failed to load terrain: %s\n", base.c_str()); return 1; } wowee::editor::WoweeTerrain::exportHeightmapPreview(terrain, base + "_heightmap.png"); wowee::editor::WoweeTerrain::exportNormalMap(terrain, base + "_normals.png"); wowee::editor::WoweeTerrain::exportZoneMap(terrain, base + "_zone.png", 512); std::printf("Exported PNGs: %s_{heightmap,normals,zone}.png\n", base.c_str()); return 0; } else if (std::strcmp(argv[i], "--fix-zone") == 0 && i + 1 < argc) { // Re-parse + re-save every JSON/binary file in a zone to apply // the editor's load-time scrubs and save-time caps. Useful when // an old zone was created before recent hardening — running // this once cleans up NaN/oversize fields without touching // the editor GUI. std::string zoneDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "fix-zone: %s does not exist\n", zoneDir.c_str()); return 1; } int touched = 0; // zone.json { wowee::editor::ZoneManifest m; std::string p = zoneDir + "/zone.json"; if (fs::exists(p) && m.load(p) && m.save(p)) touched++; } // creatures.json { wowee::editor::NpcSpawner sp; std::string p = zoneDir + "/creatures.json"; if (fs::exists(p) && sp.loadFromFile(p) && sp.saveToFile(p)) touched++; } // objects.json { wowee::editor::ObjectPlacer op; std::string p = zoneDir + "/objects.json"; if (fs::exists(p) && op.loadFromFile(p) && op.saveToFile(p)) touched++; } // quests.json { wowee::editor::QuestEditor qe; std::string p = zoneDir + "/quests.json"; if (fs::exists(p) && qe.loadFromFile(p) && qe.saveToFile(p)) touched++; } // WHM/WOT pairs and WoB files would need full pipeline access; // skip them — the editor opens them on next zone load anyway, // and the load-time scrubs run then. std::printf("fix-zone: cleaned %d files in %s\n", touched, zoneDir.c_str()); return 0; } else if (std::strcmp(argv[i], "--regen-collision") == 0 && i + 1 < argc) { // Find all WHM/WOT pairs under a zone dir and rebuild WOC for each. // Useful after sculpting changes when you want to re-derive // collision in batch instead of one tile at a time. std::string zoneDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "regen-collision: %s does not exist\n", zoneDir.c_str()); return 1; } int rebuilt = 0, failed = 0; for (auto& entry : fs::recursive_directory_iterator(zoneDir)) { if (!entry.is_regular_file()) continue; if (entry.path().extension() != ".whm") continue; std::string base = entry.path().string(); base = base.substr(0, base.size() - 4); // strip .whm wowee::pipeline::ADTTerrain terrain; if (!wowee::pipeline::WoweeTerrainLoader::load(base, terrain)) { std::fprintf(stderr, " FAILED to load: %s\n", base.c_str()); failed++; continue; } auto col = wowee::pipeline::WoweeCollisionBuilder::fromTerrain(terrain); std::string outPath = base + ".woc"; if (wowee::pipeline::WoweeCollisionBuilder::save(col, outPath)) { std::printf(" WOC rebuilt: %s (%zu triangles)\n", outPath.c_str(), col.triangles.size()); rebuilt++; } else { std::fprintf(stderr, " FAILED to save: %s\n", outPath.c_str()); failed++; } } std::printf("regen-collision: %d rebuilt, %d failed\n", rebuilt, failed); return failed > 0 ? 1 : 0; } else if (std::strcmp(argv[i], "--build-woc") == 0 && i + 1 < argc) { // Generate a WOC collision mesh from a WHM/WOT terrain pair. // Uses terrain triangles only (no WMO overlays); useful as a // first-pass collision build before the editor adds buildings. std::string base = argv[++i]; for (const char* ext : {".wot", ".whm", ".woc"}) { if (base.size() >= 4 && base.substr(base.size() - 4) == ext) { base = base.substr(0, base.size() - 4); break; } } if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) { std::fprintf(stderr, "WOT/WHM not found at base: %s\n", base.c_str()); return 1; } wowee::pipeline::ADTTerrain terrain; if (!wowee::pipeline::WoweeTerrainLoader::load(base, terrain)) { std::fprintf(stderr, "Failed to load terrain: %s\n", base.c_str()); return 1; } auto col = wowee::pipeline::WoweeCollisionBuilder::fromTerrain(terrain); std::string outPath = base + ".woc"; if (!wowee::pipeline::WoweeCollisionBuilder::save(col, outPath)) { std::fprintf(stderr, "WOC save failed: %s\n", outPath.c_str()); return 1; } std::printf("WOC built: %s (%zu triangles, %zu walkable, %zu steep)\n", outPath.c_str(), col.triangles.size(), col.walkableCount(), col.steepCount()); return 0; } else if (std::strcmp(argv[i], "--add-quest") == 0 && i + 2 < argc) { // Append a single quest to a zone's quests.json. // Args: <zoneDir> <title> [giverId] [turnInId] [xp] [level] std::string zoneDir = argv[++i]; std::string title = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "add-quest: zone '%s' does not exist\n", zoneDir.c_str()); return 1; } wowee::editor::Quest q; q.title = title; // Optional positional args after title. Each is read in order; // an empty string or '-' stops consumption so users can omit // later fields. auto tryReadUint = [&](uint32_t& target) { if (i + 1 >= argc || argv[i + 1][0] == '-') return false; try { target = static_cast<uint32_t>(std::stoul(argv[i + 1])); ++i; return true; } catch (...) { return false; } }; tryReadUint(q.questGiverNpcId); tryReadUint(q.turnInNpcId); tryReadUint(q.reward.xp); tryReadUint(q.requiredLevel); wowee::editor::QuestEditor qe; std::string path = zoneDir + "/quests.json"; if (fs::exists(path)) qe.loadFromFile(path); qe.addQuest(q); if (!qe.saveToFile(path)) { std::fprintf(stderr, "add-quest: failed to write %s\n", path.c_str()); return 1; } std::printf("Added quest '%s' to %s (now %zu total)\n", title.c_str(), path.c_str(), qe.questCount()); return 0; } else if (std::strcmp(argv[i], "--add-quest-objective") == 0 && i + 4 < argc) { // Append a single objective to an existing quest. The quest // must already exist (use --add-quest first); index is 0-based // and matches --list-quests output. std::string zoneDir = argv[++i]; std::string idxStr = argv[++i]; std::string typeStr = argv[++i]; std::string targetName = argv[++i]; std::string path = zoneDir + "/quests.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "add-quest-objective: %s not found — run --add-quest first\n", path.c_str()); return 1; } int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "add-quest-objective: bad questIdx '%s'\n", idxStr.c_str()); return 1; } using OT = wowee::editor::QuestObjectiveType; OT type; if (typeStr == "kill") type = OT::KillCreature; else if (typeStr == "collect") type = OT::CollectItem; else if (typeStr == "talk") type = OT::TalkToNPC; else if (typeStr == "explore") type = OT::ExploreArea; else if (typeStr == "escort") type = OT::EscortNPC; else if (typeStr == "use") type = OT::UseObject; else { std::fprintf(stderr, "add-quest-objective: type must be kill/collect/talk/explore/escort/use, got '%s'\n", typeStr.c_str()); return 1; } uint32_t count = 1; if (i + 1 < argc && argv[i + 1][0] != '-') { try { count = static_cast<uint32_t>(std::stoul(argv[++i])); if (count == 0) count = 1; } catch (...) {} } wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "add-quest-objective: failed to load %s\n", path.c_str()); return 1; } if (idx < 0 || idx >= static_cast<int>(qe.questCount())) { std::fprintf(stderr, "add-quest-objective: questIdx %d out of range [0, %zu)\n", idx, qe.questCount()); return 1; } wowee::editor::QuestObjective obj; obj.type = type; obj.targetName = targetName; obj.targetCount = count; // Auto-generate a description from type+name+count so addons // and tooltips have something useful by default. The user can // edit quests.json directly if they want bespoke prose. const char* verb = "complete"; switch (type) { case OT::KillCreature: verb = "Slay"; break; case OT::CollectItem: verb = "Collect"; break; case OT::TalkToNPC: verb = "Talk to"; break; case OT::ExploreArea: verb = "Explore"; break; case OT::EscortNPC: verb = "Escort"; break; case OT::UseObject: verb = "Use"; break; } obj.description = std::string(verb) + " " + (count > 1 ? std::to_string(count) + " " : "") + targetName; // Quest is stored by value in the editor's vector; mutate via // the non-const getter, which gives us a pointer we can write // through. wowee::editor::Quest* q = qe.getQuest(idx); if (!q) { std::fprintf(stderr, "add-quest-objective: getQuest(%d) returned null\n", idx); return 1; } q->objectives.push_back(obj); if (!qe.saveToFile(path)) { std::fprintf(stderr, "add-quest-objective: failed to write %s\n", path.c_str()); return 1; } std::printf("Added objective '%s' to quest %d ('%s'), now %zu objective(s)\n", obj.description.c_str(), idx, q->title.c_str(), q->objectives.size()); return 0; } else if (std::strcmp(argv[i], "--remove-quest-objective") == 0 && i + 3 < argc) { // Symmetric counterpart to --add-quest-objective. Removes the // objective at <objIdx> within quest <questIdx>. Pair with // --info-quests / --list-quests to find the right indices. std::string zoneDir = argv[++i]; std::string qIdxStr = argv[++i]; std::string oIdxStr = argv[++i]; std::string path = zoneDir + "/quests.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "remove-quest-objective: %s not found\n", path.c_str()); return 1; } int qIdx, oIdx; try { qIdx = std::stoi(qIdxStr); oIdx = std::stoi(oIdxStr); } catch (...) { std::fprintf(stderr, "remove-quest-objective: bad index\n"); return 1; } wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "remove-quest-objective: failed to load %s\n", path.c_str()); return 1; } if (qIdx < 0 || qIdx >= static_cast<int>(qe.questCount())) { std::fprintf(stderr, "remove-quest-objective: questIdx %d out of range [0, %zu)\n", qIdx, qe.questCount()); return 1; } wowee::editor::Quest* q = qe.getQuest(qIdx); if (!q) return 1; if (oIdx < 0 || oIdx >= static_cast<int>(q->objectives.size())) { std::fprintf(stderr, "remove-quest-objective: objIdx %d out of range [0, %zu)\n", oIdx, q->objectives.size()); return 1; } std::string removedDesc = q->objectives[oIdx].description; q->objectives.erase(q->objectives.begin() + oIdx); if (!qe.saveToFile(path)) { std::fprintf(stderr, "remove-quest-objective: failed to write %s\n", path.c_str()); return 1; } std::printf("Removed objective '%s' (was index %d) from quest %d ('%s'), now %zu remaining\n", removedDesc.c_str(), oIdx, qIdx, q->title.c_str(), q->objectives.size()); return 0; } else if (std::strcmp(argv[i], "--clone-quest") == 0 && i + 2 < argc) { // Duplicate a quest. Useful for templating: create a base // quest with objectives + rewards once, then clone N times // for variants ('Slay Wolves', 'Slay Bears' with the same // shape). Optional newTitle replaces the cloned copy's title; // omit to get '<original> (copy)'. std::string zoneDir = argv[++i]; std::string idxStr = argv[++i]; std::string newTitle; if (i + 1 < argc && argv[i + 1][0] != '-') { newTitle = argv[++i]; } std::string path = zoneDir + "/quests.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "clone-quest: %s not found\n", path.c_str()); return 1; } int qIdx; try { qIdx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "clone-quest: bad questIdx '%s'\n", idxStr.c_str()); return 1; } wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "clone-quest: failed to load %s\n", path.c_str()); return 1; } if (qIdx < 0 || qIdx >= static_cast<int>(qe.questCount())) { std::fprintf(stderr, "clone-quest: questIdx %d out of range [0, %zu)\n", qIdx, qe.questCount()); return 1; } // Deep-copy by value via vector iteration; .objectives and // .reward are STL containers so the copy is automatic. wowee::editor::Quest clone = qe.getQuests()[qIdx]; // Reset id so the editor's auto-id sequence assigns a fresh // one — addQuest does this internally if id==0. clone.id = 0; // Reset chain link too — copying a chained quest with the // same nextQuestId would corrupt the chain semantics. clone.nextQuestId = 0; clone.title = newTitle.empty() ? (clone.title + " (copy)") : newTitle; qe.addQuest(clone); if (!qe.saveToFile(path)) { std::fprintf(stderr, "clone-quest: failed to write %s\n", path.c_str()); return 1; } std::printf("Cloned quest %d -> '%s' (now %zu total)\n", qIdx, clone.title.c_str(), qe.questCount()); std::printf(" carried %zu objective(s), %zu item reward(s), xp=%u\n", clone.objectives.size(), clone.reward.itemRewards.size(), clone.reward.xp); return 0; } else if (std::strcmp(argv[i], "--clone-creature") == 0 && i + 2 < argc) { // Duplicate a creature spawn. Common workflow: design one // 'patrol guard' archetype, then clone it across spawn points // around a town. Preserves stats, faction, behavior, equipment; // resets id and offsets position by 5 yards by default so the // copy doesn't z-fight with the original. std::string zoneDir = argv[++i]; std::string idxStr = argv[++i]; std::string newName; float dx = 5.0f, dy = 0.0f, dz = 0.0f; if (i + 1 < argc && argv[i + 1][0] != '-') { newName = argv[++i]; } // Optional 3-axis offset after newName. if (i + 3 < argc && argv[i + 1][0] != '-') { try { dx = std::stof(argv[++i]); dy = std::stof(argv[++i]); dz = std::stof(argv[++i]); } catch (...) { std::fprintf(stderr, "clone-creature: bad offset coordinate\n"); return 1; } } std::string path = zoneDir + "/creatures.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "clone-creature: %s not found\n", path.c_str()); return 1; } int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "clone-creature: bad idx '%s'\n", idxStr.c_str()); return 1; } wowee::editor::NpcSpawner sp; if (!sp.loadFromFile(path)) { std::fprintf(stderr, "clone-creature: failed to load %s\n", path.c_str()); return 1; } if (idx < 0 || idx >= static_cast<int>(sp.spawnCount())) { std::fprintf(stderr, "clone-creature: idx %d out of range [0, %zu)\n", idx, sp.spawnCount()); return 1; } // Deep-copy by value; CreatureSpawn is POD-ish (vectors for // patrol points copy automatically). wowee::editor::CreatureSpawn clone = sp.getSpawns()[idx]; clone.id = 0; // addCreature auto-assigns a fresh id clone.name = newName.empty() ? (clone.name + " (copy)") : newName; clone.position.x += dx; clone.position.y += dy; clone.position.z += dz; // Patrol path is intentionally NOT offset — patrol points are // typically authored as world-space waypoints, not relative to // the spawn. Designers re-author the path if needed. sp.getSpawns().push_back(clone); if (!sp.saveToFile(path)) { std::fprintf(stderr, "clone-creature: failed to write %s\n", path.c_str()); return 1; } std::printf("Cloned creature %d -> '%s' at (%.1f, %.1f, %.1f) (now %zu total)\n", idx, clone.name.c_str(), clone.position.x, clone.position.y, clone.position.z, sp.spawnCount()); return 0; } else if (std::strcmp(argv[i], "--clone-object") == 0 && i + 2 < argc) { // Symmetric to --clone-creature/--clone-quest. Common // workflow: place one tree/lamp/barrel just right, then // clone N copies along a path or around a square. Default // 5-yard X offset prevents z-fighting; rotation/scale are // preserved so a tilted object stays tilted. std::string zoneDir = argv[++i]; std::string idxStr = argv[++i]; float dx = 5.0f, dy = 0.0f, dz = 0.0f; if (i + 3 < argc && argv[i + 1][0] != '-') { try { dx = std::stof(argv[++i]); dy = std::stof(argv[++i]); dz = std::stof(argv[++i]); } catch (...) { std::fprintf(stderr, "clone-object: bad offset\n"); return 1; } } std::string path = zoneDir + "/objects.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "clone-object: %s not found\n", path.c_str()); return 1; } int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "clone-object: bad idx '%s'\n", idxStr.c_str()); return 1; } wowee::editor::ObjectPlacer placer; if (!placer.loadFromFile(path)) { std::fprintf(stderr, "clone-object: failed to load %s\n", path.c_str()); return 1; } auto& objs = placer.getObjects(); if (idx < 0 || idx >= static_cast<int>(objs.size())) { std::fprintf(stderr, "clone-object: idx %d out of range [0, %zu)\n", idx, objs.size()); return 1; } // Deep-copy by value. uniqueId is reset so the new object // doesn't collide with the source's identifier in any // downstream system that dedups by it. wowee::editor::PlacedObject clone = objs[idx]; clone.uniqueId = 0; clone.selected = false; clone.position.x += dx; clone.position.y += dy; clone.position.z += dz; objs.push_back(clone); if (!placer.saveToFile(path)) { std::fprintf(stderr, "clone-object: failed to write %s\n", path.c_str()); return 1; } std::printf("Cloned object %d -> '%s' at (%.1f, %.1f, %.1f) (now %zu total)\n", idx, clone.path.c_str(), clone.position.x, clone.position.y, clone.position.z, objs.size()); return 0; } else if (std::strcmp(argv[i], "--add-quest-reward-item") == 0 && i + 3 < argc) { // Append one or more item rewards to a quest. Multiple paths // can be passed in a single invocation: // --add-quest-reward-item zone 0 'Item:Sword' 'Item:Shield' std::string zoneDir = argv[++i]; std::string idxStr = argv[++i]; std::string path = zoneDir + "/quests.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "add-quest-reward-item: %s not found\n", path.c_str()); return 1; } int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "add-quest-reward-item: bad questIdx '%s'\n", idxStr.c_str()); return 1; } wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "add-quest-reward-item: failed to load %s\n", path.c_str()); return 1; } if (idx < 0 || idx >= static_cast<int>(qe.questCount())) { std::fprintf(stderr, "add-quest-reward-item: questIdx %d out of range [0, %zu)\n", idx, qe.questCount()); return 1; } wowee::editor::Quest* q = qe.getQuest(idx); if (!q) return 1; int added = 0; // Greedy-consume any remaining args that don't start with '-' // so the caller can batch-add a whole loot table in one shot. while (i + 1 < argc && argv[i + 1][0] != '-') { q->reward.itemRewards.push_back(argv[++i]); added++; } if (added == 0) { std::fprintf(stderr, "add-quest-reward-item: need at least one itemPath\n"); return 1; } if (!qe.saveToFile(path)) { std::fprintf(stderr, "add-quest-reward-item: failed to write %s\n", path.c_str()); return 1; } std::printf("Added %d item reward(s) to quest %d ('%s'), now %zu total\n", added, idx, q->title.c_str(), q->reward.itemRewards.size()); return 0; } else if (std::strcmp(argv[i], "--set-quest-reward") == 0 && i + 2 < argc) { // Update XP / coin reward fields on an existing quest. Each // field is optional — only the ones explicitly passed are // changed. This avoids the round-trip-and-clobber footgun of // a "replace whole reward" command. std::string zoneDir = argv[++i]; std::string idxStr = argv[++i]; std::string path = zoneDir + "/quests.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "set-quest-reward: %s not found\n", path.c_str()); return 1; } int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "set-quest-reward: bad questIdx '%s'\n", idxStr.c_str()); return 1; } wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "set-quest-reward: failed to load %s\n", path.c_str()); return 1; } if (idx < 0 || idx >= static_cast<int>(qe.questCount())) { std::fprintf(stderr, "set-quest-reward: questIdx %d out of range [0, %zu)\n", idx, qe.questCount()); return 1; } wowee::editor::Quest* q = qe.getQuest(idx); if (!q) return 1; int changed = 0; auto consumeUint = [&](const char* flag, uint32_t& target) { if (i + 2 < argc && std::strcmp(argv[i + 1], flag) == 0) { try { target = static_cast<uint32_t>(std::stoul(argv[i + 2])); i += 2; changed++; return true; } catch (...) { std::fprintf(stderr, "set-quest-reward: bad %s value '%s'\n", flag, argv[i + 2]); } } return false; }; // Loop until no more recognised flags consume their value — // order-independent, so callers can pass --gold then --xp. bool any = true; while (any) { any = false; if (consumeUint("--xp", q->reward.xp)) any = true; if (consumeUint("--gold", q->reward.gold)) any = true; if (consumeUint("--silver", q->reward.silver)) any = true; if (consumeUint("--copper", q->reward.copper)) any = true; } if (changed == 0) { std::fprintf(stderr, "set-quest-reward: no fields changed — pass --xp / --gold / --silver / --copper\n"); return 1; } if (!qe.saveToFile(path)) { std::fprintf(stderr, "set-quest-reward: failed to write %s\n", path.c_str()); return 1; } std::printf("Updated %d field(s) on quest %d ('%s'): xp=%u gold=%u silver=%u copper=%u\n", changed, idx, q->title.c_str(), q->reward.xp, q->reward.gold, q->reward.silver, q->reward.copper); return 0; } else if (std::strcmp(argv[i], "--remove-creature") == 0 && i + 2 < argc) { // Remove a creature spawn by 0-based index. Pair with // --info-creatures (or your editor) to find the right index // first; nothing identifies entries reliably across reloads. std::string zoneDir = argv[++i]; std::string idxStr = argv[++i]; std::string path = zoneDir + "/creatures.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "remove-creature: %s not found\n", path.c_str()); return 1; } int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "remove-creature: bad index '%s'\n", idxStr.c_str()); return 1; } wowee::editor::NpcSpawner sp; sp.loadFromFile(path); if (idx < 0 || idx >= static_cast<int>(sp.spawnCount())) { std::fprintf(stderr, "remove-creature: index %d out of range [0, %zu)\n", idx, sp.spawnCount()); return 1; } std::string removedName = sp.getSpawns()[idx].name; sp.removeCreature(idx); if (!sp.saveToFile(path)) { std::fprintf(stderr, "remove-creature: failed to write %s\n", path.c_str()); return 1; } std::printf("Removed creature '%s' (was index %d) from %s (now %zu total)\n", removedName.c_str(), idx, path.c_str(), sp.spawnCount()); return 0; } else if (std::strcmp(argv[i], "--remove-object") == 0 && i + 2 < argc) { std::string zoneDir = argv[++i]; std::string idxStr = argv[++i]; std::string path = zoneDir + "/objects.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "remove-object: %s not found\n", path.c_str()); return 1; } int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "remove-object: bad index '%s'\n", idxStr.c_str()); return 1; } wowee::editor::ObjectPlacer placer; placer.loadFromFile(path); auto& objs = placer.getObjects(); if (idx < 0 || idx >= static_cast<int>(objs.size())) { std::fprintf(stderr, "remove-object: index %d out of range [0, %zu)\n", idx, objs.size()); return 1; } std::string removedPath = objs[idx].path; objs.erase(objs.begin() + idx); if (!placer.saveToFile(path)) { std::fprintf(stderr, "remove-object: failed to write %s\n", path.c_str()); return 1; } std::printf("Removed object '%s' (was index %d) from %s (now %zu total)\n", removedPath.c_str(), idx, path.c_str(), objs.size()); return 0; } else if (std::strcmp(argv[i], "--remove-quest") == 0 && i + 2 < argc) { std::string zoneDir = argv[++i]; std::string idxStr = argv[++i]; std::string path = zoneDir + "/quests.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "remove-quest: %s not found\n", path.c_str()); return 1; } int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "remove-quest: bad index '%s'\n", idxStr.c_str()); return 1; } wowee::editor::QuestEditor qe; qe.loadFromFile(path); if (idx < 0 || idx >= static_cast<int>(qe.questCount())) { std::fprintf(stderr, "remove-quest: index %d out of range [0, %zu)\n", idx, qe.questCount()); return 1; } std::string removedTitle = qe.getQuests()[idx].title; qe.removeQuest(idx); if (!qe.saveToFile(path)) { std::fprintf(stderr, "remove-quest: failed to write %s\n", path.c_str()); return 1; } std::printf("Removed quest '%s' (was index %d) from %s (now %zu total)\n", removedTitle.c_str(), idx, path.c_str(), qe.questCount()); return 0; } else if (std::strcmp(argv[i], "--add-object") == 0 && i + 5 < argc) { // Append a single object placement to a zone's objects.json. // Args: <zoneDir> <m2|wmo> <gamePath> <x> <y> <z> [scale] std::string zoneDir = argv[++i]; std::string typeStr = argv[++i]; std::string gamePath = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "add-object: zone '%s' does not exist\n", zoneDir.c_str()); return 1; } wowee::editor::PlaceableType ptype; if (typeStr == "m2") ptype = wowee::editor::PlaceableType::M2; else if (typeStr == "wmo") ptype = wowee::editor::PlaceableType::WMO; else { std::fprintf(stderr, "add-object: type must be 'm2' or 'wmo'\n"); return 1; } glm::vec3 pos; try { pos.x = std::stof(argv[++i]); pos.y = std::stof(argv[++i]); pos.z = std::stof(argv[++i]); } catch (const std::exception& e) { std::fprintf(stderr, "add-object: bad coordinate (%s)\n", e.what()); return 1; } wowee::editor::ObjectPlacer placer; std::string path = zoneDir + "/objects.json"; if (fs::exists(path)) placer.loadFromFile(path); placer.setActivePath(gamePath, ptype); placer.placeObject(pos); // Optional scale after coordinates. if (i + 1 < argc && argv[i + 1][0] != '-') { try { float scale = std::stof(argv[++i]); if (std::isfinite(scale) && scale > 0.0f) { // Set scale on the just-placed object (last in list). placer.getObjects().back().scale = scale; } } catch (...) {} } if (!placer.saveToFile(path)) { std::fprintf(stderr, "add-object: failed to write %s\n", path.c_str()); return 1; } std::printf("Added %s '%s' to %s (now %zu total)\n", typeStr.c_str(), gamePath.c_str(), path.c_str(), placer.getObjects().size()); return 0; } else if (std::strcmp(argv[i], "--add-creature") == 0 && i + 4 < argc) { // Append a single creature spawn to a zone's creatures.json. // Args: <zoneDir> <name> <x> <y> <z> [displayId] [level] // Useful for batch-populating zones via shell script without // launching the GUI placement tool. std::string zoneDir = argv[++i]; std::string name = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "add-creature: zone '%s' does not exist\n", zoneDir.c_str()); return 1; } wowee::editor::CreatureSpawn s; s.name = name; try { s.position.x = std::stof(argv[++i]); s.position.y = std::stof(argv[++i]); s.position.z = std::stof(argv[++i]); } catch (const std::exception& e) { std::fprintf(stderr, "add-creature: bad coordinate (%s)\n", e.what()); return 1; } // Optional displayId (positional, after coordinates). if (i + 1 < argc && argv[i + 1][0] != '-') { try { s.displayId = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) { /* leave 0 → SQL exporter substitutes 11707 */ } } if (i + 1 < argc && argv[i + 1][0] != '-') { try { s.level = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) { /* leave default 1 */ } } // Load existing spawns (if any), append, save. wowee::editor::NpcSpawner spawner; std::string path = zoneDir + "/creatures.json"; if (fs::exists(path)) spawner.loadFromFile(path); spawner.placeCreature(s); if (!spawner.saveToFile(path)) { std::fprintf(stderr, "add-creature: failed to write %s\n", path.c_str()); return 1; } std::printf("Added creature '%s' to %s (now %zu total)\n", name.c_str(), path.c_str(), spawner.spawnCount()); return 0; } else if (std::strcmp(argv[i], "--add-item") == 0 && i + 2 < argc) { // Append one item entry to <zoneDir>/items.json. Inline // JSON without a dedicated editor class — items.json is // a simple {"items": [...]} array of records, and the // schema is small enough that we don't need NpcSpawner- // style infrastructure yet. // // Schema per item: // id (uint32) — Item.dbc primary key (auto-increments // from 1 if omitted) // name (string) // quality (uint8) — 0..6 (poor..artifact, default 1) // displayId (uint32) — ItemDisplayInfo index (default 0) // itemLevel (uint32) — default 1 // stackable (uint32) — max stack size (default 1) std::string zoneDir = argv[++i]; std::string name = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "add-item: zone '%s' does not exist\n", zoneDir.c_str()); return 1; } uint32_t id = 0, displayId = 0, itemLevel = 1; uint32_t quality = 1; if (i + 1 < argc && argv[i + 1][0] != '-') { try { id = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { quality = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} if (quality > 6) quality = 1; } if (i + 1 < argc && argv[i + 1][0] != '-') { try { displayId = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { itemLevel = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } std::string path = zoneDir + "/items.json"; nlohmann::json doc = nlohmann::json::object({{"items", nlohmann::json::array()}}); if (fs::exists(path)) { std::ifstream in(path); try { in >> doc; } catch (...) { std::fprintf(stderr, "add-item: %s exists but is not valid JSON\n", path.c_str()); return 1; } if (!doc.contains("items") || !doc["items"].is_array()) { doc["items"] = nlohmann::json::array(); } } // Auto-assign id if user passed 0 / nothing — pick the // smallest unused positive integer so the items.json // numbering stays contiguous. if (id == 0) { std::set<uint32_t> used; for (const auto& it : doc["items"]) { if (it.contains("id") && it["id"].is_number_unsigned()) { used.insert(it["id"].get<uint32_t>()); } } id = 1; while (used.count(id)) ++id; } // Reject duplicate id so the user notices a collision. for (const auto& it : doc["items"]) { if (it.contains("id") && it["id"].is_number_unsigned() && it["id"].get<uint32_t>() == id) { std::fprintf(stderr, "add-item: id %u already in use in %s\n", id, path.c_str()); return 1; } } nlohmann::json item = { {"id", id}, {"name", name}, {"quality", quality}, {"displayId", displayId}, {"itemLevel", itemLevel}, {"stackable", 1}, }; doc["items"].push_back(item); std::ofstream out(path); if (!out) { std::fprintf(stderr, "add-item: failed to write %s\n", path.c_str()); return 1; } out << doc.dump(2); out.close(); static const char* qualityNames[] = { "poor", "common", "uncommon", "rare", "epic", "legendary", "artifact" }; std::printf("Added item '%s' (id=%u, quality=%s, ilvl=%u) to %s (now %zu total)\n", name.c_str(), id, qualityNames[quality], itemLevel, path.c_str(), doc["items"].size()); return 0; } else if (std::strcmp(argv[i], "--random-populate-zone") == 0 && i + 1 < argc) { // Randomly add creatures and/or objects to a zone for // playtest scenarios. Reads the zone manifest's tile // bounds so spawn positions stay inside the actual // playable area. Seeded LCG for reproducibility — same // seed always produces the same population. // // Flags: // --seed N (default 42) // --creatures N (default 20) // --objects N (default 10) std::string zoneDir = argv[++i]; uint32_t seed = 42; int creatureCount = 20; int objectCount = 10; while (i + 2 < argc && argv[i + 1][0] == '-') { std::string flag = argv[++i]; if (flag == "--seed") { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } else if (flag == "--creatures") { try { creatureCount = std::stoi(argv[++i]); } catch (...) {} } else if (flag == "--objects") { try { objectCount = std::stoi(argv[++i]); } catch (...) {} } else { std::fprintf(stderr, "random-populate-zone: unknown flag '%s'\n", flag.c_str()); return 1; } } namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "random-populate-zone: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "random-populate-zone: failed to parse %s\n", manifestPath.c_str()); return 1; } if (zm.tiles.empty()) { std::fprintf(stderr, "random-populate-zone: zone has no tiles to populate\n"); return 1; } // Compute the world AABB the zone occupies so spawns land // inside it. Each tile is 533.33y; WoW grid centers tile // (32, 32) at world origin. constexpr float kTileSize = 533.33333f; int tMinX = 64, tMaxX = -1, tMinY = 64, tMaxY = -1; for (const auto& [tx, ty] : zm.tiles) { tMinX = std::min(tMinX, tx); tMaxX = std::max(tMaxX, tx); tMinY = std::min(tMinY, ty); tMaxY = std::max(tMaxY, ty); } float wMinX = (32.0f - tMaxY - 1) * kTileSize; float wMaxX = (32.0f - tMinY) * kTileSize; float wMinY = (32.0f - tMaxX - 1) * kTileSize; float wMaxY = (32.0f - tMinX) * kTileSize; float baseZ = zm.baseHeight; uint32_t rng = seed ? seed : 1u; auto next01 = [&]() { rng = rng * 1664525u + 1013904223u; return (rng >> 8) / float(1 << 24); }; auto rangeF = [&](float a, float b) { return a + next01() * (b - a); }; auto rangeI = [&](int a, int b) { return a + static_cast<int>(next01() * (b - a + 1)); }; // Tiny bestiary so the random output reads as plausible // rather than "Creature1 / Creature2". static const std::vector<std::pair<const char*, uint32_t>> kRandomCreatures = { {"Wolf", 5}, {"Boar", 4}, {"Bear", 7}, {"Spider", 3}, {"Bandit", 6}, {"Kobold", 4}, {"Murloc", 5}, {"Skeleton", 5}, {"Wisp", 3}, {"Goblin", 5}, {"Stag", 4}, {"Crab", 3}, }; static const std::vector<const char*> kRandomObjects = { "World/Generic/Tree01.wmo", "World/Generic/Boulder.wmo", "World/Generic/Bush.wmo", "World/Generic/Stump.wmo", "World/Generic/Mushroom.wmo", }; // Creatures. wowee::editor::NpcSpawner spawner; std::string cpath = zoneDir + "/creatures.json"; if (fs::exists(cpath)) spawner.loadFromFile(cpath); int placedCreatures = 0; for (int n = 0; n < creatureCount; ++n) { const auto& [name, baseLvl] = kRandomCreatures[ rangeI(0, static_cast<int>(kRandomCreatures.size()) - 1)]; wowee::editor::CreatureSpawn s; s.name = name; s.position.x = rangeF(wMinX, wMaxX); s.position.y = rangeF(wMinY, wMaxY); s.position.z = baseZ; int lvl = std::max(1, static_cast<int>(baseLvl) + rangeI(-1, 2)); s.level = static_cast<uint32_t>(lvl); s.health = 50 + s.level * 10; s.orientation = rangeF(0.0f, 360.0f); spawner.placeCreature(s); placedCreatures++; } if (placedCreatures > 0) spawner.saveToFile(cpath); // Objects. wowee::editor::ObjectPlacer placer; std::string opath = zoneDir + "/objects.json"; if (fs::exists(opath)) placer.loadFromFile(opath); int placedObjects = 0; // Push PlacedObject directly into the placer's vector so // we don't fight placeObject()'s early-return on empty // activePath_. uniqueId starts after any existing objects // to keep IDs collision-free. auto& objs = placer.getObjects(); uint32_t maxUid = 0; for (const auto& o : objs) maxUid = std::max(maxUid, o.uniqueId); for (int n = 0; n < objectCount; ++n) { wowee::editor::PlacedObject o; o.path = kRandomObjects[ rangeI(0, static_cast<int>(kRandomObjects.size()) - 1)]; o.type = wowee::editor::PlaceableType::WMO; o.position.x = rangeF(wMinX, wMaxX); o.position.y = rangeF(wMinY, wMaxY); o.position.z = baseZ; o.rotation = glm::vec3(0.0f, rangeF(0.0f, 6.28f), 0.0f); o.scale = rangeF(0.8f, 1.4f); o.uniqueId = ++maxUid; o.nameId = 0; o.selected = false; objs.push_back(o); placedObjects++; } if (placedObjects > 0) placer.saveToFile(opath); std::printf("random-populate-zone: %s\n", zoneDir.c_str()); std::printf(" seed : %u\n", seed); std::printf(" zone bbox : (%.0f, %.0f) - (%.0f, %.0f)\n", wMinX, wMinY, wMaxX, wMaxY); std::printf(" creatures : %d added (%zu total)\n", placedCreatures, spawner.spawnCount()); std::printf(" objects : %d added (%zu total)\n", placedObjects, placer.getObjects().size()); return 0; } else if (std::strcmp(argv[i], "--random-populate-items") == 0 && i + 1 < argc) { // Seeded random items.json populator. Pulls a base name // and a noun from inline word lists, picks a quality up // to maxQuality, randomizes itemLevel and stack size // around plausible defaults. Useful for playtest loot // tables that need bulk content without hand-typing each // entry. // // Flags: --seed N (default 7), --count N (default 30), // --max-quality Q (default 4 = epic; 0..6 valid). std::string zoneDir = argv[++i]; uint32_t seed = 7; int count = 30; int maxQuality = 4; while (i + 2 < argc && argv[i + 1][0] == '-') { std::string flag = argv[++i]; if (flag == "--seed") { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } else if (flag == "--count") { try { count = std::stoi(argv[++i]); } catch (...) {} } else if (flag == "--max-quality") { try { maxQuality = std::stoi(argv[++i]); } catch (...) {} } else { std::fprintf(stderr, "random-populate-items: unknown flag '%s'\n", flag.c_str()); return 1; } } if (maxQuality < 0 || maxQuality > 6) maxQuality = 4; namespace fs = std::filesystem; if (!fs::exists(zoneDir + "/zone.json")) { std::fprintf(stderr, "random-populate-items: %s has no zone.json\n", zoneDir.c_str()); return 1; } uint32_t rng = seed ? seed : 1u; auto next01 = [&]() { rng = rng * 1664525u + 1013904223u; return (rng >> 8) / float(1 << 24); }; auto rangeI = [&](int a, int b) { return a + static_cast<int>(next01() * (b - a + 1)); }; // Inline name lexicon. {prefix, noun} → "Glowing Sword". // Quality ramps prefix selection; rare+ items get fancier // adjectives. static const std::vector<const char*> kPrefixes[5] = { {"Worn", "Tattered", "Cracked", "Dented", "Faded"}, // poor {"Common", "Plain", "Basic", "Simple", "Standard"}, // common {"Sharp", "Sturdy", "Polished", "Reinforced", "Fine"}, // uncommon {"Glowing", "Runed", "Enchanted", "Storm", "Mystic"}, // rare {"Ancient", "Eternal", "Heroic", "Vengeful", "Soul"}, // epic }; static const std::vector<const char*> kNouns = { "Sword", "Mace", "Axe", "Dagger", "Staff", "Bow", "Helm", "Cuirass", "Greaves", "Gauntlets", "Ring", "Amulet", "Cloak", "Belt", "Boots", "Potion", "Scroll", "Tome", "Wand", "Shield", }; // Open the items doc. std::string ipath = zoneDir + "/items.json"; nlohmann::json doc = nlohmann::json::object({{"items", nlohmann::json::array()}}); if (fs::exists(ipath)) { std::ifstream in(ipath); try { in >> doc; } catch (...) {} if (!doc.contains("items") || !doc["items"].is_array()) { doc["items"] = nlohmann::json::array(); } } std::set<uint32_t> used; for (const auto& it : doc["items"]) { if (it.contains("id") && it["id"].is_number_unsigned()) used.insert(it["id"].get<uint32_t>()); } int added = 0; for (int n = 0; n < count; ++n) { int q = std::min(maxQuality, rangeI(0, maxQuality)); int qBucket = std::min(q, 4); const auto& prefixes = kPrefixes[qBucket]; std::string name = prefixes[rangeI(0, static_cast<int>(prefixes.size()) - 1)]; name += " "; name += kNouns[rangeI(0, static_cast<int>(kNouns.size()) - 1)]; uint32_t id = 1; while (used.count(id)) ++id; used.insert(id); int ilvl = std::max(1, rangeI(1, 5) + q * 12 + rangeI(-3, 3)); doc["items"].push_back({ {"id", id}, {"name", name}, {"quality", q}, {"displayId", rangeI(1000, 9999)}, {"itemLevel", ilvl}, {"stackable", q == 0 || q == 1 ? rangeI(1, 20) : 1}, }); added++; } std::ofstream out(ipath); if (!out) { std::fprintf(stderr, "random-populate-items: failed to write %s\n", ipath.c_str()); return 1; } out << doc.dump(2); out.close(); std::printf("random-populate-items: %s\n", ipath.c_str()); std::printf(" seed : %u\n", seed); std::printf(" added : %d\n", added); std::printf(" total items : %zu\n", doc["items"].size()); std::printf(" max quality : %d\n", maxQuality); return 0; } else if (std::strcmp(argv[i], "--gen-random-zone") == 0 && i + 1 < argc) { // End-to-end random zone generator. Composes scaffold-zone // + random-populate-zone + random-populate-items in one // invocation. Useful for "I just want a complete test // zone, don't make me chain three commands." // // Args: // <name> required (becomes the slug) // [tx ty] optional (default 32 32) // --seed N default 42 // --creatures N default 20 // --objects N default 10 // --items N default 25 // // Honors --random-populate-zone's hard caps + the existing // scaffold-zone validation. Sub-commands' output streams // through. std::string name = argv[++i]; int tx = 32, ty = 32; uint32_t seed = 42; int creatures = 20, objects = 10, items = 25; // Optional positional tx/ty (must be before any --flags). if (i + 2 < argc && argv[i + 1][0] != '-' && argv[i + 2][0] != '-') { try { tx = std::stoi(argv[++i]); ty = std::stoi(argv[++i]); } catch (...) {} } while (i + 2 < argc && argv[i + 1][0] == '-') { std::string flag = argv[++i]; if (flag == "--seed") try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} else if (flag == "--creatures") try { creatures = std::stoi(argv[++i]); } catch (...) {} else if (flag == "--objects") try { objects = std::stoi(argv[++i]); } catch (...) {} else if (flag == "--items") try { items = std::stoi(argv[++i]); } catch (...) {} else { std::fprintf(stderr, "gen-random-zone: unknown flag '%s'\n", flag.c_str()); return 1; } } // Slug-clean the name to match scaffold-zone's expectations. std::string slug; for (char c : name) { if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { slug += c; } else if (c == ' ') { slug += '_'; } } if (slug.empty()) { std::fprintf(stderr, "gen-random-zone: name '%s' has no valid characters\n", name.c_str()); return 1; } std::string self = argv[0]; namespace fs = std::filesystem; std::string zoneDir = "custom_zones/" + slug; std::printf("gen-random-zone: %s (tile %d, %d)\n", slug.c_str(), tx, ty); std::fflush(stdout); // 1. Scaffold. std::string scaffoldCmd = "\"" + self + "\" --scaffold-zone \"" + slug + "\" " + std::to_string(tx) + " " + std::to_string(ty); int rc = std::system(scaffoldCmd.c_str()); if (rc != 0) { std::fprintf(stderr, "gen-random-zone: scaffold step failed (rc=%d)\n", rc); return 1; } // 2. Random populate. std::fflush(stdout); std::string popCmd = "\"" + self + "\" --random-populate-zone \"" + zoneDir + "\" --seed " + std::to_string(seed) + " --creatures " + std::to_string(creatures) + " --objects " + std::to_string(objects); rc = std::system(popCmd.c_str()); if (rc != 0) { std::fprintf(stderr, "gen-random-zone: populate step failed (rc=%d)\n", rc); return 1; } // 3. Random items. std::fflush(stdout); std::string itemsCmd = "\"" + self + "\" --random-populate-items \"" + zoneDir + "\" --seed " + std::to_string(seed + 1) + " --count " + std::to_string(items); rc = std::system(itemsCmd.c_str()); if (rc != 0) { std::fprintf(stderr, "gen-random-zone: items step failed (rc=%d)\n", rc); return 1; } std::printf("\ngen-random-zone: complete\n"); std::printf(" zone dir : %s\n", zoneDir.c_str()); std::printf(" creatures : %d\n", creatures); std::printf(" objects : %d\n", objects); std::printf(" items : %d\n", items); return 0; } else if (std::strcmp(argv[i], "--gen-random-project") == 0 && i + 1 < argc) { // Project-wide companion: spawn N random zones in one // pass. Names default to "Zone1, Zone2..."; tile // coordinates step from (32, 32) outward in a simple // raster so they don't overlap. Each zone gets a unique // sub-seed so its random content differs. int count = 0; try { count = std::stoi(argv[++i]); } catch (...) { std::fprintf(stderr, "gen-random-project: <count> must be an integer\n"); return 1; } if (count < 1 || count > 100) { std::fprintf(stderr, "gen-random-project: count %d out of range (1..100)\n", count); return 1; } std::string prefix = "Zone"; uint32_t seed = 100; int creatures = 20, objects = 10, items = 25; while (i + 2 < argc && argv[i + 1][0] == '-') { std::string flag = argv[++i]; if (flag == "--prefix") prefix = argv[++i]; else if (flag == "--seed") try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} else if (flag == "--creatures") try { creatures = std::stoi(argv[++i]); } catch (...) {} else if (flag == "--objects") try { objects = std::stoi(argv[++i]); } catch (...) {} else if (flag == "--items") try { items = std::stoi(argv[++i]); } catch (...) {} else { std::fprintf(stderr, "gen-random-project: unknown flag '%s'\n", flag.c_str()); return 1; } } std::string self = argv[0]; int produced = 0, failed = 0; std::printf("gen-random-project: %d zone(s) with prefix '%s'\n", count, prefix.c_str()); for (int n = 0; n < count; ++n) { // Step outward from (32, 32) in a small raster so the // tiles don't coincide. (-1,0,1,...) X (-1,0,1,...). int side = 1; while ((2 * side + 1) * (2 * side + 1) <= n) side++; int idx = n; int dx = idx % (2 * side + 1) - side; int dy = (idx / (2 * side + 1)) - side; int tx = std::max(0, std::min(63, 32 + dx)); int ty = std::max(0, std::min(63, 32 + dy)); std::string zoneName = prefix + std::to_string(n + 1); std::printf("\n=== %s (tile %d, %d) ===\n", zoneName.c_str(), tx, ty); std::fflush(stdout); std::string cmd = "\"" + self + "\" --gen-random-zone \"" + zoneName + "\" " + std::to_string(tx) + " " + std::to_string(ty) + " --seed " + std::to_string(seed + n) + " --creatures " + std::to_string(creatures) + " --objects " + std::to_string(objects) + " --items " + std::to_string(items); int rc = std::system(cmd.c_str()); if (rc == 0) produced++; else failed++; } std::printf("\n--- summary ---\n"); std::printf(" produced : %d\n", produced); std::printf(" failed : %d\n", failed); return failed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--info-zone-audio") == 0 && i + 1 < argc) { // Print the audio configuration stored in zone.json: // music track, day/night ambience, volume sliders. // Useful for spot-checking that the zone has been wired // up to the right audio assets before bake/export. std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "info-zone-audio: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "info-zone-audio: failed to parse %s\n", manifestPath.c_str()); return 1; } if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["music"] = zm.musicTrack; j["ambienceDay"] = zm.ambienceDay; j["ambienceNight"] = zm.ambienceNight; j["musicVolume"] = zm.musicVolume; j["ambienceVolume"] = zm.ambienceVolume; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone audio: %s\n", zoneDir.c_str()); std::printf(" music : %s\n", zm.musicTrack.empty() ? "(none)" : zm.musicTrack.c_str()); std::printf(" ambience day : %s\n", zm.ambienceDay.empty() ? "(none)" : zm.ambienceDay.c_str()); std::printf(" ambience night: %s\n", zm.ambienceNight.empty() ? "(none)" : zm.ambienceNight.c_str()); std::printf(" music vol : %.2f\n", zm.musicVolume); std::printf(" ambience vol : %.2f\n", zm.ambienceVolume); return 0; } else if (std::strcmp(argv[i], "--info-project-audio") == 0 && i + 1 < argc) { // Project-wide audio rollup. Walks every zone in // <projectDir>, reads the audio fields out of zone.json, // emits a table showing which zones have music/ambience // configured. Useful for spotting zones still missing // audio assignment before a release pass. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "info-project-audio: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); struct Row { std::string name; std::string music; std::string ambDay; std::string ambNight; float musicVol, ambVol; }; std::vector<Row> rows; int withMusic = 0, withAmbience = 0; for (const auto& zoneDir : zones) { wowee::editor::ZoneManifest zm; if (!zm.load(zoneDir + "/zone.json")) continue; Row r; r.name = fs::path(zoneDir).filename().string(); r.music = zm.musicTrack; r.ambDay = zm.ambienceDay; r.ambNight = zm.ambienceNight; r.musicVol = zm.musicVolume; r.ambVol = zm.ambienceVolume; if (!r.music.empty()) withMusic++; if (!r.ambDay.empty() || !r.ambNight.empty()) withAmbience++; rows.push_back(r); } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = zones.size(); j["withMusic"] = withMusic; j["withAmbience"] = withAmbience; nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({{"name", r.name}, {"music", r.music}, {"ambienceDay", r.ambDay}, {"ambienceNight", r.ambNight}, {"musicVolume", r.musicVol}, {"ambienceVolume", r.ambVol}}); } j["zones"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project audio: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" with music : %d\n", withMusic); std::printf(" with ambience : %d\n", withAmbience); std::printf("\n zone music? ambience? m-vol a-vol\n"); auto label = [](const std::string& s) { if (s.empty()) return "(none)"; auto sl = s.rfind('\\'); if (sl == std::string::npos) sl = s.rfind('/'); return sl == std::string::npos ? s.c_str() : s.c_str() + sl + 1; }; for (const auto& r : rows) { std::string ambLabel = !r.ambDay.empty() ? r.ambDay : !r.ambNight.empty() ? r.ambNight : ""; std::printf(" %-22s %-6s %-9s %5.2f %5.2f\n", r.name.substr(0, 22).c_str(), r.music.empty() ? "no" : "yes", ambLabel.empty() ? "no" : "yes", r.musicVol, r.ambVol); } return 0; } else if (std::strcmp(argv[i], "--set-item") == 0 && i + 2 < argc) { // Edit fields on an existing item in place. Lookup is by // id by default; '#N' for index lookup. Only specified // flags are changed; everything else is preserved // verbatim — including any extra fields added by hand. // // Supported flags: --name, --quality, --displayId, // --itemLevel, --stackable. Each takes one positional // argument that follows the flag. std::string zoneDir = argv[++i]; std::string lookup = argv[++i]; namespace fs = std::filesystem; std::string path = zoneDir + "/items.json"; if (!fs::exists(path)) { std::fprintf(stderr, "set-item: %s has no items.json\n", zoneDir.c_str()); return 1; } nlohmann::json doc; try { std::ifstream in(path); in >> doc; } catch (...) { std::fprintf(stderr, "set-item: %s is not valid JSON\n", path.c_str()); return 1; } if (!doc.contains("items") || !doc["items"].is_array()) { std::fprintf(stderr, "set-item: %s has no 'items' array\n", path.c_str()); return 1; } auto& items = doc["items"]; int foundIdx = -1; if (!lookup.empty() && lookup[0] == '#') { try { int idx = std::stoi(lookup.substr(1)); if (idx >= 0 && static_cast<size_t>(idx) < items.size()) foundIdx = idx; } catch (...) {} } else { uint32_t targetId = 0; try { targetId = static_cast<uint32_t>(std::stoul(lookup)); } catch (...) { std::fprintf(stderr, "set-item: lookup '%s' is not a number\n", lookup.c_str()); return 1; } for (size_t k = 0; k < items.size(); ++k) { if (items[k].contains("id") && items[k]["id"].is_number_unsigned() && items[k]["id"].get<uint32_t>() == targetId) { foundIdx = static_cast<int>(k); break; } } } if (foundIdx < 0) { std::fprintf(stderr, "set-item: no match for '%s' in %s\n", lookup.c_str(), path.c_str()); return 1; } auto& it = items[foundIdx]; std::vector<std::string> changes; // Walk the remaining args looking for known --field value // pairs. Anything unrecognized is reported and aborts so // typos don't silently no-op. while (i + 2 < argc) { std::string flag = argv[i + 1]; std::string val = argv[i + 2]; if (flag.size() < 2 || flag[0] != '-' || flag[1] != '-') break; if (flag == "--name") { it["name"] = val; changes.push_back("name=" + val); } else if (flag == "--quality") { try { uint32_t q = static_cast<uint32_t>(std::stoul(val)); if (q > 6) { std::fprintf(stderr, "set-item: quality %u out of range (0..6)\n", q); return 1; } it["quality"] = q; changes.push_back("quality=" + val); } catch (...) { std::fprintf(stderr, "set-item: --quality needs a number\n"); return 1; } } else if (flag == "--displayId") { try { it["displayId"] = static_cast<uint32_t>(std::stoul(val)); changes.push_back("displayId=" + val); } catch (...) { std::fprintf(stderr, "set-item: --displayId needs a number\n"); return 1; } } else if (flag == "--itemLevel") { try { it["itemLevel"] = static_cast<uint32_t>(std::stoul(val)); changes.push_back("itemLevel=" + val); } catch (...) { std::fprintf(stderr, "set-item: --itemLevel needs a number\n"); return 1; } } else if (flag == "--stackable") { try { uint32_t s = static_cast<uint32_t>(std::stoul(val)); if (s == 0 || s > 1000) { std::fprintf(stderr, "set-item: stackable %u out of range (1..1000)\n", s); return 1; } it["stackable"] = s; changes.push_back("stackable=" + val); } catch (...) { std::fprintf(stderr, "set-item: --stackable needs a number\n"); return 1; } } else { std::fprintf(stderr, "set-item: unknown flag '%s' (typo?)\n", flag.c_str()); return 1; } i += 2; } if (changes.empty()) { std::fprintf(stderr, "set-item: no field flags supplied — nothing to change\n"); return 1; } std::ofstream out(path); if (!out) { std::fprintf(stderr, "set-item: failed to write %s\n", path.c_str()); return 1; } out << doc.dump(2); out.close(); std::printf("Updated item %d in %s:\n", foundIdx, path.c_str()); for (const auto& c : changes) { std::printf(" %s\n", c.c_str()); } return 0; } else if (std::strcmp(argv[i], "--export-zone-items-md") == 0 && i + 1 < argc) { // Render items.json as a Markdown table grouped by // quality. Useful for design docs, PR descriptions, and // GitHub Pages — one rendered page communicates the loot // landscape better than scrolling through JSON. std::string zoneDir = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; namespace fs = std::filesystem; std::string path = zoneDir + "/items.json"; if (!fs::exists(path)) { std::fprintf(stderr, "export-zone-items-md: %s has no items.json\n", zoneDir.c_str()); return 1; } nlohmann::json doc; try { std::ifstream in(path); in >> doc; } catch (...) { std::fprintf(stderr, "export-zone-items-md: %s is not valid JSON\n", path.c_str()); return 1; } if (!doc.contains("items") || !doc["items"].is_array()) { std::fprintf(stderr, "export-zone-items-md: %s has no 'items' array\n", path.c_str()); return 1; } if (outPath.empty()) outPath = zoneDir + "/ITEMS.md"; const auto& items = doc["items"]; static const char* qualityNames[] = { "Poor", "Common", "Uncommon", "Rare", "Epic", "Legendary", "Artifact" }; // Bucket by quality so the report reads top-down from // best loot to filler. Reverse iteration over the buckets. std::map<int, std::vector<size_t>> byQuality; for (size_t k = 0; k < items.size(); ++k) { uint32_t q = items[k].value("quality", 1u); if (q > 6) q = 0; byQuality[q].push_back(k); } std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-zone-items-md: cannot write %s\n", outPath.c_str()); return 1; } std::string zoneName = fs::path(zoneDir).filename().string(); out << "# Items: " << zoneName << "\n\n"; out << "Source: `" << path << "` \n"; out << "Total items: **" << items.size() << "**\n\n"; // Quality histogram up top. out << "## Quality breakdown\n\n"; out << "| Quality | Count |\n|---|---:|\n"; for (int q = 6; q >= 0; --q) { auto it = byQuality.find(q); if (it == byQuality.end()) continue; out << "| " << qualityNames[q] << " | " << it->second.size() << " |\n"; } out << "\n"; // Per-quality sections, best first. for (int q = 6; q >= 0; --q) { auto qit = byQuality.find(q); if (qit == byQuality.end()) continue; out << "## " << qualityNames[q] << "\n\n"; out << "| ID | Name | iLvl | Display | Stack |\n"; out << "|---:|---|---:|---:|---:|\n"; for (size_t k : qit->second) { const auto& it = items[k]; std::string name = it.value("name", std::string("(unnamed)")); out << "| " << it.value("id", 0u) << " | " << name << " | " << it.value("itemLevel", 1u) << " | " << it.value("displayId", 0u) << " | " << it.value("stackable", 1u) << " |\n"; } out << "\n"; } out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" total items : %zu\n", items.size()); std::printf(" qualities : %zu (used)\n", byQuality.size()); return 0; } else if (std::strcmp(argv[i], "--export-project-items-md") == 0 && i + 1 < argc) { // Project-wide items markdown. Walks every zone in // <projectDir> and emits one document with: project-wide // header + total + quality histogram, then per-zone // sections each containing a table (ID/name/quality/ // ilvl/displayId/stack). Easier to scan than running // --export-zone-items-md N times. std::string projectDir = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "export-project-items-md: %s is not a directory\n", projectDir.c_str()); return 1; } if (outPath.empty()) outPath = projectDir + "/ITEMS.md"; std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; if (!fs::exists(entry.path() / "items.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); static const char* qualityNames[] = { "Poor", "Common", "Uncommon", "Rare", "Epic", "Legendary", "Artifact" }; int totalItems = 0; std::map<int, int> globalQ; // Per-zone collected items so we don't have to re-read // each items.json twice. struct ZItems { std::string name; nlohmann::json items; }; std::vector<ZItems> zoneItems; for (const auto& zoneDir : zones) { std::string ipath = zoneDir + "/items.json"; nlohmann::json doc; try { std::ifstream in(ipath); in >> doc; } catch (...) { continue; } if (!doc.contains("items") || !doc["items"].is_array()) continue; ZItems z; z.name = fs::path(zoneDir).filename().string(); z.items = doc["items"]; for (const auto& it : z.items) { int q = static_cast<int>(it.value("quality", 1u)); if (q < 0 || q > 6) q = 0; globalQ[q]++; totalItems++; } zoneItems.push_back(std::move(z)); } std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-project-items-md: cannot write %s\n", outPath.c_str()); return 1; } out << "# Project Items: " << fs::path(projectDir).filename().string() << "\n\n"; out << "Source: `" << projectDir << "` \n"; out << "Zones with items: **" << zoneItems.size() << "** \n"; out << "Total items: **" << totalItems << "**\n\n"; out << "## Project quality breakdown\n\n"; out << "| Quality | Count |\n|---|---:|\n"; for (int q = 6; q >= 0; --q) { auto it = globalQ.find(q); if (it == globalQ.end()) continue; out << "| " << qualityNames[q] << " | " << it->second << " |\n"; } out << "\n"; for (const auto& z : zoneItems) { out << "## Zone: " << z.name << "\n\n"; out << "Items: **" << z.items.size() << "**\n\n"; out << "| ID | Name | Quality | iLvl | Display | Stack |\n"; out << "|---:|---|---|---:|---:|---:|\n"; for (const auto& it : z.items) { int q = static_cast<int>(it.value("quality", 1u)); if (q < 0 || q > 6) q = 0; std::string name = it.value("name", std::string("(unnamed)")); out << "| " << it.value("id", 0u) << " | " << name << " | " << qualityNames[q] << " | " << it.value("itemLevel", 1u) << " | " << it.value("displayId", 0u) << " | " << it.value("stackable", 1u) << " |\n"; } out << "\n"; } out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" zones with items : %zu\n", zoneItems.size()); std::printf(" total items : %d\n", totalItems); return 0; } else if (std::strcmp(argv[i], "--export-project-items-csv") == 0 && i + 1 < argc) { // Single CSV with every item across every zone. The // zone name is the first column so a pivot table can // group by it; everything else mirrors --export-zone-csv // items columns. Saves running the per-zone CSV exporter // N times and concatenating manually. std::string projectDir = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "export-project-items-csv: %s is not a directory\n", projectDir.c_str()); return 1; } if (outPath.empty()) outPath = projectDir + "/items.csv"; std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; if (!fs::exists(entry.path() / "items.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); // CSV-escape the same way --export-zone-csv does. auto csvEsc = [](const std::string& s) { bool needs = s.find(',') != std::string::npos || s.find('"') != std::string::npos || s.find('\n') != std::string::npos; if (!needs) return s; std::string out = "\""; for (char c : s) { if (c == '"') out += "\"\""; else out += c; } out += "\""; return out; }; std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-project-items-csv: cannot write %s\n", outPath.c_str()); return 1; } out << "zone,index,id,name,quality,itemLevel,displayId,stackable\n"; int totalRows = 0; for (const auto& zoneDir : zones) { std::string zoneName = fs::path(zoneDir).filename().string(); std::string ipath = zoneDir + "/items.json"; nlohmann::json doc; try { std::ifstream in(ipath); in >> doc; } catch (...) { continue; } if (!doc.contains("items") || !doc["items"].is_array()) continue; const auto& items = doc["items"]; for (size_t k = 0; k < items.size(); ++k) { const auto& it = items[k]; out << csvEsc(zoneName) << "," << k << "," << it.value("id", 0u) << "," << csvEsc(it.value("name", std::string())) << "," << it.value("quality", 1u) << "," << it.value("itemLevel", 1u) << "," << it.value("displayId", 0u) << "," << it.value("stackable", 1u) << "\n"; totalRows++; } } out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" zones with items : %zu\n", zones.size()); std::printf(" rows : %d\n", totalRows); return 0; } else if (std::strcmp(argv[i], "--remove-item") == 0 && i + 2 < argc) { // Remove the item at given 0-based index from <zoneDir>/ // items.json. Mirrors --remove-creature/--remove-object/ // --remove-quest semantics — bounds-checked, file rewrites // on success, exit 1 on out-of-range. std::string zoneDir = argv[++i]; int idx = -1; try { idx = std::stoi(argv[++i]); } catch (...) { std::fprintf(stderr, "remove-item: index must be an integer\n"); return 1; } namespace fs = std::filesystem; std::string path = zoneDir + "/items.json"; if (!fs::exists(path)) { std::fprintf(stderr, "remove-item: %s has no items.json\n", zoneDir.c_str()); return 1; } nlohmann::json doc; try { std::ifstream in(path); in >> doc; } catch (...) { std::fprintf(stderr, "remove-item: %s is not valid JSON\n", path.c_str()); return 1; } if (!doc.contains("items") || !doc["items"].is_array()) { std::fprintf(stderr, "remove-item: %s has no 'items' array\n", path.c_str()); return 1; } auto& items = doc["items"]; if (idx < 0 || static_cast<size_t>(idx) >= items.size()) { std::fprintf(stderr, "remove-item: index %d out of range (have %zu)\n", idx, items.size()); return 1; } std::string removedName = items[idx].value("name", std::string("(unnamed)")); uint32_t removedId = items[idx].value("id", 0u); items.erase(items.begin() + idx); std::ofstream out(path); if (!out) { std::fprintf(stderr, "remove-item: failed to write %s\n", path.c_str()); return 1; } out << doc.dump(2); out.close(); std::printf("Removed item '%s' (id=%u) from %s (now %zu total)\n", removedName.c_str(), removedId, path.c_str(), items.size()); return 0; } else if (std::strcmp(argv[i], "--copy-zone-items") == 0 && i + 2 < argc) { // Copy items from one zone to another. Default mode // replaces the destination items.json wholesale; --merge // appends each source item to the existing destination // list, re-id'ing on collision so the destination's // existing IDs are preserved and the source's new // entries get fresh ones. std::string fromZone = argv[++i]; std::string toZone = argv[++i]; bool mergeMode = false; if (i + 1 < argc && std::strcmp(argv[i + 1], "--merge") == 0) { mergeMode = true; i++; } namespace fs = std::filesystem; std::string srcPath = fromZone + "/items.json"; if (!fs::exists(srcPath)) { std::fprintf(stderr, "copy-zone-items: %s has no items.json\n", fromZone.c_str()); return 1; } if (!fs::exists(toZone) || !fs::is_directory(toZone)) { std::fprintf(stderr, "copy-zone-items: dest %s is not a directory\n", toZone.c_str()); return 1; } nlohmann::json src; try { std::ifstream in(srcPath); in >> src; } catch (...) { std::fprintf(stderr, "copy-zone-items: %s is not valid JSON\n", srcPath.c_str()); return 1; } if (!src.contains("items") || !src["items"].is_array()) { std::fprintf(stderr, "copy-zone-items: %s has no 'items' array\n", srcPath.c_str()); return 1; } std::string dstPath = toZone + "/items.json"; nlohmann::json dst = nlohmann::json::object({{"items", nlohmann::json::array()}}); int copied = 0, reIded = 0; if (mergeMode && fs::exists(dstPath)) { try { std::ifstream in(dstPath); in >> dst; } catch (...) {} if (!dst.contains("items") || !dst["items"].is_array()) { dst["items"] = nlohmann::json::array(); } std::set<uint32_t> usedIds; for (const auto& it : dst["items"]) { if (it.contains("id") && it["id"].is_number_unsigned()) { usedIds.insert(it["id"].get<uint32_t>()); } } for (const auto& it : src["items"]) { nlohmann::json newItem = it; uint32_t srcId = it.value("id", 0u); if (srcId == 0 || usedIds.count(srcId)) { // Pick the next free id. uint32_t fresh = 1; while (usedIds.count(fresh)) ++fresh; newItem["id"] = fresh; usedIds.insert(fresh); if (srcId != 0) reIded++; } else { usedIds.insert(srcId); } dst["items"].push_back(newItem); copied++; } } else { // Replace mode: destination becomes a verbatim copy of // the source items array. dst["items"] = src["items"]; copied = static_cast<int>(src["items"].size()); } std::ofstream out(dstPath); if (!out) { std::fprintf(stderr, "copy-zone-items: failed to write %s\n", dstPath.c_str()); return 1; } out << dst.dump(2); out.close(); std::printf("Copied %d item(s) from %s to %s\n", copied, fromZone.c_str(), toZone.c_str()); std::printf(" mode : %s\n", mergeMode ? "merge (append + re-id)" : "replace"); std::printf(" dst total : %zu\n", dst["items"].size()); if (reIded > 0) { std::printf(" re-ided : %d (id collisions)\n", reIded); } return 0; } else if (std::strcmp(argv[i], "--clone-item") == 0 && i + 2 < argc) { // Duplicate the item at given 0-based index. Auto-assigns // the smallest unused positive id; optional <newName> // overrides the cloned name (without it the new entry // gets " (copy)" appended). std::string zoneDir = argv[++i]; int idx = -1; try { idx = std::stoi(argv[++i]); } catch (...) { std::fprintf(stderr, "clone-item: index must be an integer\n"); return 1; } std::string newName; if (i + 1 < argc && argv[i + 1][0] != '-') newName = argv[++i]; namespace fs = std::filesystem; std::string path = zoneDir + "/items.json"; if (!fs::exists(path)) { std::fprintf(stderr, "clone-item: %s has no items.json\n", zoneDir.c_str()); return 1; } nlohmann::json doc; try { std::ifstream in(path); in >> doc; } catch (...) { std::fprintf(stderr, "clone-item: %s is not valid JSON\n", path.c_str()); return 1; } if (!doc.contains("items") || !doc["items"].is_array()) { std::fprintf(stderr, "clone-item: %s has no 'items' array\n", path.c_str()); return 1; } auto& items = doc["items"]; if (idx < 0 || static_cast<size_t>(idx) >= items.size()) { std::fprintf(stderr, "clone-item: index %d out of range (have %zu)\n", idx, items.size()); return 1; } // Pick the next free id. std::set<uint32_t> used; for (const auto& it : items) { if (it.contains("id") && it["id"].is_number_unsigned()) { used.insert(it["id"].get<uint32_t>()); } } uint32_t newId = 1; while (used.count(newId)) ++newId; nlohmann::json clone = items[idx]; clone["id"] = newId; if (!newName.empty()) { clone["name"] = newName; } else { std::string oldName = clone.value("name", std::string("(unnamed)")); clone["name"] = oldName + " (copy)"; } items.push_back(clone); std::ofstream out(path); if (!out) { std::fprintf(stderr, "clone-item: failed to write %s\n", path.c_str()); return 1; } out << doc.dump(2); out.close(); std::printf("Cloned item idx %d to '%s' (id=%u) in %s (now %zu total)\n", idx, clone["name"].get<std::string>().c_str(), newId, path.c_str(), items.size()); return 0; } else if (std::strcmp(argv[i], "--scaffold-zone") == 0 && i + 1 < argc) { // Generate a minimal valid empty zone — useful for kickstarting // a new authoring session without needing to launch the GUI. std::string rawName = argv[++i]; int sx = 32, sy = 32; if (i + 2 < argc) { int parsedX = std::atoi(argv[i + 1]); int parsedY = std::atoi(argv[i + 2]); if (parsedX >= 0 && parsedX <= 63 && parsedY >= 0 && parsedY <= 63) { sx = parsedX; sy = parsedY; i += 2; } } // Slugify name to match unpackZone / server module rules. std::string slug; for (char c : rawName) { if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { slug += c; } else if (c == ' ') { slug += '_'; } } if (slug.empty()) { std::fprintf(stderr, "--scaffold-zone: name '%s' has no valid characters\n", rawName.c_str()); return 1; } namespace fs = std::filesystem; std::string dir = "custom_zones/" + slug; if (fs::exists(dir)) { std::fprintf(stderr, "--scaffold-zone: directory already exists: %s\n", dir.c_str()); return 1; } fs::create_directories(dir); // Blank flat terrain at the requested tile. auto terrain = wowee::editor::TerrainEditor::createBlankTerrain( sx, sy, 100.0f, wowee::editor::Biome::Grassland); std::string base = dir + "/" + slug + "_" + std::to_string(sx) + "_" + std::to_string(sy); wowee::editor::WoweeTerrain::exportOpen(terrain, base, sx, sy); // Minimal zone.json wowee::editor::ZoneManifest manifest; manifest.mapName = slug; manifest.displayName = rawName; manifest.mapId = 9000; manifest.baseHeight = 100.0f; manifest.tiles.push_back({sx, sy}); manifest.save(dir + "/zone.json"); std::printf("Scaffolded zone: %s\n", dir.c_str()); std::printf(" tile : (%d, %d)\n", sx, sy); std::printf(" files : %s.wot, %s.whm, zone.json\n", slug.c_str(), slug.c_str()); std::printf(" next step: run editor without args, then File → Open Zone\n"); return 0; } else if (std::strcmp(argv[i], "--mvp-zone") == 0 && i + 1 < argc) { // Quick-start: scaffold + populate one of each content type // (1 creature, 1 object, 1 quest with objective + reward). // Useful for demos, screenshot bait, smoke tests of the // bake/validate pipeline. The zone goes from empty to // 'something to look at' in one command. std::string rawName = argv[++i]; int sx = 32, sy = 32; if (i + 2 < argc) { int parsedX = std::atoi(argv[i + 1]); int parsedY = std::atoi(argv[i + 2]); if (parsedX >= 0 && parsedX <= 63 && parsedY >= 0 && parsedY <= 63) { sx = parsedX; sy = parsedY; i += 2; } } // Reuse scaffold-zone's slug logic. std::string slug; for (char c : rawName) { if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') slug += c; else if (c == ' ') slug += '_'; } if (slug.empty()) { std::fprintf(stderr, "mvp-zone: name '%s' has no valid characters\n", rawName.c_str()); return 1; } namespace fs = std::filesystem; std::string dir = "custom_zones/" + slug; if (fs::exists(dir)) { std::fprintf(stderr, "mvp-zone: directory already exists: %s\n", dir.c_str()); return 1; } fs::create_directories(dir); // Scaffold terrain. auto terrain = wowee::editor::TerrainEditor::createBlankTerrain( sx, sy, 100.0f, wowee::editor::Biome::Grassland); std::string base = dir + "/" + slug + "_" + std::to_string(sx) + "_" + std::to_string(sy); wowee::editor::WoweeTerrain::exportOpen(terrain, base, sx, sy); // Manifest. wowee::editor::ZoneManifest zm; zm.mapName = slug; zm.displayName = rawName; zm.mapId = 9000; zm.baseHeight = 100.0f; zm.tiles.push_back({sx, sy}); zm.hasCreatures = true; zm.save(dir + "/zone.json"); // Position the demo content roughly centered in the tile. // Tile (32, 32) is the WoW map origin; tile centers are at // 533.33-yard intervals from there. float centerX = (32.0f - sy) * 533.33333f - 266.667f; float centerY = (32.0f - sx) * 533.33333f - 266.667f; float centerZ = 100.0f; // Demo creature. wowee::editor::NpcSpawner sp; wowee::editor::CreatureSpawn c; c.name = "Demo Wolf"; c.position = {centerX, centerY, centerZ}; c.level = 5; c.health = 100; c.minDamage = 5; c.maxDamage = 10; c.displayId = 11430; // any valid id; renderer falls back if absent sp.getSpawns().push_back(c); sp.saveToFile(dir + "/creatures.json"); // Demo object — a tree placement near the creature. wowee::editor::ObjectPlacer op; wowee::editor::PlacedObject po; po.type = wowee::editor::PlaceableType::M2; po.path = "World/Generic/Tree.m2"; po.position = {centerX + 5.0f, centerY, centerZ}; po.scale = 1.0f; op.getObjects().push_back(po); op.saveToFile(dir + "/objects.json"); // Demo quest with objective + XP reward. wowee::editor::QuestEditor qe; wowee::editor::Quest q; q.title = "Welcome to " + rawName; q.requiredLevel = 1; q.questGiverNpcId = c.id; // self-referential so refs check passes q.turnInNpcId = c.id; q.reward.xp = 100; wowee::editor::QuestObjective obj; obj.type = wowee::editor::QuestObjectiveType::KillCreature; obj.targetName = "Demo Wolf"; obj.targetCount = 1; obj.description = "Slay the Demo Wolf"; q.objectives.push_back(obj); qe.addQuest(q); qe.saveToFile(dir + "/quests.json"); std::printf("Created demo zone: %s\n", dir.c_str()); std::printf(" tile : (%d, %d)\n", sx, sy); std::printf(" contents : 1 creature, 1 object, 1 quest (with objective + reward)\n"); std::printf(" next : wowee_editor --info-zone-tree %s\n", dir.c_str()); return 0; } else if (std::strcmp(argv[i], "--add-tile") == 0 && i + 3 < argc) { // Extend an existing zone with another ADT tile. Zones can // span multiple tiles (e.g. a continent fragment), but // --scaffold-zone only creates one. This adds another: // wowee_editor --add-tile custom_zones/MyZone 29 30 // Generates a fresh blank-flat WHM/WOT pair at the new tile // and appends to the zone manifest's tiles list. std::string zoneDir = argv[++i]; int tx, ty; try { tx = std::stoi(argv[++i]); ty = std::stoi(argv[++i]); } catch (...) { std::fprintf(stderr, "add-tile: bad coordinates\n"); return 1; } float baseHeight = 100.0f; if (i + 1 < argc && argv[i + 1][0] != '-') { try { baseHeight = std::stof(argv[++i]); } catch (...) {} } if (tx < 0 || tx >= 64 || ty < 0 || ty >= 64) { std::fprintf(stderr, "add-tile: tile coord (%d, %d) out of WoW grid [0, 64)\n", tx, ty); return 1; } namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "add-tile: %s has no zone.json — not a zone dir\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "add-tile: failed to parse %s\n", manifestPath.c_str()); return 1; } // Reject duplicates so we don't silently overwrite an existing // tile's heightmap when the user makes a typo. for (const auto& [ex, ey] : zm.tiles) { if (ex == tx && ey == ty) { std::fprintf(stderr, "add-tile: tile (%d, %d) already in manifest\n", tx, ty); return 1; } } // Also bail if the file would clobber an existing one outside // the manifest (e.g. user hand-created tiles without updating // zone.json). Catches drift between disk and manifest. std::string base = zoneDir + "/" + zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty); if (fs::exists(base + ".whm") || fs::exists(base + ".wot")) { std::fprintf(stderr, "add-tile: %s.{whm,wot} already exists on disk (manifest out of sync?)\n", base.c_str()); return 1; } // Generate the new heightmap. Reuses the same factory that // --scaffold-zone uses, so the output is consistent. auto terrain = wowee::editor::TerrainEditor::createBlankTerrain( tx, ty, baseHeight, wowee::editor::Biome::Grassland); wowee::editor::WoweeTerrain::exportOpen(terrain, base, tx, ty); // Append + save manifest. ZoneManifest::save rebuilds the // files block from the tiles list, so the new adt_tx_ty entry // appears automatically in zone.json. zm.tiles.push_back({tx, ty}); if (!zm.save(manifestPath)) { std::fprintf(stderr, "add-tile: failed to save %s\n", manifestPath.c_str()); return 1; } std::printf("Added tile (%d, %d) to %s\n", tx, ty, zoneDir.c_str()); std::printf(" files : %s.whm, %s.wot\n", (zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty)).c_str(), (zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty)).c_str()); std::printf(" tiles now : %zu total\n", zm.tiles.size()); return 0; } else if (std::strcmp(argv[i], "--remove-tile") == 0 && i + 3 < argc) { // Symmetric counterpart to --add-tile. Drops the entry from // ZoneManifest::tiles AND deletes the WHM/WOT/WOC files on // disk so the zone is left consistent (no orphan sidecars). std::string zoneDir = argv[++i]; int tx, ty; try { tx = std::stoi(argv[++i]); ty = std::stoi(argv[++i]); } catch (...) { std::fprintf(stderr, "remove-tile: bad coordinates\n"); return 1; } namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "remove-tile: %s has no zone.json — not a zone dir\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "remove-tile: failed to parse %s\n", manifestPath.c_str()); return 1; } auto it = std::find_if(zm.tiles.begin(), zm.tiles.end(), [&](const std::pair<int,int>& p) { return p.first == tx && p.second == ty; }); if (it == zm.tiles.end()) { std::fprintf(stderr, "remove-tile: tile (%d, %d) not in manifest\n", tx, ty); return 1; } // Don't strand a zone with zero tiles — server module gen and // pack-wcp both expect at least one. The user can --rename-zone // or rm -rf if they want the zone gone entirely. if (zm.tiles.size() == 1) { std::fprintf(stderr, "remove-tile: refusing to remove last tile (zone would be empty)\n"); return 1; } zm.tiles.erase(it); // Delete the slug-prefixed files for this tile. Use error_code // so we don't throw on missing files — partial removal from // earlier failures shouldn't block cleanup of what's left. std::string base = zoneDir + "/" + zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty); int deleted = 0; std::error_code ec; for (const char* ext : {".whm", ".wot", ".woc"}) { if (fs::remove(base + ext, ec)) deleted++; } if (!zm.save(manifestPath)) { std::fprintf(stderr, "remove-tile: failed to save %s\n", manifestPath.c_str()); return 1; } std::printf("Removed tile (%d, %d) from %s\n", tx, ty, zoneDir.c_str()); std::printf(" deleted : %d file(s) (.whm/.wot/.woc)\n", deleted); std::printf(" tiles now : %zu remaining\n", zm.tiles.size()); return 0; } else if (std::strcmp(argv[i], "--list-tiles") == 0 && i + 1 < argc) { // Enumerate every tile in the zone manifest with on-disk // file presence — useful for spotting missing/orphan files // before pack-wcp would fail. std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "list-tiles: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "list-tiles: failed to parse %s\n", manifestPath.c_str()); return 1; } auto baseFor = [&](int tx, int ty) { return zoneDir + "/" + zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty); }; if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["mapName"] = zm.mapName; j["count"] = zm.tiles.size(); nlohmann::json arr = nlohmann::json::array(); for (const auto& [tx, ty] : zm.tiles) { std::string b = baseFor(tx, ty); arr.push_back({ {"x", tx}, {"y", ty}, {"whm", fs::exists(b + ".whm")}, {"wot", fs::exists(b + ".wot")}, {"woc", fs::exists(b + ".woc")}, }); } j["tiles"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone: %s (%s, %zu tile(s))\n", zoneDir.c_str(), zm.mapName.c_str(), zm.tiles.size()); std::printf(" tx ty whm wot woc\n"); for (const auto& [tx, ty] : zm.tiles) { std::string b = baseFor(tx, ty); std::printf(" %3d %3d %s %s %s\n", tx, ty, fs::exists(b + ".whm") ? "y" : "-", fs::exists(b + ".wot") ? "y" : "-", fs::exists(b + ".woc") ? "y" : "-"); } return 0; } else if (std::strcmp(argv[i], "--copy-zone") == 0 && i + 2 < argc) { // Duplicate a zone — copy every file then rename slug-prefixed // ones (heightmap/terrain/collision sidecars carry the slug in // their filenames, e.g. "Sample_28_30.whm") so the new zone is // self-consistent. Useful for templating: scaffold once, then // copy-zone N times to create variants. std::string srcDir = argv[++i]; std::string rawName = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "copy-zone: source dir not found: %s\n", srcDir.c_str()); return 1; } if (!fs::exists(srcDir + "/zone.json")) { std::fprintf(stderr, "copy-zone: %s has no zone.json — not a zone dir\n", srcDir.c_str()); return 1; } // Slugify new name (matches scaffold-zone rules so the result // round-trips through unpackZone / server module gen). std::string newSlug; for (char c : rawName) { if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { newSlug += c; } else if (c == ' ') { newSlug += '_'; } } if (newSlug.empty()) { std::fprintf(stderr, "copy-zone: name '%s' has no valid characters\n", rawName.c_str()); return 1; } std::string dstDir = "custom_zones/" + newSlug; if (fs::exists(dstDir)) { std::fprintf(stderr, "copy-zone: destination already exists: %s\n", dstDir.c_str()); return 1; } // Read the source slug from its zone.json so we know what // prefix to rewrite. Don't trust the directory name — a user // could have renamed the dir without touching the manifest. wowee::editor::ZoneManifest src; if (!src.load(srcDir + "/zone.json")) { std::fprintf(stderr, "copy-zone: failed to parse %s/zone.json\n", srcDir.c_str()); return 1; } std::string oldSlug = src.mapName; if (oldSlug == newSlug) { std::fprintf(stderr, "copy-zone: new slug matches old (%s); nothing to do\n", oldSlug.c_str()); return 1; } // Recursive copy preserves any subdirs (e.g. data/ for DBC sidecars). std::error_code ec; fs::create_directories(dstDir); fs::copy(srcDir, dstDir, fs::copy_options::recursive | fs::copy_options::copy_symlinks, ec); if (ec) { std::fprintf(stderr, "copy-zone: copy failed: %s\n", ec.message().c_str()); return 1; } // Rename slug-prefixed files inside the destination. Match // "<oldSlug>_..." or "<oldSlug>." so we catch both // "Sample_28_30.whm" and a hypothetical "Sample.wdt". int renamed = 0; for (const auto& entry : fs::recursive_directory_iterator(dstDir)) { if (!entry.is_regular_file()) continue; std::string fname = entry.path().filename().string(); bool match = (fname.size() > oldSlug.size() + 1 && fname.compare(0, oldSlug.size(), oldSlug) == 0 && (fname[oldSlug.size()] == '_' || fname[oldSlug.size()] == '.')); if (!match) continue; std::string newName = newSlug + fname.substr(oldSlug.size()); fs::rename(entry.path(), entry.path().parent_path() / newName, ec); if (!ec) renamed++; } // Rewrite the destination's zone.json with the new slug so its // files-block (rebuilt from mapName by save()) matches the // renamed files on disk. wowee::editor::ZoneManifest dst = src; dst.mapName = newSlug; dst.displayName = rawName; if (!dst.save(dstDir + "/zone.json")) { std::fprintf(stderr, "copy-zone: failed to write %s/zone.json\n", dstDir.c_str()); return 1; } std::printf("Copied %s -> %s\n", srcDir.c_str(), dstDir.c_str()); std::printf(" mapName : %s -> %s\n", oldSlug.c_str(), newSlug.c_str()); std::printf(" renamed : %d slug-prefixed file(s)\n", renamed); return 0; } else if (std::strcmp(argv[i], "--rename-zone") == 0 && i + 2 < argc) { // In-place rename — like --copy-zone but no copy. Useful when // the user wants to fix a typo or change a name without // doubling disk usage. Renames the directory itself too // (Old/ -> New/ under the same parent), so paths shift. std::string srcDir = argv[++i]; std::string rawName = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "rename-zone: source dir not found: %s\n", srcDir.c_str()); return 1; } if (!fs::exists(srcDir + "/zone.json")) { std::fprintf(stderr, "rename-zone: %s has no zone.json — not a zone dir\n", srcDir.c_str()); return 1; } std::string newSlug; for (char c : rawName) { if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { newSlug += c; } else if (c == ' ') { newSlug += '_'; } } if (newSlug.empty()) { std::fprintf(stderr, "rename-zone: name '%s' has no valid characters\n", rawName.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(srcDir + "/zone.json")) { std::fprintf(stderr, "rename-zone: failed to parse %s/zone.json\n", srcDir.c_str()); return 1; } std::string oldSlug = zm.mapName; if (oldSlug == newSlug && rawName == zm.displayName) { std::fprintf(stderr, "rename-zone: nothing to do (slug=%s, displayName=%s already match)\n", oldSlug.c_str(), rawName.c_str()); return 1; } // Compute target directory: same parent, new slug name. If the // current directory name already matches the new slug, skip // the dir rename (only manifest + slug-prefixed files change). fs::path srcPath = fs::absolute(srcDir); fs::path parent = srcPath.parent_path(); fs::path dstPath = parent / newSlug; bool needDirRename = (srcPath.filename() != newSlug); if (needDirRename && fs::exists(dstPath)) { std::fprintf(stderr, "rename-zone: target dir already exists: %s\n", dstPath.string().c_str()); return 1; } // Rename slug-prefixed files inside the source dir BEFORE // moving the directory — fewer paths to fix up if anything // fails midway. fs::rename is atomic per-call. std::error_code ec; int renamed = 0; for (const auto& entry : fs::recursive_directory_iterator(srcDir)) { if (!entry.is_regular_file()) continue; std::string fname = entry.path().filename().string(); bool match = (oldSlug != newSlug && fname.size() > oldSlug.size() + 1 && fname.compare(0, oldSlug.size(), oldSlug) == 0 && (fname[oldSlug.size()] == '_' || fname[oldSlug.size()] == '.')); if (!match) continue; std::string newName = newSlug + fname.substr(oldSlug.size()); fs::rename(entry.path(), entry.path().parent_path() / newName, ec); if (!ec) renamed++; } // Update manifest and save BEFORE the dir rename so the file // exists at the path we're saving to. zm.mapName = newSlug; zm.displayName = rawName; if (!zm.save(srcDir + "/zone.json")) { std::fprintf(stderr, "rename-zone: failed to write zone.json\n"); return 1; } // Now move the directory itself. std::string finalDir = srcDir; if (needDirRename) { fs::rename(srcPath, dstPath, ec); if (ec) { std::fprintf(stderr, "rename-zone: dir rename failed (%s); manifest already updated\n", ec.message().c_str()); return 1; } finalDir = dstPath.string(); } std::printf("Renamed %s -> %s\n", srcDir.c_str(), finalDir.c_str()); std::printf(" mapName : %s -> %s\n", oldSlug.c_str(), newSlug.c_str()); std::printf(" renamed : %d slug-prefixed file(s)\n", renamed); return 0; } else if (std::strcmp(argv[i], "--remove-zone") == 0 && i + 1 < argc) { // Delete a zone directory entirely. Requires --confirm to // actually delete (defense against accidental destruction // and against shell glob mishaps). Without --confirm, // just lists what would be deleted. std::string zoneDir = argv[++i]; bool confirm = false; if (i + 1 < argc && std::strcmp(argv[i + 1], "--confirm") == 0) { confirm = true; i++; } namespace fs = std::filesystem; if (!fs::exists(zoneDir)) { std::fprintf(stderr, "remove-zone: %s does not exist\n", zoneDir.c_str()); return 1; } if (!fs::exists(zoneDir + "/zone.json")) { // Belt-and-suspenders: refuse to wipe anything that doesn't // look like a zone dir, even with --confirm. Catches typos // like '--remove-zone .' that would nuke the whole project. std::fprintf(stderr, "remove-zone: %s has no zone.json — refusing to delete (not a zone dir)\n", zoneDir.c_str()); return 1; } // Read manifest for the user-facing name. wowee::editor::ZoneManifest zm; std::string zoneName = zoneDir; if (zm.load(zoneDir + "/zone.json")) { zoneName = zm.displayName.empty() ? zm.mapName : zm.displayName; } // Walk for what would be removed (counts + total bytes). int fileCount = 0; uint64_t totalBytes = 0; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; fileCount++; totalBytes += e.file_size(ec); } if (!confirm) { std::printf("remove-zone: %s ('%s')\n", zoneDir.c_str(), zoneName.c_str()); std::printf(" would delete: %d file(s), %.1f KB\n", fileCount, totalBytes / 1024.0); std::printf(" re-run with --confirm to actually delete\n"); return 0; } // Confirmed — wipe it. uintmax_t removed = fs::remove_all(zoneDir, ec); if (ec) { std::fprintf(stderr, "remove-zone: failed to remove %s (%s)\n", zoneDir.c_str(), ec.message().c_str()); return 1; } std::printf("Removed %s ('%s')\n", zoneDir.c_str(), zoneName.c_str()); std::printf(" deleted: %ju filesystem entries, %.1f KB freed\n", static_cast<uintmax_t>(removed), totalBytes / 1024.0); return 0; } else if (std::strcmp(argv[i], "--clear-zone-content") == 0 && i + 1 < argc) { // Wipe content files (creatures.json / objects.json / // quests.json) from a zone while keeping terrain + manifest // intact. Useful for templating: --copy-zone gives you a // duplicate; --clear-zone-content turns it into an empty // shell ready for fresh population. // // Pass --creatures / --objects / --quests to wipe individually, // or --all to wipe everything. At least one selector is required. std::string zoneDir = argv[++i]; bool wipeCreatures = false, wipeObjects = false, wipeQuests = false; while (i + 1 < argc && argv[i + 1][0] == '-') { std::string opt = argv[i + 1]; if (opt == "--creatures") { wipeCreatures = true; ++i; } else if (opt == "--objects") { wipeObjects = true; ++i; } else if (opt == "--quests") { wipeQuests = true; ++i; } else if (opt == "--all") { wipeCreatures = wipeObjects = wipeQuests = true; ++i; } else break; // unknown flag — stop consuming, surface the error } if (!wipeCreatures && !wipeObjects && !wipeQuests) { std::fprintf(stderr, "clear-zone-content: pass --creatures / --objects / --quests / --all\n"); return 1; } namespace fs = std::filesystem; if (!fs::exists(zoneDir + "/zone.json")) { std::fprintf(stderr, "clear-zone-content: %s has no zone.json — not a zone dir\n", zoneDir.c_str()); return 1; } // Delete (not blank-write) so the next --info-* doesn't see // an empty file and report 'total: 0' as if data existed. // Missing files are the canonical 'no content' state. int deleted = 0; std::error_code ec; auto wipe = [&](const std::string& fname) { std::string p = zoneDir + "/" + fname; if (fs::exists(p) && fs::remove(p, ec)) { ++deleted; std::printf(" removed : %s\n", fname.c_str()); } else if (fs::exists(p)) { std::fprintf(stderr, " WARN: failed to remove %s (%s)\n", p.c_str(), ec.message().c_str()); } else { std::printf(" skipped : %s (already absent)\n", fname.c_str()); } }; std::printf("Cleared content from %s\n", zoneDir.c_str()); if (wipeCreatures) wipe("creatures.json"); if (wipeObjects) wipe("objects.json"); if (wipeQuests) wipe("quests.json"); // Also reset manifest.hasCreatures so server module gen // doesn't expect an NPC table that's no longer there. if (wipeCreatures) { wowee::editor::ZoneManifest zm; if (zm.load(zoneDir + "/zone.json")) { if (zm.hasCreatures) { zm.hasCreatures = false; zm.save(zoneDir + "/zone.json"); std::printf(" updated : zone.json hasCreatures = false\n"); } } } std::printf(" removed : %d file(s) total\n", deleted); return 0; } else if (std::strcmp(argv[i], "--strip-zone") == 0 && i + 1 < argc) { // Cleanup pass: remove the derived outputs (.glb/.obj/.stl/ // .html/.dot/.csv/ZONE.md/DEPS.md) leaving only source files // (zone.json + content JSONs + open binary formats). Useful // before --pack-wcp so the archive doesn't carry redundant // exports, or before committing to git so derived blobs // don't bloat history. // // Optional --dry-run flag previews what would be removed // without actually deleting anything. std::string zoneDir = argv[++i]; bool dryRun = false; if (i + 1 < argc && std::strcmp(argv[i + 1], "--dry-run") == 0) { dryRun = true; i++; } namespace fs = std::filesystem; if (!fs::exists(zoneDir + "/zone.json")) { std::fprintf(stderr, "strip-zone: %s has no zone.json\n", zoneDir.c_str()); return 1; } // Whitelist of derived extensions. PNG is special-cased: it // can be either a derived export (heightmap preview at zone // root) or a source sidecar (BLP→PNG inside data/). Only // strip PNGs at the top-level zone dir. auto isDerivedExt = [](const std::string& ext) { return ext == ".glb" || ext == ".obj" || ext == ".stl" || ext == ".html" || ext == ".dot" || ext == ".csv"; }; auto isDerivedFilename = [](const std::string& name) { return name == "ZONE.md" || name == "DEPS.md" || name == "quests.dot"; }; int removed = 0; uint64_t bytesFreed = 0; std::error_code ec; // Top-level only — do NOT recurse into data/ (those are // source sidecars). for (const auto& e : fs::directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::string name = e.path().filename().string(); bool kill = false; if (isDerivedExt(ext)) kill = true; if (isDerivedFilename(name)) kill = true; // PNG at zone root is derived (--export-png); PNGs inside // data/ are source. Top-level loop only sees the root // dir, so .png here is always derived. if (ext == ".png") kill = true; if (!kill) continue; uint64_t sz = e.file_size(ec); if (dryRun) { std::printf(" would remove: %s (%llu bytes)\n", name.c_str(), static_cast<unsigned long long>(sz)); } else { if (fs::remove(e.path(), ec)) { std::printf(" removed: %s (%llu bytes)\n", name.c_str(), static_cast<unsigned long long>(sz)); removed++; bytesFreed += sz; } else { std::fprintf(stderr, " WARN: failed to remove %s (%s)\n", name.c_str(), ec.message().c_str()); } } } std::printf("\nstrip-zone: %s%s\n", zoneDir.c_str(), dryRun ? " (dry-run)" : ""); if (dryRun) { std::printf(" pass --dry-run off to actually delete\n"); } else { std::printf(" removed : %d file(s)\n", removed); std::printf(" freed : %.1f KB\n", bytesFreed / 1024.0); } return 0; } else if (std::strcmp(argv[i], "--strip-project") == 0 && i + 1 < argc) { // Project-wide wrapper around --strip-zone. Walks every zone // in <projectDir>, removes derived outputs at each zone's // top level, and reports per-zone removed/freed counts plus // an aggregate. Honors --dry-run for safe previews. std::string projectDir = argv[++i]; bool dryRun = false; if (i + 1 < argc && std::strcmp(argv[i + 1], "--dry-run") == 0) { dryRun = true; i++; } namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "strip-project: %s is not a directory\n", projectDir.c_str()); return 1; } // Same derived-classifier as --strip-zone — keep in sync. auto isDerivedExt = [](const std::string& ext) { return ext == ".glb" || ext == ".obj" || ext == ".stl" || ext == ".html" || ext == ".dot" || ext == ".csv"; }; auto isDerivedFilename = [](const std::string& name) { return name == "ZONE.md" || name == "DEPS.md" || name == "quests.dot"; }; std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); struct ZRow { std::string name; int removed = 0; uint64_t freed = 0; }; std::vector<ZRow> rows; int totalRemoved = 0; uint64_t totalFreed = 0; int totalFailed = 0; for (const auto& zoneDir : zones) { ZRow r; r.name = fs::path(zoneDir).filename().string(); std::error_code ec; for (const auto& e : fs::directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::string name = e.path().filename().string(); bool kill = false; if (isDerivedExt(ext)) kill = true; if (isDerivedFilename(name)) kill = true; if (ext == ".png") kill = true; if (!kill) continue; uint64_t sz = e.file_size(ec); if (dryRun) { r.removed++; r.freed += sz; } else { if (fs::remove(e.path(), ec)) { r.removed++; r.freed += sz; } else { std::fprintf(stderr, " WARN: failed to remove %s/%s (%s)\n", r.name.c_str(), name.c_str(), ec.message().c_str()); totalFailed++; } } } totalRemoved += r.removed; totalFreed += r.freed; rows.push_back(r); } std::printf("strip-project: %s%s\n", projectDir.c_str(), dryRun ? " (dry-run)" : ""); std::printf(" zones : %zu\n", zones.size()); std::printf("\n zone removed freed\n"); for (const auto& r : rows) { std::printf(" %-26s %5d %9.1f KB\n", r.name.substr(0, 26).c_str(), r.removed, r.freed / 1024.0); } std::printf("\n totals%s : %d file(s), %.1f KB\n", dryRun ? " (would-remove)" : " ", totalRemoved, totalFreed / 1024.0); if (dryRun) { std::printf(" pass --dry-run off to actually delete\n"); } return totalFailed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--gen-texture") == 0 && i + 2 < argc) { // Synthesize a placeholder PNG texture. Lets users add a // working texture to their project without an external // image editor — useful for prototyping new meshes, // filling out a zone before art is final, or generating // test fixtures. // // <colorHex|pattern>: // "RRGGBB" or "RGB" hex (case-insensitive) → solid color // "checker" → 32x32 black/white checkerboard // "grid" → black background with white 1-px grid every 16 std::string outPath = argv[++i]; std::string spec = argv[++i]; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { W = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { H = std::stoi(argv[++i]); } catch (...) {} } if (W < 1 || H < 1 || W > 8192 || H > 8192) { std::fprintf(stderr, "gen-texture: invalid size %dx%d (must be 1..8192)\n", W, H); return 1; } std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); std::string lower = spec; std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return std::tolower(c); }); if (lower == "checker") { for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { bool dark = ((x / 32) + (y / 32)) & 1; uint8_t v = dark ? 16 : 240; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = v; pixels[i2 + 1] = v; pixels[i2 + 2] = v; } } } else if (lower == "grid") { for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { bool line = (x % 16 == 0) || (y % 16 == 0); uint8_t v = line ? 240 : 32; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = v; pixels[i2 + 1] = v; pixels[i2 + 2] = v; } } } else { // Hex color. Accept "RGB" (3 chars) or "RRGGBB" (6 chars), // optional leading '#'. std::string hex = lower; if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHex = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; uint8_t r = 0, g = 0, b = 0; if (hex.size() == 6) { int hi, lo; if ((hi = fromHex(hex[0])) < 0) goto bad_color; if ((lo = fromHex(hex[1])) < 0) goto bad_color; r = static_cast<uint8_t>((hi << 4) | lo); if ((hi = fromHex(hex[2])) < 0) goto bad_color; if ((lo = fromHex(hex[3])) < 0) goto bad_color; g = static_cast<uint8_t>((hi << 4) | lo); if ((hi = fromHex(hex[4])) < 0) goto bad_color; if ((lo = fromHex(hex[5])) < 0) goto bad_color; b = static_cast<uint8_t>((hi << 4) | lo); } else if (hex.size() == 3) { int v0, v1, v2; if ((v0 = fromHex(hex[0])) < 0) goto bad_color; if ((v1 = fromHex(hex[1])) < 0) goto bad_color; if ((v2 = fromHex(hex[2])) < 0) goto bad_color; r = static_cast<uint8_t>((v0 << 4) | v0); g = static_cast<uint8_t>((v1 << 4) | v1); b = static_cast<uint8_t>((v2 << 4) | v2); } else { goto bad_color; } for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = r; pixels[i2 + 1] = g; pixels[i2 + 2] = b; } } goto color_ok; bad_color: std::fprintf(stderr, "gen-texture: '%s' is not a valid hex color or 'checker'/'grid'\n", spec.c_str()); return 1; color_ok: ; } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture: stbi_write_png failed for %s\n", outPath.c_str()); return 1; } std::printf("Wrote %s\n", outPath.c_str()); std::printf(" size : %dx%d\n", W, H); std::printf(" spec : %s\n", spec.c_str()); return 0; } else if (std::strcmp(argv[i], "--add-texture-to-zone") == 0 && i + 2 < argc) { // Import an existing PNG into a zone directory. Useful // for the "I have an artist-painted texture, get it into // my project" workflow — complements --gen-texture // (procedural placeholder) and --convert-blp-png (legacy // BLP migration). // // Optional <renameTo> argument lets the user store the // PNG under a project-specific name (e.g., a generic // "stone.png" downloaded from a tileset becomes // "courtyard_floor.png" in the zone). // // Refuses to overwrite an existing destination unless the // source and destination are byte-identical (idempotent // re-runs are safe). std::string zoneDir = argv[++i]; std::string srcPng = argv[++i]; std::string renameTo; if (i + 1 < argc && argv[i + 1][0] != '-') renameTo = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(zoneDir) || !fs::is_directory(zoneDir)) { std::fprintf(stderr, "add-texture-to-zone: %s is not a directory\n", zoneDir.c_str()); return 1; } if (!fs::exists(srcPng) || !fs::is_regular_file(srcPng)) { std::fprintf(stderr, "add-texture-to-zone: %s is not a file\n", srcPng.c_str()); return 1; } // Sanity-check: must end in .png (any case) so users // don't accidentally drop a .blp/.tga and get surprised // when nothing renders. std::string srcExt = fs::path(srcPng).extension().string(); std::transform(srcExt.begin(), srcExt.end(), srcExt.begin(), [](unsigned char c) { return std::tolower(c); }); if (srcExt != ".png") { std::fprintf(stderr, "add-texture-to-zone: %s is not a .png " "(use --convert-blp-png for .blp first)\n", srcPng.c_str()); return 1; } std::string destLeaf = renameTo.empty() ? fs::path(srcPng).filename().string() : renameTo; // If the rename arg lacks an extension, append .png so // common typos ("stone" -> "stone.png") just work. if (fs::path(destLeaf).extension().string().empty()) { destLeaf += ".png"; } std::string destPath = zoneDir + "/" + destLeaf; std::error_code ec; if (fs::exists(destPath)) { // Allow re-running if the bytes already match — makes // makefile-driven workflows idempotent. if (fs::file_size(srcPng, ec) == fs::file_size(destPath, ec)) { std::ifstream a(srcPng, std::ios::binary); std::ifstream b(destPath, std::ios::binary); std::stringstream sa, sb; sa << a.rdbuf(); sb << b.rdbuf(); if (sa.str() == sb.str()) { std::printf("Already present: %s (no-op)\n", destPath.c_str()); return 0; } } std::fprintf(stderr, "add-texture-to-zone: %s already exists with different " "content (delete it first if intentional)\n", destPath.c_str()); return 1; } fs::copy_file(srcPng, destPath, ec); if (ec) { std::fprintf(stderr, "add-texture-to-zone: copy failed (%s)\n", ec.message().c_str()); return 1; } uint64_t bytes = fs::file_size(destPath, ec); std::printf("Imported %s -> %s\n", srcPng.c_str(), destPath.c_str()); std::printf(" bytes : %llu\n", static_cast<unsigned long long>(bytes)); std::printf(" next : --add-texture-to-mesh <wom-base> %s\n", destPath.c_str()); return 0; } else if (std::strcmp(argv[i], "--repair-zone") == 0 && i + 1 < argc) { // Auto-fix the common manifest-vs-disk drift issues that // accumulate when a zone is hand-edited or partially copied: // - WHM/WOT files exist on disk but tile not in manifest // -> add to tiles // - manifest hasCreatures=false but creatures.json exists // and is non-empty -> set true // - manifest hasCreatures=true but no creatures.json or // empty -> clear false // // Tiles in manifest with NO disk files are NOT auto-removed // (they may indicate work-in-progress); they're warned about // so the user can decide. // // --dry-run flag previews changes without writing. std::string zoneDir = argv[++i]; bool dryRun = false; if (i + 1 < argc && std::strcmp(argv[i + 1], "--dry-run") == 0) { dryRun = true; i++; } namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "repair-zone: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "repair-zone: parse failed\n"); return 1; } int fixes = 0, warnings = 0; // Pass 1: scan disk for WHM files matching mapName_X_Y.whm // pattern. Match against manifest tiles. Anything on disk // but missing from manifest gets queued for addition. std::set<std::pair<int,int>> manifestTiles( zm.tiles.begin(), zm.tiles.end()); std::set<std::pair<int,int>> diskTiles; std::error_code ec; for (const auto& e : fs::directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; std::string name = e.path().filename().string(); if (e.path().extension() != ".whm") continue; // Expect "<mapName>_TX_TY.whm". Parse out the two // integers between the last two underscores. std::string stem = name.substr(0, name.size() - 4); std::string prefix = zm.mapName + "_"; if (stem.size() <= prefix.size() || stem.substr(0, prefix.size()) != prefix) { continue; // doesn't match map slug } std::string coords = stem.substr(prefix.size()); auto under = coords.find('_'); if (under == std::string::npos) continue; try { int tx = std::stoi(coords.substr(0, under)); int ty = std::stoi(coords.substr(under + 1)); diskTiles.insert({tx, ty}); } catch (...) {} } // Tiles on disk but not in manifest -> add. std::vector<std::pair<int,int>> toAdd; for (const auto& d : diskTiles) { if (manifestTiles.count(d) == 0) toAdd.push_back(d); } for (const auto& [tx, ty] : toAdd) { std::printf(" %s tile (%d, %d) to manifest\n", dryRun ? "would add" : "added", tx, ty); if (!dryRun) zm.tiles.push_back({tx, ty}); fixes++; } // Tiles in manifest but no .whm on disk -> warn (not auto-removed). for (const auto& m : manifestTiles) { if (diskTiles.count(m) == 0) { std::printf(" WARN: tile (%d, %d) in manifest but no %s_%d_%d.whm on disk\n", m.first, m.second, zm.mapName.c_str(), m.first, m.second); warnings++; } } // hasCreatures flag sync. bool creaturesPresent = false; wowee::editor::NpcSpawner sp; if (sp.loadFromFile(zoneDir + "/creatures.json") && sp.spawnCount() > 0) { creaturesPresent = true; } if (zm.hasCreatures != creaturesPresent) { std::printf(" %s hasCreatures: %s -> %s\n", dryRun ? "would set" : "set", zm.hasCreatures ? "true" : "false", creaturesPresent ? "true" : "false"); if (!dryRun) zm.hasCreatures = creaturesPresent; fixes++; } if (!dryRun && fixes > 0) { if (!zm.save(manifestPath)) { std::fprintf(stderr, "repair-zone: failed to write %s\n", manifestPath.c_str()); return 1; } } std::printf("\nrepair-zone: %s%s\n", zoneDir.c_str(), dryRun ? " (dry-run)" : ""); std::printf(" fixes : %d\n", fixes); std::printf(" warnings : %d (manual decision needed)\n", warnings); if (dryRun && fixes > 0) { std::printf(" re-run without --dry-run to apply\n"); } return 0; } else if (std::strcmp(argv[i], "--repair-project") == 0 && i + 1 < argc) { // Project-wide wrapper around --repair-zone. Spawns the // binary per-zone so each zone's full repair report // streams through, then aggregates a final tally. Honors // --dry-run for safe previews. std::string projectDir = argv[++i]; bool dryRun = false; if (i + 1 < argc && std::strcmp(argv[i + 1], "--dry-run") == 0) { dryRun = true; i++; } namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "repair-project: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); std::string self = argv[0]; int totalFailed = 0; std::printf("repair-project: %s%s\n", projectDir.c_str(), dryRun ? " (dry-run)" : ""); std::printf(" zones : %zu\n", zones.size()); for (const auto& zoneDir : zones) { std::printf("\n--- %s ---\n", fs::path(zoneDir).filename().string().c_str()); // Flush so the section marker lands before the spawned // child's stdout — std::system inherits FDs but each // process has its own buffer. std::fflush(stdout); std::string cmd = "\"" + self + "\" --repair-zone \"" + zoneDir + "\"" + (dryRun ? " --dry-run" : ""); int rc = std::system(cmd.c_str()); if (rc != 0) totalFailed++; } std::printf("\n--- summary ---\n"); std::printf(" zones processed : %zu\n", zones.size()); std::printf(" failures : %d\n", totalFailed); if (dryRun) { std::printf(" re-run without --dry-run to apply changes\n"); } return totalFailed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--gen-makefile") == 0 && i + 1 < argc) { // Generate a Makefile that rebuilds every derived output for // a zone. With this in place, designers can `make` to refresh // glb/obj/stl/html/csv/md from sources after editing // creatures.json or terrain — without remembering which // wowee_editor flag does what. The Makefile uses dependency // tracking so only stale outputs get rebuilt. std::string zoneDir = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "gen-makefile: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "gen-makefile: parse failed\n"); return 1; } if (outPath.empty()) outPath = zoneDir + "/Makefile"; std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "gen-makefile: cannot write %s\n", outPath.c_str()); return 1; } // Use a single absolute editor path so the Makefile works // from any cwd (running `make -C custom_zones/MyZone` etc.). std::error_code ec; std::string editorBin = fs::canonical("/proc/self/exe", ec).string(); if (ec || editorBin.empty()) editorBin = "wowee_editor"; // Per-tile WHM/WOT inputs feed the bake targets. Compose the // list once so all targets share the same dep set. std::string tileWhmDeps; for (const auto& [tx, ty] : zm.tiles) { tileWhmDeps += " " + zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty) + ".whm"; } std::string slug = zm.mapName; out << "# Generated by wowee_editor --gen-makefile\n" "# Zone: " << slug << "\n" "# Run from this directory: `make` to rebuild all\n" "# derived outputs from sources, `make clean` to wipe.\n\n"; out << "EDITOR := " << editorBin << "\n"; out << "ZONE := .\n\n"; // Source dep aggregations for content-derived outputs. out << "CONTENT_SRCS := zone.json $(wildcard creatures.json) " "$(wildcard objects.json) $(wildcard quests.json)\n"; out << "TERRAIN_SRCS := zone.json" << tileWhmDeps << "\n\n"; out << ".PHONY: all clean glb obj stl html docs csv graph\n\n"; out << "all: glb obj stl html docs csv graph\n\n"; // Each target lists its dependencies so make can skip already- // up-to-date outputs. out << "glb: " << slug << ".glb\n" << slug << ".glb: $(TERRAIN_SRCS)\n" << "\t$(EDITOR) --bake-zone-glb $(ZONE)\n\n"; out << "obj: " << slug << ".obj\n" << slug << ".obj: $(TERRAIN_SRCS)\n" << "\t$(EDITOR) --bake-zone-obj $(ZONE)\n\n"; out << "stl: " << slug << ".stl\n" << slug << ".stl: $(TERRAIN_SRCS)\n" << "\t$(EDITOR) --bake-zone-stl $(ZONE)\n\n"; out << "html: " << slug << ".html\n" << slug << ".html: " << slug << ".glb\n" << "\t$(EDITOR) --export-zone-html $(ZONE)\n\n"; out << "docs: ZONE.md DEPS.md\n"; out << "ZONE.md: $(CONTENT_SRCS)\n" << "\t$(EDITOR) --export-zone-summary-md $(ZONE)\n"; out << "DEPS.md: zone.json $(wildcard objects.json) $(wildcard *.wob)\n" << "\t$(EDITOR) --export-zone-deps-md $(ZONE)\n\n"; // CSV + graph targets use '-' prefix so missing-content // (zone without creatures/quests) doesn't fail the whole // 'make all'. The editor prints the error to stderr; make // continues with the next target. out << "csv:\n" << "\t-$(EDITOR) --export-zone-csv $(ZONE)\n\n"; out << "graph:\n" << "\t-$(EDITOR) --export-quest-graph $(ZONE)\n\n"; out << "clean:\n" << "\t$(EDITOR) --strip-zone $(ZONE)\n\n"; out << "validate:\n" << "\t$(EDITOR) --validate-all $(ZONE)\n"; out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" zone : %s\n", slug.c_str()); std::printf(" tiles : %zu (terrain dep)\n", zm.tiles.size()); std::printf(" next : cd %s && make\n", zoneDir.c_str()); return 0; } else if (std::strcmp(argv[i], "--gen-project-makefile") == 0 && i + 1 < argc) { // Top-level Makefile that delegates to per-zone Makefiles. // Pairs with --gen-makefile (per-zone): one project-level // command rebuilds every zone's derived outputs in parallel. // // wowee_editor --gen-project-makefile custom_zones // make -C custom_zones -j$(nproc) # all zones in parallel std::string projectDir = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "gen-project-makefile: %s is not a directory\n", projectDir.c_str()); return 1; } if (outPath.empty()) outPath = projectDir + "/Makefile"; // Find zones (dirs with zone.json). std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().filename().string()); } std::sort(zones.begin(), zones.end()); if (zones.empty()) { std::fprintf(stderr, "gen-project-makefile: no zones found in %s\n", projectDir.c_str()); return 1; } std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "gen-project-makefile: cannot write %s\n", outPath.c_str()); return 1; } std::error_code ec; std::string editorBin = fs::canonical("/proc/self/exe", ec).string(); if (ec || editorBin.empty()) editorBin = "wowee_editor"; out << "# Generated by wowee_editor --gen-project-makefile\n" "# Project: " << projectDir << " (" << zones.size() << " zones)\n" "# Run from this dir: `make` to rebuild all zone outputs.\n" "# Pass -j to make for parallel zone builds across cores.\n\n"; out << "EDITOR := " << editorBin << "\n\n"; out << "ZONES := "; for (const auto& z : zones) out << z << " "; out << "\n\n"; out << ".PHONY: all clean validate index html stats $(addsuffix -bake,$(ZONES)) " "$(addsuffix -clean,$(ZONES)) $(addsuffix -validate,$(ZONES))\n\n"; // Aggregate phony targets: 'make' rebuilds all zones; 'make // ZONE-bake' targets just one. The per-zone Makefile must // exist (regenerate via --gen-makefile if not). out << "all: $(addsuffix -bake,$(ZONES)) index\n\n"; for (const auto& z : zones) { out << z << "-bake:\n"; out << "\t@if [ ! -f " << z << "/Makefile ]; then \\\n" << "\t $(EDITOR) --gen-makefile " << z << " >/dev/null; fi\n"; out << "\t$(MAKE) -C " << z << " all\n\n"; out << z << "-clean:\n"; out << "\t-$(EDITOR) --strip-zone " << z << "\n\n"; out << z << "-validate:\n"; out << "\t$(EDITOR) --validate-all " << z << "\n\n"; } // Top-level utility targets. out << "clean: $(addsuffix -clean,$(ZONES))\n\n"; out << "validate: $(addsuffix -validate,$(ZONES))\n\n"; out << "index:\n" << "\t$(EDITOR) --export-project-html .\n\n"; out << "stats:\n" << "\t$(EDITOR) --zone-stats .\n\n"; out << "tilemap:\n" << "\t$(EDITOR) --info-tilemap .\n"; out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" %zu zone(s) wired up\n", zones.size()); std::printf(" next: make -C %s -j$(nproc)\n", projectDir.c_str()); return 0; } else if (std::strcmp(argv[i], "--list-zones") == 0) { // Optional --json after the flag for machine-readable output. bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; auto zones = wowee::pipeline::CustomZoneDiscovery::scan({"custom_zones", "output"}); if (jsonOut) { nlohmann::json j = nlohmann::json::array(); for (const auto& z : zones) { nlohmann::json zoneObj; zoneObj["name"] = z.name; zoneObj["directory"] = z.directory; zoneObj["mapId"] = z.mapId; zoneObj["author"] = z.author; zoneObj["description"] = z.description; zoneObj["hasCreatures"] = z.hasCreatures; zoneObj["hasQuests"] = z.hasQuests; nlohmann::json tiles = nlohmann::json::array(); for (const auto& t : z.tiles) tiles.push_back({t.first, t.second}); zoneObj["tiles"] = tiles; j.push_back(std::move(zoneObj)); } std::printf("%s\n", j.dump(2).c_str()); return 0; } if (zones.empty()) { std::printf("No custom zones found in custom_zones/ or output/\n"); } else { std::printf("Custom zones found:\n"); for (const auto& z : zones) { std::printf(" %s — %s%s%s\n", z.name.c_str(), z.directory.c_str(), z.hasCreatures ? " [NPCs]" : "", z.hasQuests ? " [Quests]" : ""); } } return 0; } else if (std::strcmp(argv[i], "--zone-stats") == 0 && i + 1 < argc) { // Multi-zone aggregator. Walks <projectDir> for every dir // with a zone.json and emits totals across the project: // tile counts, creature/object/quest counts, on-disk byte // sizes per format. Useful for content-pack release notes // and capacity planning. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "zone-stats: %s is not a directory\n", projectDir.c_str()); return 1; } // Collect zone dirs. std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (fs::exists(entry.path() / "zone.json")) { zones.push_back(entry.path().string()); } } std::sort(zones.begin(), zones.end()); // Aggregate. struct Totals { int zoneCount = 0; int tileCount = 0; int creatures = 0, objects = 0, quests = 0; int hostileCreatures = 0; int chainedQuests = 0; uint64_t totalXp = 0; uint64_t whmBytes = 0, wotBytes = 0, wocBytes = 0; uint64_t womBytes = 0, wobBytes = 0; uint64_t pngBytes = 0, jsonBytes = 0; uint64_t otherBytes = 0; } T; T.zoneCount = static_cast<int>(zones.size()); // Per-zone breakdown for the table view (kept short — not // every field, just the high-signal ones). struct ZoneRow { std::string name; int tiles = 0, creatures = 0, objects = 0, quests = 0; uint64_t bytes = 0; }; std::vector<ZoneRow> rows; for (const auto& zoneDir : zones) { wowee::editor::ZoneManifest zm; if (!zm.load(zoneDir + "/zone.json")) continue; wowee::editor::NpcSpawner sp; sp.loadFromFile(zoneDir + "/creatures.json"); wowee::editor::ObjectPlacer op; op.loadFromFile(zoneDir + "/objects.json"); wowee::editor::QuestEditor qe; qe.loadFromFile(zoneDir + "/quests.json"); ZoneRow row; row.name = zm.mapName.empty() ? fs::path(zoneDir).filename().string() : zm.mapName; row.tiles = static_cast<int>(zm.tiles.size()); row.creatures = static_cast<int>(sp.spawnCount()); row.objects = static_cast<int>(op.getObjects().size()); row.quests = static_cast<int>(qe.questCount()); T.tileCount += row.tiles; T.creatures += row.creatures; T.objects += row.objects; T.quests += row.quests; for (const auto& s : sp.getSpawns()) { if (s.hostile) T.hostileCreatures++; } for (const auto& q : qe.getQuests()) { if (q.nextQuestId != 0) T.chainedQuests++; T.totalXp += q.reward.xp; } // Walk on-disk files in the zone (recursive — sub-dirs // like data/ may hold sidecars). Bucket by extension. std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; uint64_t sz = e.file_size(ec); if (ec) continue; row.bytes += sz; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); if (ext == ".whm") T.whmBytes += sz; else if (ext == ".wot") T.wotBytes += sz; else if (ext == ".woc") T.wocBytes += sz; else if (ext == ".wom") T.womBytes += sz; else if (ext == ".wob") T.wobBytes += sz; else if (ext == ".png") T.pngBytes += sz; else if (ext == ".json") T.jsonBytes += sz; else T.otherBytes += sz; } rows.push_back(row); } uint64_t totalBytes = T.whmBytes + T.wotBytes + T.wocBytes + T.womBytes + T.wobBytes + T.pngBytes + T.jsonBytes + T.otherBytes; if (jsonOut) { nlohmann::json j; j["projectDir"] = projectDir; j["zoneCount"] = T.zoneCount; j["tileCount"] = T.tileCount; j["creatures"] = T.creatures; j["hostileCreatures"] = T.hostileCreatures; j["objects"] = T.objects; j["quests"] = T.quests; j["chainedQuests"] = T.chainedQuests; j["totalXp"] = T.totalXp; j["bytes"] = { {"whm", T.whmBytes}, {"wot", T.wotBytes}, {"woc", T.wocBytes}, {"wom", T.womBytes}, {"wob", T.wobBytes}, {"png", T.pngBytes}, {"json", T.jsonBytes}, {"other", T.otherBytes}, {"total", totalBytes} }; nlohmann::json zarr = nlohmann::json::array(); for (const auto& r : rows) { zarr.push_back({ {"name", r.name}, {"tiles", r.tiles}, {"creatures", r.creatures}, {"objects", r.objects}, {"quests", r.quests}, {"bytes", r.bytes} }); } j["zones"] = zarr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone stats: %s\n", projectDir.c_str()); std::printf(" zones : %d\n", T.zoneCount); std::printf(" tiles : %d total\n", T.tileCount); std::printf(" creatures : %d (%d hostile)\n", T.creatures, T.hostileCreatures); std::printf(" objects : %d\n", T.objects); std::printf(" quests : %d (%d chained, %llu total XP)\n", T.quests, T.chainedQuests, static_cast<unsigned long long>(T.totalXp)); constexpr double kKB = 1024.0; std::printf(" bytes : %.1f KB total\n", totalBytes / kKB); std::printf(" whm/wot : %.1f KB / %.1f KB\n", T.whmBytes / kKB, T.wotBytes / kKB); std::printf(" woc : %.1f KB\n", T.wocBytes / kKB); std::printf(" wom/wob : %.1f KB / %.1f KB\n", T.womBytes / kKB, T.wobBytes / kKB); std::printf(" png/json : %.1f KB / %.1f KB\n", T.pngBytes / kKB, T.jsonBytes / kKB); if (T.otherBytes > 0) { std::printf(" other : %.1f KB\n", T.otherBytes / kKB); } std::printf("\n per-zone breakdown:\n"); std::printf(" name tiles creat obj quest bytes\n"); for (const auto& r : rows) { std::printf(" %-18s %5d %5d %3d %5d %7.1f KB\n", r.name.substr(0, 18).c_str(), r.tiles, r.creatures, r.objects, r.quests, r.bytes / kKB); } return 0; } else if (std::strcmp(argv[i], "--info-tilemap") == 0 && i + 1 < argc) { // Visualize the WoW 64x64 ADT grid showing which tiles are // claimed by which zones across a project. Useful for // spotting tile-coord collisions before two zones try to // ship overlapping content, and for getting a 'where am I // working?' overview of a multi-zone project. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "info-tilemap: %s is not a directory\n", projectDir.c_str()); return 1; } // Map (tx, ty) -> vector<zone names> so collision overlaps // are visible. Walk every zone in the project. std::map<std::pair<int,int>, std::vector<std::string>> claims; std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; wowee::editor::ZoneManifest zm; if (!zm.load((entry.path() / "zone.json").string())) continue; std::string zname = zm.mapName.empty() ? entry.path().filename().string() : zm.mapName; zones.push_back(zname); for (const auto& [tx, ty] : zm.tiles) { if (tx >= 0 && tx < 64 && ty >= 0 && ty < 64) { claims[{tx, ty}].push_back(zname); } } } // Per-zone label glyph: first letter of the zone name, // uppercased so different zones get distinct chars in the // grid. Multi-letter overlap collapses to '*'. std::map<std::string, char> zoneGlyph; char nextGlyph = 'A'; for (const auto& z : zones) { if (zoneGlyph.count(z)) continue; if (!z.empty() && std::isalpha(static_cast<unsigned char>(z[0]))) { zoneGlyph[z] = static_cast<char>(std::toupper(static_cast<unsigned char>(z[0]))); } else { zoneGlyph[z] = nextGlyph++; if (nextGlyph > 'Z') nextGlyph = 'a'; } } int collisions = 0; for (const auto& [coord, owners] : claims) { if (owners.size() > 1) collisions++; } if (jsonOut) { nlohmann::json j; j["projectDir"] = projectDir; j["zoneCount"] = zones.size(); j["claimedTiles"] = claims.size(); j["collisions"] = collisions; nlohmann::json claimsJson = nlohmann::json::array(); for (const auto& [coord, owners] : claims) { claimsJson.push_back({{"x", coord.first}, {"y", coord.second}, {"zones", owners}}); } j["claims"] = claimsJson; std::printf("%s\n", j.dump(2).c_str()); return collisions == 0 ? 0 : 1; } std::printf("Tilemap: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" tiles used : %zu\n", claims.size()); std::printf(" collisions : %d (multiple zones claiming same tile)\n", collisions); std::printf(" legend :"); for (const auto& [name, glyph] : zoneGlyph) { std::printf(" %c=%s", glyph, name.c_str()); } std::printf("\n\n"); // Render 64x64 grid. Print column header in groups of 10 // for readability. std::printf(" "); for (int x = 0; x < 64; ++x) { std::printf("%c", (x % 10 == 0) ? '0' + (x / 10) : ' '); } std::printf("\n"); std::printf(" "); for (int x = 0; x < 64; ++x) std::printf("%d", x % 10); std::printf("\n"); for (int y = 0; y < 64; ++y) { // Skip rows that have no tiles claimed — keeps the // output bounded for projects in one corner of the map. bool rowHasContent = false; for (int x = 0; x < 64 && !rowHasContent; ++x) { if (claims.count({x, y})) rowHasContent = true; } if (!rowHasContent) continue; std::printf(" y=%2d ", y); for (int x = 0; x < 64; ++x) { auto it = claims.find({x, y}); if (it == claims.end()) { std::printf("."); } else if (it->second.size() > 1) { std::printf("*"); // collision } else { std::printf("%c", zoneGlyph[it->second[0]]); } } std::printf("\n"); } if (collisions > 0) { std::printf("\n COLLISIONS:\n"); for (const auto& [coord, owners] : claims) { if (owners.size() < 2) continue; std::printf(" (%d, %d) claimed by:", coord.first, coord.second); for (const auto& o : owners) std::printf(" %s", o.c_str()); std::printf("\n"); } } return collisions == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--list-zone-deps") == 0 && i + 1 < argc) { // Enumerate every external model path a zone references — // both directly placed (objects.json) and indirectly via // doodad placements inside any WOB sitting next to the // zone manifest. Useful when packaging a content pack to // confirm every needed asset will ship. std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(zoneDir + "/zone.json")) { std::fprintf(stderr, "list-zone-deps: %s has no zone.json\n", zoneDir.c_str()); return 1; } // Collect with usage counts so duplicates report '×N' instead // of cluttering the table. std::map<std::string, int> directM2; // m2 placements std::map<std::string, int> directWMO; // wmo placements std::map<std::string, int> doodadM2; // m2s referenced inside WOBs wowee::editor::ObjectPlacer op; if (op.loadFromFile(zoneDir + "/objects.json")) { for (const auto& o : op.getObjects()) { if (o.type == wowee::editor::PlaceableType::M2) directM2[o.path]++; else if (o.type == wowee::editor::PlaceableType::WMO) directWMO[o.path]++; } } // Walk WOBs in the zone directory recursively and pull in // their doodad model paths. Sub-dirs caught too in case the // user organizes buildings under a buildings/ subfolder. int wobCount = 0; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); if (ext != ".wob") continue; wobCount++; std::string base = e.path().string(); if (base.size() >= 4) base = base.substr(0, base.size() - 4); auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); for (const auto& d : bld.doodads) { if (d.modelPath.empty()) continue; doodadM2[d.modelPath]++; } } // For each direct WMO placement, also recurse into the WOB // sitting at that path (relative to the zone) so transitive // doodad deps surface — this matches the runtime's actual // load chain. for (const auto& [path, count] : directWMO) { // Strip extension since loader takes a base path. std::string base = path; if (base.size() >= 4 && base.substr(base.size() - 4) == ".wmo") base = base.substr(0, base.size() - 4); // Try relative-to-zone first, then absolute. std::string trial = zoneDir + "/" + base; if (!wowee::pipeline::WoweeBuildingLoader::exists(trial)) trial = base; if (!wowee::pipeline::WoweeBuildingLoader::exists(trial)) continue; auto bld = wowee::pipeline::WoweeBuildingLoader::load(trial); for (const auto& d : bld.doodads) { if (d.modelPath.empty()) continue; doodadM2[d.modelPath]++; } } size_t totalUnique = directM2.size() + directWMO.size() + doodadM2.size(); if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["wobCount"] = wobCount; j["totalUnique"] = totalUnique; auto toArr = [](const std::map<std::string, int>& m) { nlohmann::json a = nlohmann::json::array(); for (const auto& [path, count] : m) { a.push_back({{"path", path}, {"count", count}}); } return a; }; j["directM2"] = toArr(directM2); j["directWMO"] = toArr(directWMO); j["doodadM2"] = toArr(doodadM2); std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone deps: %s\n", zoneDir.c_str()); std::printf(" WOBs scanned : %d\n", wobCount); std::printf(" unique paths total : %zu\n", totalUnique); auto emit = [](const char* tag, const std::map<std::string, int>& m) { std::printf("\n %s (%zu unique):\n", tag, m.size()); if (m.empty()) { std::printf(" *none*\n"); return; } for (const auto& [path, count] : m) { if (count > 1) std::printf(" %s ×%d\n", path.c_str(), count); else std::printf(" %s\n", path.c_str()); } }; emit("Direct M2 placements", directM2); emit("Direct WMO placements", directWMO); emit("WOB doodad M2 refs", doodadM2); return 0; } else if (std::strcmp(argv[i], "--list-project-orphans") == 0 && i + 1 < argc) { // Inverse of --list-zone-deps. Walks every zone in // <projectDir>, collects the set of .wom/.wob files // sitting on disk and the set of paths actually // referenced by objects.json placements + WOB doodad // lists. Files in the first set but not the second are // orphans — candidates for removal before --pack-wcp so // the archive doesn't carry dead weight. // // Comparison is by basename (extension stripped) since // the reference paths sometimes include the extension and // sometimes don't. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "list-project-orphans: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); // Project-wide reference set. Normalize by stripping // extension and any leading "./". auto normalize = [](std::string p) { while (p.size() >= 2 && p[0] == '.' && p[1] == '/') p.erase(0, 2); std::string ext = fs::path(p).extension().string(); if (ext == ".wom" || ext == ".wob" || ext == ".m2" || ext == ".wmo") { p = p.substr(0, p.size() - ext.size()); } return p; }; std::set<std::string> referencedBases; // normalized basenames for (const auto& zoneDir : zones) { wowee::editor::ObjectPlacer op; if (op.loadFromFile(zoneDir + "/objects.json")) { for (const auto& o : op.getObjects()) { if (o.path.empty()) continue; // Reference can be relative to zone or just a // bare model name; record both forms for the // membership test. std::string norm = normalize(o.path); referencedBases.insert(norm); // Also try the leaf basename so unqualified // refs match. referencedBases.insert(fs::path(norm).filename().string()); } } std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; if (e.path().extension() != ".wob") continue; std::string base = e.path().string(); if (base.size() >= 4) base = base.substr(0, base.size() - 4); auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); for (const auto& d : bld.doodads) { if (d.modelPath.empty()) continue; std::string norm = normalize(d.modelPath); referencedBases.insert(norm); referencedBases.insert(fs::path(norm).filename().string()); } } } // Now walk every zone again and flag orphan .wom/.wob files. struct Orphan { std::string zone, path; uint64_t bytes; }; std::vector<Orphan> orphans; uint64_t totalOrphanBytes = 0; for (const auto& zoneDir : zones) { std::string zoneName = fs::path(zoneDir).filename().string(); std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); if (ext != ".wom" && ext != ".wob") continue; std::string rel = fs::relative(e.path(), zoneDir, ec).string(); if (ec) rel = e.path().filename().string(); std::string normRel = rel.substr(0, rel.size() - ext.size()); std::string leaf = e.path().stem().string(); if (referencedBases.count(normRel) || referencedBases.count(leaf)) { continue; // referenced, not orphan } uint64_t sz = e.file_size(ec); if (ec) sz = 0; orphans.push_back({zoneName, rel, sz}); totalOrphanBytes += sz; } } std::sort(orphans.begin(), orphans.end(), [](const Orphan& a, const Orphan& b) { if (a.zone != b.zone) return a.zone < b.zone; return a.path < b.path; }); if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["referencedCount"] = referencedBases.size(); j["orphanCount"] = orphans.size(); j["orphanBytes"] = totalOrphanBytes; nlohmann::json arr = nlohmann::json::array(); for (const auto& o : orphans) { arr.push_back({{"zone", o.zone}, {"path", o.path}, {"bytes", o.bytes}}); } j["orphans"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project orphans: %s\n", projectDir.c_str()); std::printf(" zones scanned : %zu\n", zones.size()); std::printf(" refs collected : %zu (normalized basenames)\n", referencedBases.size()); std::printf(" orphan .wom/.wob : %zu file(s), %.1f KB\n", orphans.size(), totalOrphanBytes / 1024.0); if (orphans.empty()) { std::printf("\n (no orphans — every model file is referenced)\n"); return 0; } std::printf("\n zone bytes path\n"); for (const auto& o : orphans) { std::printf(" %-20s %8llu %s\n", o.zone.substr(0, 20).c_str(), static_cast<unsigned long long>(o.bytes), o.path.c_str()); } return 0; } else if (std::strcmp(argv[i], "--remove-project-orphans") == 0 && i + 1 < argc) { // Destructive companion to --list-project-orphans. Reuses // the same reference-collection + orphan-detection logic // and then deletes the resulting files. --dry-run shows // what would be removed without touching anything. std::string projectDir = argv[++i]; bool dryRun = false; if (i + 1 < argc && std::strcmp(argv[i + 1], "--dry-run") == 0) { dryRun = true; i++; } namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "remove-project-orphans: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); // Same normalize + reference collection as --list-project-orphans. // Keep both functions in sync if the matching rules evolve. auto normalize = [](std::string p) { while (p.size() >= 2 && p[0] == '.' && p[1] == '/') p.erase(0, 2); std::string ext = fs::path(p).extension().string(); if (ext == ".wom" || ext == ".wob" || ext == ".m2" || ext == ".wmo") { p = p.substr(0, p.size() - ext.size()); } return p; }; std::set<std::string> referencedBases; for (const auto& zoneDir : zones) { wowee::editor::ObjectPlacer op; if (op.loadFromFile(zoneDir + "/objects.json")) { for (const auto& o : op.getObjects()) { if (o.path.empty()) continue; std::string norm = normalize(o.path); referencedBases.insert(norm); referencedBases.insert(fs::path(norm).filename().string()); } } std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; if (e.path().extension() != ".wob") continue; std::string base = e.path().string(); if (base.size() >= 4) base = base.substr(0, base.size() - 4); auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); for (const auto& d : bld.doodads) { if (d.modelPath.empty()) continue; std::string norm = normalize(d.modelPath); referencedBases.insert(norm); referencedBases.insert(fs::path(norm).filename().string()); } } } int removed = 0, failed = 0; uint64_t freedBytes = 0; for (const auto& zoneDir : zones) { std::string zoneName = fs::path(zoneDir).filename().string(); std::error_code ec; std::vector<fs::path> toRemove; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); if (ext != ".wom" && ext != ".wob") continue; std::string rel = fs::relative(e.path(), zoneDir, ec).string(); if (ec) rel = e.path().filename().string(); std::string normRel = rel.substr(0, rel.size() - ext.size()); std::string leaf = e.path().stem().string(); if (referencedBases.count(normRel) || referencedBases.count(leaf)) continue; toRemove.push_back(e.path()); } // Materialize the deletion list before removing so we // don't mutate the directory while iterating. for (const auto& p : toRemove) { uint64_t sz = fs::file_size(p, ec); if (ec) sz = 0; std::string rel = fs::relative(p, zoneDir, ec).string(); if (ec) rel = p.filename().string(); if (dryRun) { std::printf(" would remove: %s/%s (%llu bytes)\n", zoneName.c_str(), rel.c_str(), static_cast<unsigned long long>(sz)); removed++; freedBytes += sz; } else { if (fs::remove(p, ec)) { std::printf(" removed: %s/%s (%llu bytes)\n", zoneName.c_str(), rel.c_str(), static_cast<unsigned long long>(sz)); removed++; freedBytes += sz; } else { std::fprintf(stderr, " WARN: failed to remove %s (%s)\n", p.c_str(), ec.message().c_str()); failed++; } } } } std::printf("\nremove-project-orphans: %s%s\n", projectDir.c_str(), dryRun ? " (dry-run)" : ""); std::printf(" zones : %zu\n", zones.size()); std::printf(" refs : %zu (normalized basenames)\n", referencedBases.size()); std::printf(" %s : %d file(s)\n", dryRun ? "would remove" : "removed ", removed); std::printf(" freed : %.1f KB\n", freedBytes / 1024.0); if (failed > 0) { std::printf(" FAILED : %d (see stderr)\n", failed); } if (dryRun && removed > 0) { std::printf(" re-run without --dry-run to apply\n"); } return failed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--export-zone-deps-md") == 0 && i + 1 < argc) { // Markdown counterpart to --list-zone-deps. Writes a sortable // GitHub-rendered table of every external model the zone // references plus on-disk presence (so PR reviewers see at a // glance whether dependencies are accounted for in the // accompanying asset bundle). std::string zoneDir = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(zoneDir + "/zone.json")) { std::fprintf(stderr, "export-zone-deps-md: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; zm.load(zoneDir + "/zone.json"); if (outPath.empty()) outPath = zoneDir + "/DEPS.md"; // Same dep-collection pass as --list-zone-deps. std::map<std::string, int> directM2; std::map<std::string, int> directWMO; std::map<std::string, int> doodadM2; wowee::editor::ObjectPlacer op; if (op.loadFromFile(zoneDir + "/objects.json")) { for (const auto& o : op.getObjects()) { if (o.type == wowee::editor::PlaceableType::M2) directM2[o.path]++; else if (o.type == wowee::editor::PlaceableType::WMO) directWMO[o.path]++; } } int wobCount = 0; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file() || e.path().extension() != ".wob") continue; wobCount++; std::string base = e.path().string(); if (base.size() >= 4) base = base.substr(0, base.size() - 4); auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); for (const auto& d : bld.doodads) { if (!d.modelPath.empty()) doodadM2[d.modelPath]++; } } // Resolve dep on disk. Same heuristic as --check-zone-refs: // try both open + proprietary in conventional roots. auto stripExt = [](const std::string& p, const char* ext) { size_t n = std::strlen(ext); if (p.size() >= n) { std::string tail = p.substr(p.size() - n); std::string lower = tail; for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c)); if (lower == ext) return p.substr(0, p.size() - n); } return p; }; auto resolveStatus = [&](const std::string& path, bool isWMO) { std::string base, openExt, propExt; if (isWMO) { base = stripExt(path, ".wmo"); openExt = ".wob"; propExt = ".wmo"; } else { base = stripExt(path, ".m2"); openExt = ".wom"; propExt = ".m2"; } std::vector<std::string> roots = { "", zoneDir + "/", "output/", "custom_zones/", "Data/" }; bool hasOpen = false, hasProp = false; for (const auto& root : roots) { if (fs::exists(root + base + openExt)) hasOpen = true; if (fs::exists(root + base + propExt)) hasProp = true; } if (hasOpen && hasProp) return "open + proprietary"; if (hasOpen) return "open only"; if (hasProp) return "proprietary only"; return "MISSING"; }; std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-zone-deps-md: cannot write %s\n", outPath.c_str()); return 1; } out << "# Dependencies — " << (zm.displayName.empty() ? zm.mapName : zm.displayName) << "\n\n"; out << "*Auto-generated by `wowee_editor --export-zone-deps-md`. " "Status is best-effort — checks zone-local, output/, " "custom_zones/, Data/ roots in that order.*\n\n"; auto emitTable = [&](const char* heading, const std::map<std::string,int>& m, bool isWMO) { out << "## " << heading << " (" << m.size() << ")\n\n"; if (m.empty()) { out << "*None.*\n\n"; return; } out << "| Refs | Path | Status |\n"; out << "|---:|---|---|\n"; for (const auto& [path, count] : m) { out << "| " << count << " | `" << path << "` | " << resolveStatus(path, isWMO) << " |\n"; } out << "\n"; }; emitTable("Direct M2 placements", directM2, false); emitTable("Direct WMO placements", directWMO, true); emitTable("WOB doodad M2 refs", doodadM2, false); out << "## Summary\n\n"; out << "- Zone: `" << zm.mapName << "`\n"; out << "- WOBs scanned: " << wobCount << "\n"; out << "- Unique dependencies: " << directM2.size() + directWMO.size() + doodadM2.size() << "\n"; out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" %zu M2 placements, %zu WMO placements, %zu WOB doodad refs\n", directM2.size(), directWMO.size(), doodadM2.size()); return 0; } else if (std::strcmp(argv[i], "--export-zone-spawn-png") == 0 && i + 1 < argc) { // Top-down PNG of spawn positions colored by type. Bound by // the zone's tile range so the image is properly framed. // Useful for design review (does the spawn distribution // match the intended encounter design?) and for showing // collaborators 'where are the mobs'. std::string zoneDir = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "export-zone-spawn-png: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "export-zone-spawn-png: parse failed\n"); return 1; } if (zm.tiles.empty()) { std::fprintf(stderr, "export-zone-spawn-png: zone has no tiles\n"); return 1; } if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + "_spawns.png"; // Compute world-space bounds from manifest tiles. Same math // as --info-zone-extents. constexpr float kTileSize = 533.33333f; int tileMinX = 64, tileMaxX = -1; int tileMinY = 64, tileMaxY = -1; for (const auto& [tx, ty] : zm.tiles) { tileMinX = std::min(tileMinX, tx); tileMaxX = std::max(tileMaxX, tx); tileMinY = std::min(tileMinY, ty); tileMaxY = std::max(tileMaxY, ty); } float worldMinX = (32.0f - tileMaxY - 1) * kTileSize; float worldMaxX = (32.0f - tileMinY) * kTileSize; float worldMinY = (32.0f - tileMaxX - 1) * kTileSize; float worldMaxY = (32.0f - tileMinX) * kTileSize; // Image dimensions: 256px per tile so detail is visible // without inflating per-pixel cost. int tilesX = tileMaxY - tileMinY + 1; // tile.y maps to world.x int tilesY = tileMaxX - tileMinX + 1; const int kPxPerTile = 256; int imgW = tilesX * kPxPerTile; int imgH = tilesY * kPxPerTile; // Cap output size — 16-tile-wide projects shouldn't exceed // 4096 wide. Scale down if needed. int maxDim = std::max(imgW, imgH); if (maxDim > 4096) { int divisor = (maxDim + 4095) / 4096; imgW = std::max(64, imgW / divisor); imgH = std::max(64, imgH / divisor); } std::vector<uint8_t> img(imgW * imgH * 3, 32); // dark grey background // Tile-grid lines so the boundary is visible. for (int t = 1; t < tilesX; ++t) { int x = (t * imgW) / tilesX; if (x >= 0 && x < imgW) { for (int y = 0; y < imgH; ++y) { size_t off = (y * imgW + x) * 3; img[off] = img[off+1] = img[off+2] = 64; } } } for (int t = 1; t < tilesY; ++t) { int y = (t * imgH) / tilesY; if (y >= 0 && y < imgH) { for (int x = 0; x < imgW; ++x) { size_t off = (y * imgW + x) * 3; img[off] = img[off+1] = img[off+2] = 64; } } } // Plot spawn points. Map world (X, Y) to image (px, py): // px = (worldMaxX - X) / (worldMaxX - worldMinX) * imgW // py = (worldMaxY - Y) / (worldMaxY - worldMinY) * imgH // since +X world is north (up) and +Y world is west (left) // in WoW coords. float wRangeX = worldMaxX - worldMinX; float wRangeY = worldMaxY - worldMinY; auto plotPoint = [&](float wx, float wy, uint8_t r, uint8_t g, uint8_t b) { if (wRangeX <= 0 || wRangeY <= 0) return; int px = static_cast<int>((worldMaxX - wx) / wRangeX * imgW); int py = static_cast<int>((worldMaxY - wy) / wRangeY * imgH); // 3×3 dot. for (int dy = -1; dy <= 1; ++dy) { for (int dx = -1; dx <= 1; ++dx) { int x = px + dx, y = py + dy; if (x < 0 || x >= imgW || y < 0 || y >= imgH) continue; size_t off = (y * imgW + x) * 3; img[off] = r; img[off+1] = g; img[off+2] = b; } } }; // Creatures = red. wowee::editor::NpcSpawner sp; int creaturesPlotted = 0; if (sp.loadFromFile(zoneDir + "/creatures.json")) { for (const auto& s : sp.getSpawns()) { plotPoint(s.position.x, s.position.y, 220, 60, 60); creaturesPlotted++; } } // Objects = green (M2) / blue (WMO). wowee::editor::ObjectPlacer op; int objectsPlotted = 0; if (op.loadFromFile(zoneDir + "/objects.json")) { for (const auto& o : op.getObjects()) { if (o.type == wowee::editor::PlaceableType::M2) { plotPoint(o.position.x, o.position.y, 60, 200, 60); } else { plotPoint(o.position.x, o.position.y, 60, 120, 220); } objectsPlotted++; } } if (!stbi_write_png(outPath.c_str(), imgW, imgH, 3, img.data(), imgW * 3)) { std::fprintf(stderr, "export-zone-spawn-png: stbi_write_png failed\n"); return 1; } std::printf("Wrote %s\n", outPath.c_str()); std::printf(" %dx%d px, tile grid %dx%d, %d creatures (red), %d objects (green/blue)\n", imgW, imgH, tilesX, tilesY, creaturesPlotted, objectsPlotted); return 0; } else if (std::strcmp(argv[i], "--check-zone-refs") == 0 && i + 1 < argc) { // Cross-reference checker: every model path in objects.json // must resolve as either an open WOM/WOB sidecar or a // proprietary M2/WMO; every quest's giver/turnIn NPC ID must // appear in creatures.json (when the zone has creatures). // Catches dangling references that --validate doesn't, since // --validate only checks open-format file presence. std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(zoneDir + "/zone.json")) { std::fprintf(stderr, "check-zone-refs: %s has no zone.json\n", zoneDir.c_str()); return 1; } // Try to find a model on disk in any of the conventional // locations (zone-local, output/, custom_zones/, Data/). // Strips extension and tries each open + proprietary variant. auto stripExt = [](const std::string& p, const char* ext) { size_t n = std::strlen(ext); if (p.size() >= n) { std::string tail = p.substr(p.size() - n); std::string lower = tail; for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c)); if (lower == ext) return p.substr(0, p.size() - n); } return p; }; auto modelExists = [&](const std::string& path, bool isWMO) { std::string base; std::vector<std::string> exts; if (isWMO) { base = stripExt(path, ".wmo"); exts = {".wob", ".wmo"}; } else { base = stripExt(path, ".m2"); exts = {".wom", ".m2"}; } std::vector<std::string> roots = { "", zoneDir + "/", "output/", "custom_zones/", "Data/" }; for (const auto& root : roots) { for (const auto& ext : exts) { if (fs::exists(root + base + ext)) return true; // Case-fold fallback for case-sensitive filesystems // (designers usually type Mixed Case but Linux // stores asset paths lowercase after extraction). std::string lower = base + ext; for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c)); if (fs::exists(root + lower)) return true; } } return false; }; std::vector<std::string> errors; // Object placements -> models on disk wowee::editor::ObjectPlacer op; int objectsChecked = 0, objectsMissing = 0; if (op.loadFromFile(zoneDir + "/objects.json")) { for (size_t k = 0; k < op.getObjects().size(); ++k) { const auto& o = op.getObjects()[k]; objectsChecked++; bool isWMO = (o.type == wowee::editor::PlaceableType::WMO); if (!modelExists(o.path, isWMO)) { objectsMissing++; if (errors.size() < 30) { errors.push_back("object[" + std::to_string(k) + "] missing: " + o.path); } } } } // Quest NPCs -> creatures.json IDs (only when creatures exist; // otherwise NPC IDs may legitimately reference upstream content // outside the zone). wowee::editor::NpcSpawner sp; wowee::editor::QuestEditor qe; int questsChecked = 0, questsMissing = 0; bool hasCreatures = sp.loadFromFile(zoneDir + "/creatures.json"); std::unordered_set<uint32_t> creatureIds; if (hasCreatures) { for (const auto& s : sp.getSpawns()) creatureIds.insert(s.id); } if (qe.loadFromFile(zoneDir + "/quests.json") && hasCreatures) { for (size_t k = 0; k < qe.getQuests().size(); ++k) { const auto& q = qe.getQuests()[k]; questsChecked++; bool localGiver = (q.questGiverNpcId != 0 && creatureIds.count(q.questGiverNpcId) == 0); bool localTurn = (q.turnInNpcId != 0 && q.turnInNpcId != q.questGiverNpcId && creatureIds.count(q.turnInNpcId) == 0); // Only flag IDs that look 'small' (likely zone-local). // Production uses 6-digit IDs that reference upstream // content; designers wire those in deliberately. if (localGiver && q.questGiverNpcId < 100000) { questsMissing++; if (errors.size() < 30) { errors.push_back("quest[" + std::to_string(k) + "] '" + q.title + "' giver " + std::to_string(q.questGiverNpcId) + " not in creatures.json"); } } if (localTurn && q.turnInNpcId < 100000) { questsMissing++; if (errors.size() < 30) { errors.push_back("quest[" + std::to_string(k) + "] '" + q.title + "' turn-in " + std::to_string(q.turnInNpcId) + " not in creatures.json"); } } } } int totalErrors = objectsMissing + questsMissing; if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["objectsChecked"] = objectsChecked; j["objectsMissing"] = objectsMissing; j["questsChecked"] = questsChecked; j["questsMissing"] = questsMissing; j["errors"] = errors; j["passed"] = (totalErrors == 0); std::printf("%s\n", j.dump(2).c_str()); return totalErrors == 0 ? 0 : 1; } std::printf("Zone refs: %s\n", zoneDir.c_str()); std::printf(" objects checked : %d (%d missing)\n", objectsChecked, objectsMissing); std::printf(" quests checked : %d (%d bad NPC refs)\n", questsChecked, questsMissing); if (totalErrors == 0) { std::printf(" PASSED\n"); return 0; } std::printf(" FAILED — %d issue(s):\n", totalErrors); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; } else if (std::strcmp(argv[i], "--check-zone-content") == 0 && i + 1 < argc) { // Sanity-check creature/object/quest fields for plausible // values. --check-zone-refs catches dangling references; // this catches data-quality issues like creatures with 0 HP, // objects with negative scale, quests with no objectives. // Both are needed — a quest can have valid NPC IDs (refs OK) // AND no objectives (content broken). std::string zoneDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(zoneDir + "/zone.json")) { std::fprintf(stderr, "check-zone-content: %s has no zone.json\n", zoneDir.c_str()); return 1; } std::vector<std::string> warnings; int creatureWarn = 0, objectWarn = 0, questWarn = 0; // Creatures wowee::editor::NpcSpawner sp; if (sp.loadFromFile(zoneDir + "/creatures.json")) { for (size_t k = 0; k < sp.spawnCount(); ++k) { const auto& s = sp.getSpawns()[k]; if (s.name.empty()) { warnings.push_back("creature[" + std::to_string(k) + "] has empty name"); creatureWarn++; } if (s.health == 0) { warnings.push_back("creature[" + std::to_string(k) + "] '" + s.name + "' has 0 health"); creatureWarn++; } if (s.level == 0) { warnings.push_back("creature[" + std::to_string(k) + "] '" + s.name + "' has level 0"); creatureWarn++; } if (s.minDamage > s.maxDamage) { warnings.push_back("creature[" + std::to_string(k) + "] '" + s.name + "' has minDamage > maxDamage"); creatureWarn++; } if (s.scale <= 0.0f || !std::isfinite(s.scale)) { warnings.push_back("creature[" + std::to_string(k) + "] '" + s.name + "' has non-positive or non-finite scale"); creatureWarn++; } if (s.displayId == 0) { warnings.push_back("creature[" + std::to_string(k) + "] '" + s.name + "' has displayId=0 (will render invisibly)"); creatureWarn++; } } } // Objects wowee::editor::ObjectPlacer op; if (op.loadFromFile(zoneDir + "/objects.json")) { for (size_t k = 0; k < op.getObjects().size(); ++k) { const auto& o = op.getObjects()[k]; if (o.path.empty()) { warnings.push_back("object[" + std::to_string(k) + "] has empty path"); objectWarn++; } if (o.scale <= 0.0f || !std::isfinite(o.scale)) { warnings.push_back("object[" + std::to_string(k) + "] has non-positive or non-finite scale"); objectWarn++; } if (!std::isfinite(o.position.x) || !std::isfinite(o.position.y) || !std::isfinite(o.position.z)) { warnings.push_back("object[" + std::to_string(k) + "] has non-finite position"); objectWarn++; } } } // Quests wowee::editor::QuestEditor qe; if (qe.loadFromFile(zoneDir + "/quests.json")) { for (size_t k = 0; k < qe.questCount(); ++k) { const auto& q = qe.getQuests()[k]; if (q.title.empty()) { warnings.push_back("quest[" + std::to_string(k) + "] has empty title"); questWarn++; } if (q.objectives.empty()) { warnings.push_back("quest[" + std::to_string(k) + "] '" + q.title + "' has no objectives (uncompletable)"); questWarn++; } if (q.reward.xp == 0 && q.reward.itemRewards.empty() && q.reward.gold == 0 && q.reward.silver == 0 && q.reward.copper == 0) { warnings.push_back("quest[" + std::to_string(k) + "] '" + q.title + "' has no reward at all"); questWarn++; } if (q.requiredLevel == 0) { warnings.push_back("quest[" + std::to_string(k) + "] '" + q.title + "' has requiredLevel=0"); questWarn++; } } } int total = creatureWarn + objectWarn + questWarn; if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["creatureWarnings"] = creatureWarn; j["objectWarnings"] = objectWarn; j["questWarnings"] = questWarn; j["totalWarnings"] = total; j["warnings"] = warnings; j["passed"] = (total == 0); std::printf("%s\n", j.dump(2).c_str()); return total == 0 ? 0 : 1; } std::printf("Zone content: %s\n", zoneDir.c_str()); std::printf(" creature warnings: %d\n", creatureWarn); std::printf(" object warnings : %d\n", objectWarn); std::printf(" quest warnings : %d\n", questWarn); if (total == 0) { std::printf(" PASSED\n"); return 0; } std::printf(" FAILED — %d total warning(s):\n", total); for (const auto& w : warnings) std::printf(" - %s\n", w.c_str()); return 1; } else if (std::strcmp(argv[i], "--check-project-content") == 0 && i + 1 < argc) { // Project-level content sanity check. Walks every zone and // runs the same per-zone checks that --check-zone-content // does, aggregating warnings per zone. Exit 1 if any zone // has any warning. Designed for CI gates before --pack-wcp. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "check-project-content: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); // Same per-zone walks as --check-zone-content. Reuse the // logic by counting issues directly here (cheaper than // shelling out to a sub-invocation per zone). struct ZoneRow { std::string name; int creatureWarn, objectWarn, questWarn; }; std::vector<ZoneRow> rows; int projectFailedZones = 0; for (const auto& zoneDir : zones) { ZoneRow row{fs::path(zoneDir).filename().string(), 0, 0, 0}; wowee::editor::NpcSpawner sp; if (sp.loadFromFile(zoneDir + "/creatures.json")) { for (const auto& s : sp.getSpawns()) { if (s.name.empty()) row.creatureWarn++; if (s.health == 0) row.creatureWarn++; if (s.level == 0) row.creatureWarn++; if (s.minDamage > s.maxDamage) row.creatureWarn++; if (s.scale <= 0.0f || !std::isfinite(s.scale)) row.creatureWarn++; if (s.displayId == 0) row.creatureWarn++; } } wowee::editor::ObjectPlacer op; if (op.loadFromFile(zoneDir + "/objects.json")) { for (const auto& o : op.getObjects()) { if (o.path.empty()) row.objectWarn++; if (o.scale <= 0.0f || !std::isfinite(o.scale)) row.objectWarn++; if (!std::isfinite(o.position.x) || !std::isfinite(o.position.y) || !std::isfinite(o.position.z)) row.objectWarn++; } } wowee::editor::QuestEditor qe; if (qe.loadFromFile(zoneDir + "/quests.json")) { for (const auto& q : qe.getQuests()) { if (q.title.empty()) row.questWarn++; if (q.objectives.empty()) row.questWarn++; if (q.reward.xp == 0 && q.reward.itemRewards.empty() && q.reward.gold == 0 && q.reward.silver == 0 && q.reward.copper == 0) row.questWarn++; if (q.requiredLevel == 0) row.questWarn++; } } int rowTotal = row.creatureWarn + row.objectWarn + row.questWarn; if (rowTotal > 0) projectFailedZones++; rows.push_back(row); } int allPassed = (projectFailedZones == 0); int totalWarn = 0; for (const auto& r : rows) totalWarn += r.creatureWarn + r.objectWarn + r.questWarn; if (jsonOut) { nlohmann::json j; j["projectDir"] = projectDir; j["totalZones"] = zones.size(); j["failedZones"] = projectFailedZones; j["totalWarnings"] = totalWarn; j["passed"] = bool(allPassed); nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({{"zone", r.name}, {"creatureWarn", r.creatureWarn}, {"objectWarn", r.objectWarn}, {"questWarn", r.questWarn}}); } j["zones"] = arr; std::printf("%s\n", j.dump(2).c_str()); return allPassed ? 0 : 1; } std::printf("check-project-content: %s\n", projectDir.c_str()); std::printf(" zones : %zu (%d failed)\n", zones.size(), projectFailedZones); std::printf(" total warns : %d\n", totalWarn); std::printf("\n zone creat object quest status\n"); for (const auto& r : rows) { int rowTotal = r.creatureWarn + r.objectWarn + r.questWarn; std::printf(" %-26s %5d %5d %5d %s\n", r.name.substr(0, 26).c_str(), r.creatureWarn, r.objectWarn, r.questWarn, rowTotal == 0 ? "PASS" : "FAIL"); } if (allPassed) { std::printf("\n ALL ZONES PASSED\n"); return 0; } std::printf("\n %d zone(s) have content warnings\n", projectFailedZones); return 1; } else if (std::strcmp(argv[i], "--check-project-refs") == 0 && i + 1 < argc) { // Project-level cross-reference checker. Walks every zone // and runs the same model-path / NPC-id checks as // --check-zone-refs. Aggregates per zone with file-level // breakdown. Exit 1 if any zone has dangling refs. std::string projectDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "check-project-refs: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); // Same model-resolve logic as --check-zone-refs, applied // per zone with the appropriate root list. auto stripExt = [](const std::string& p, const char* ext) { size_t n = std::strlen(ext); if (p.size() >= n) { std::string tail = p.substr(p.size() - n); std::string lower = tail; for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c)); if (lower == ext) return p.substr(0, p.size() - n); } return p; }; struct ZoneRow { std::string name; int objCheck, objMiss, qCheck, qMiss; }; std::vector<ZoneRow> rows; int projectFailedZones = 0; for (const auto& zoneDir : zones) { ZoneRow row{fs::path(zoneDir).filename().string(), 0, 0, 0, 0}; auto modelExists = [&](const std::string& path, bool isWMO) { std::string base; std::vector<std::string> exts; if (isWMO) { base = stripExt(path, ".wmo"); exts = {".wob", ".wmo"}; } else { base = stripExt(path, ".m2"); exts = {".wom", ".m2"}; } std::vector<std::string> roots = { "", zoneDir + "/", "output/", "custom_zones/", "Data/" }; for (const auto& root : roots) { for (const auto& ext : exts) { if (fs::exists(root + base + ext)) return true; std::string lower = base + ext; for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c)); if (fs::exists(root + lower)) return true; } } return false; }; wowee::editor::ObjectPlacer op; if (op.loadFromFile(zoneDir + "/objects.json")) { for (const auto& o : op.getObjects()) { row.objCheck++; bool isWMO = (o.type == wowee::editor::PlaceableType::WMO); if (!modelExists(o.path, isWMO)) row.objMiss++; } } wowee::editor::NpcSpawner sp; wowee::editor::QuestEditor qe; bool hasCreatures = sp.loadFromFile(zoneDir + "/creatures.json"); std::unordered_set<uint32_t> creatureIds; if (hasCreatures) { for (const auto& s : sp.getSpawns()) creatureIds.insert(s.id); } if (qe.loadFromFile(zoneDir + "/quests.json") && hasCreatures) { for (const auto& q : qe.getQuests()) { row.qCheck++; bool localGiver = (q.questGiverNpcId != 0 && q.questGiverNpcId < 100000 && creatureIds.count(q.questGiverNpcId) == 0); bool localTurn = (q.turnInNpcId != 0 && q.turnInNpcId < 100000 && q.turnInNpcId != q.questGiverNpcId && creatureIds.count(q.turnInNpcId) == 0); if (localGiver) row.qMiss++; if (localTurn) row.qMiss++; } } if (row.objMiss + row.qMiss > 0) projectFailedZones++; rows.push_back(row); } int allPassed = (projectFailedZones == 0); int totalMiss = 0; for (const auto& r : rows) totalMiss += r.objMiss + r.qMiss; if (jsonOut) { nlohmann::json j; j["projectDir"] = projectDir; j["totalZones"] = zones.size(); j["failedZones"] = projectFailedZones; j["totalMissing"] = totalMiss; j["passed"] = bool(allPassed); nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({{"zone", r.name}, {"objectsChecked", r.objCheck}, {"objectsMissing", r.objMiss}, {"questsChecked", r.qCheck}, {"questsMissing", r.qMiss}}); } j["zones"] = arr; std::printf("%s\n", j.dump(2).c_str()); return allPassed ? 0 : 1; } std::printf("check-project-refs: %s\n", projectDir.c_str()); std::printf(" zones : %zu (%d failed)\n", zones.size(), projectFailedZones); std::printf(" total missing: %d\n", totalMiss); std::printf("\n zone obj_chk obj_miss q_chk q_miss status\n"); for (const auto& r : rows) { int rowMiss = r.objMiss + r.qMiss; std::printf(" %-26s %5d %5d %5d %5d %s\n", r.name.substr(0, 26).c_str(), r.objCheck, r.objMiss, r.qCheck, r.qMiss, rowMiss == 0 ? "PASS" : "FAIL"); } if (allPassed) { std::printf("\n ALL ZONES PASSED\n"); return 0; } std::printf("\n %d zone(s) have dangling refs\n", projectFailedZones); return 1; } else if (std::strcmp(argv[i], "--for-each-zone") == 0 && i + 1 < argc) { // Batch runner: enumerates zones in <projectDir> and runs the // command after '--' for each one. '{}' in the command is // substituted with the zone path (find -exec convention). // // wowee_editor --for-each-zone custom_zones -- \\ // wowee_editor --validate-all {} // // Returns the count of failed runs as the exit code (capped // at 255 so the shell can still see it). std::string projectDir = argv[++i]; // The literal '--' separates the projectDir from the command. // Skip it; everything after is the command template. if (i + 1 < argc && std::strcmp(argv[i + 1], "--") == 0) ++i; if (i + 1 >= argc) { std::fprintf(stderr, "for-each-zone: need command after '--'\n"); return 1; } // Collect command tokens until end of argv. Don't try to be // clever about quoting — just escape each token for shell // safety using single quotes (' inside is escaped as '\\''). std::vector<std::string> cmdTokens; for (int k = i + 1; k < argc; ++k) cmdTokens.push_back(argv[k]); i = argc - 1; // consume rest of argv namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "for-each-zone: %s is not a directory\n", projectDir.c_str()); return 1; } // Find every child dir that contains a zone.json — that's the // canonical 'is this a zone?' test the rest of the editor uses. std::vector<std::string> zones; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (fs::exists(entry.path() / "zone.json")) { zones.push_back(entry.path().string()); } } std::sort(zones.begin(), zones.end()); if (zones.empty()) { std::fprintf(stderr, "for-each-zone: no zones found in %s\n", projectDir.c_str()); return 1; } auto shellEscape = [](const std::string& s) { std::string out = "'"; for (char c : s) { if (c == '\'') out += "'\\''"; else out += c; } out += "'"; return out; }; int failed = 0; for (const auto& zone : zones) { std::string cmd; for (size_t k = 0; k < cmdTokens.size(); ++k) { if (k > 0) cmd += " "; std::string token = cmdTokens[k]; // Replace {} with zone path (every occurrence). size_t pos; while ((pos = token.find("{}")) != std::string::npos) { token.replace(pos, 2, zone); } cmd += shellEscape(token); } std::printf("[%s]\n", zone.c_str()); // Flush before std::system so the header lands above the // child's output rather than after (parent stdout is line- // buffered, child writes go straight to the terminal). std::fflush(stdout); int rc = std::system(cmd.c_str()); if (rc != 0) { failed++; std::fprintf(stderr, "for-each-zone: command exited %d for %s\n", rc, zone.c_str()); } } std::printf("\nfor-each-zone: %zu zones, %d failed\n", zones.size(), failed); return failed > 255 ? 255 : failed; } else if (std::strcmp(argv[i], "--for-each-tile") == 0 && i + 1 < argc) { // Per-tile batch runner. --for-each-zone iterates zones in // a project; this iterates tiles within a zone. The '{}' in // the command template is replaced with the tile-base path // (zoneDir/mapName_TX_TY) — the form most tile-level // editor commands take. // // wowee_editor --for-each-tile MyZone -- \\ // wowee_editor --build-woc {} // wowee_editor --for-each-tile MyZone -- \\ // wowee_editor --validate-whm {} std::string zoneDir = argv[++i]; if (i + 1 < argc && std::strcmp(argv[i + 1], "--") == 0) ++i; if (i + 1 >= argc) { std::fprintf(stderr, "for-each-tile: need command after '--'\n"); return 1; } std::vector<std::string> cmdTokens; for (int k = i + 1; k < argc; ++k) cmdTokens.push_back(argv[k]); i = argc - 1; namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "for-each-tile: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "for-each-tile: parse failed\n"); return 1; } if (zm.tiles.empty()) { std::fprintf(stderr, "for-each-tile: zone has no tiles\n"); return 1; } // Same shell-escape + cmd-substitution as --for-each-zone. auto shellEscape = [](const std::string& s) { std::string out = "'"; for (char c : s) { if (c == '\'') out += "'\\''"; else out += c; } out += "'"; return out; }; int failed = 0; // Sort tiles so order is deterministic across runs. auto tiles = zm.tiles; std::sort(tiles.begin(), tiles.end()); for (const auto& [tx, ty] : tiles) { std::string tileBase = zoneDir + "/" + zm.mapName + "_" + std::to_string(tx) + "_" + std::to_string(ty); std::string cmd; for (size_t k = 0; k < cmdTokens.size(); ++k) { if (k > 0) cmd += " "; std::string token = cmdTokens[k]; size_t pos; while ((pos = token.find("{}")) != std::string::npos) { token.replace(pos, 2, tileBase); } cmd += shellEscape(token); } std::printf("[%s (%d, %d)]\n", tileBase.c_str(), tx, ty); std::fflush(stdout); int rc = std::system(cmd.c_str()); if (rc != 0) { failed++; std::fprintf(stderr, "for-each-tile: command exited %d for (%d, %d)\n", rc, tx, ty); } } std::printf("\nfor-each-tile: %zu tiles, %d failed\n", tiles.size(), failed); return failed > 255 ? 255 : failed; } else if (std::strcmp(argv[i], "--version") == 0 || std::strcmp(argv[i], "-v") == 0) { std::printf("Wowee World Editor v1.0.0\n"); std::printf("Open formats: WOT/WHM/WOM/WOB/WOC/WCP + PNG/JSON (all novel)\n"); std::printf("By Kelsi Davis\n"); return 0; } else if (std::strcmp(argv[i], "--list-commands") == 0) { // Capture printUsage's stdout and grep for '--flag' tokens at // the start of each line. This auto-tracks the help text as // commands are added — no parallel list to maintain. Result // is a sorted, deduped, one-per-line list of recognized flags. FILE* old = stdout; // Temp file lets us read printUsage's output back. fmemopen // would be cleaner but isn't available on Windows; tmpfile is // portable. FILE* tmp = std::tmpfile(); if (!tmp) { std::fprintf(stderr, "list-commands: tmpfile failed\n"); return 1; } stdout = tmp; wowee::editor::cli::printUsage(argv[0]); stdout = old; std::fseek(tmp, 0, SEEK_SET); std::set<std::string> commands; char line[512]; while (std::fgets(line, sizeof(line), tmp)) { // Match leading whitespace then '--' then [a-z-]+ const char* p = line; while (*p == ' ' || *p == '\t') ++p; if (p[0] != '-' || p[1] != '-') continue; std::string flag; while (*p && (std::isalnum(static_cast<unsigned char>(*p)) || *p == '-' || *p == '_')) { flag += *p++; } if (flag.size() > 2) commands.insert(flag); } std::fclose(tmp); // Always include the meta-flags that printUsage describes // alongside others (-h/-v aliases) since the regex above only // captures double-dash forms. commands.insert("--help"); commands.insert("--version"); for (const auto& c : commands) std::printf("%s\n", c.c_str()); return 0; } else if (std::strcmp(argv[i], "--info-cli-stats") == 0) { // Meta-stats on the CLI surface: total command count + per- // category breakdown by prefix verb (--info-*, --validate-*, // --diff-*, etc.). Useful for tracking growth over time and // spotting category imbalances. bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; // Re-use --list-commands' parser. Capture printUsage stdout. FILE* old = stdout; FILE* tmp = std::tmpfile(); if (!tmp) { std::fprintf(stderr, "info-cli-stats: tmpfile failed\n"); return 1; } stdout = tmp; wowee::editor::cli::printUsage(argv[0]); stdout = old; std::fseek(tmp, 0, SEEK_SET); std::set<std::string> commands; char line[512]; while (std::fgets(line, sizeof(line), tmp)) { const char* p = line; while (*p == ' ' || *p == '\t') ++p; if (p[0] != '-' || p[1] != '-') continue; std::string flag; while (*p && (std::isalnum(static_cast<unsigned char>(*p)) || *p == '-' || *p == '_')) { flag += *p++; } if (flag.size() > 2) commands.insert(flag); } std::fclose(tmp); commands.insert("--help"); commands.insert("--version"); // Bucket by category — verb is the second token after '--', // up to the next dash. So '--info-zone-tree' -> 'info'. std::map<std::string, int> byCategory; int maxLen = 0; for (const auto& c : commands) { if (static_cast<int>(c.size()) > maxLen) maxLen = static_cast<int>(c.size()); size_t verbStart = 2; // skip '--' size_t verbEnd = c.find('-', verbStart); std::string verb = (verbEnd == std::string::npos) ? c.substr(verbStart) : c.substr(verbStart, verbEnd - verbStart); byCategory[verb]++; } if (jsonOut) { nlohmann::json j; j["totalCommands"] = commands.size(); j["maxFlagLength"] = maxLen; nlohmann::json cats = nlohmann::json::object(); for (const auto& [v, c] : byCategory) cats[v] = c; j["byCategory"] = cats; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("CLI surface stats\n"); std::printf(" total commands : %zu\n", commands.size()); std::printf(" longest flag : %d chars\n", maxLen); std::printf("\n Categories (by verb prefix, sorted by count):\n"); // Sort by count descending for the table. std::vector<std::pair<std::string, int>> sorted( byCategory.begin(), byCategory.end()); std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); for (const auto& [verb, count] : sorted) { std::printf(" --%-12s %4d\n", verb.c_str(), count); } return 0; } else if (std::strcmp(argv[i], "--info-cli-categories") == 0) { // Discovery view of every CLI flag grouped by verb prefix. // Where --info-cli-stats just counts per category, this // lists every command in each category — handy for "I // know I want to gen something but what shapes/textures // are available?" FILE* old = stdout; FILE* tmp = std::tmpfile(); if (!tmp) { std::fprintf(stderr, "info-cli-categories: tmpfile failed\n"); return 1; } stdout = tmp; wowee::editor::cli::printUsage(argv[0]); stdout = old; std::fseek(tmp, 0, SEEK_SET); std::set<std::string> commands; char line[512]; while (std::fgets(line, sizeof(line), tmp)) { const char* p = line; while (*p == ' ' || *p == '\t') ++p; if (p[0] != '-' || p[1] != '-') continue; std::string flag; while (*p && (std::isalnum(static_cast<unsigned char>(*p)) || *p == '-' || *p == '_')) { flag += *p++; } if (flag.size() > 2) commands.insert(flag); } std::fclose(tmp); commands.insert("--help"); commands.insert("--version"); std::map<std::string, std::vector<std::string>> byCategory; for (const auto& c : commands) { size_t verbStart = 2; size_t verbEnd = c.find('-', verbStart); std::string verb = (verbEnd == std::string::npos) ? c.substr(verbStart) : c.substr(verbStart, verbEnd - verbStart); byCategory[verb].push_back(c); } std::printf("CLI commands by category (%zu total):\n\n", commands.size()); // Sort categories by count descending, commands within // each alphabetically. std::vector<std::pair<std::string, std::vector<std::string>>> sorted( byCategory.begin(), byCategory.end()); std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { if (a.second.size() != b.second.size()) return a.second.size() > b.second.size(); return a.first < b.first; }); for (const auto& [verb, cmds] : sorted) { std::printf("--%s (%zu):\n", verb.c_str(), cmds.size()); for (const auto& c : cmds) { std::printf(" %s\n", c.c_str()); } std::printf("\n"); } return 0; } else if (std::strcmp(argv[i], "--info-cli-help") == 0 && i + 1 < argc) { // Substring search through the help text. With 130+ commands, // 'is there a thing for X?' is a common ask — this answers it // without making the user scroll the full --help output: // // wowee_editor --info-cli-help quest // wowee_editor --info-cli-help validate // wowee_editor --info-cli-help glb std::string pattern = argv[++i]; // Lowercase the pattern for case-insensitive match. std::string patLower = pattern; for (auto& c : patLower) c = std::tolower(static_cast<unsigned char>(c)); // Capture printUsage stdout, walk line-by-line, print every // line containing the pattern (case-insensitive). Continuation // lines (the indented description on the line after a flag) // are emitted along with the flag line for context. FILE* old = stdout; FILE* tmp = std::tmpfile(); if (!tmp) { std::fprintf(stderr, "info-cli-help: tmpfile failed\n"); return 1; } stdout = tmp; wowee::editor::cli::printUsage(argv[0]); stdout = old; std::fseek(tmp, 0, SEEK_SET); std::vector<std::string> lines; char buf[1024]; while (std::fgets(buf, sizeof(buf), tmp)) { std::string s = buf; if (!s.empty() && s.back() == '\n') s.pop_back(); lines.push_back(std::move(s)); } std::fclose(tmp); int matches = 0; for (size_t k = 0; k < lines.size(); ++k) { std::string lower = lines[k]; for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c)); if (lower.find(patLower) == std::string::npos) continue; std::printf("%s\n", lines[k].c_str()); // Look ahead for a continuation line (indented and not // starting with '--'). Print it for context. if (k + 1 < lines.size()) { const auto& next = lines[k + 1]; if (!next.empty() && next[0] == ' ' && next.find("--") == std::string::npos) { std::printf("%s\n", next.c_str()); } } matches++; } if (matches == 0) { std::fprintf(stderr, "info-cli-help: no matches for '%s'\n", pattern.c_str()); return 1; } std::fprintf(stderr, "\n%d line(s) matched '%s'\n", matches, pattern.c_str()); return 0; } else if (std::strcmp(argv[i], "--validate-cli-help") == 0) { // Self-check: every flag we declare in kArgRequired (the list // of commands needing positional args) must appear in the // help text printUsage emits. Catches drift where someone // adds a handler + argument check but forgets the help line. bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; // Capture printUsage's stdout. FILE* old = stdout; FILE* tmp = std::tmpfile(); if (!tmp) { std::fprintf(stderr, "validate-cli-help: tmpfile failed\n"); return 1; } stdout = tmp; wowee::editor::cli::printUsage(argv[0]); stdout = old; std::fseek(tmp, 0, SEEK_SET); std::string helpText; char chunk[1024]; while (std::fgets(chunk, sizeof(chunk), tmp)) helpText += chunk; std::fclose(tmp); // Walk kArgRequired and check each appears in the help. std::vector<std::string> missing; for (const char* opt : kArgRequired) { if (helpText.find(opt) == std::string::npos) { missing.push_back(opt); } } if (jsonOut) { nlohmann::json j; j["totalArgRequired"] = sizeof(kArgRequired) / sizeof(kArgRequired[0]); j["missing"] = missing; j["passed"] = missing.empty(); std::printf("%s\n", j.dump(2).c_str()); return missing.empty() ? 0 : 1; } std::printf("CLI help self-check\n"); std::printf(" kArgRequired entries : %zu\n", sizeof(kArgRequired) / sizeof(kArgRequired[0])); if (missing.empty()) { std::printf(" PASSED — every kArgRequired flag is documented\n"); return 0; } std::printf(" FAILED — %zu flag(s) missing from help text:\n", missing.size()); for (const auto& m : missing) std::printf(" - %s\n", m.c_str()); return 1; } else if (std::strcmp(argv[i], "--gen-completion") == 0 && i + 1 < argc) { // Emit a bash or zsh completion script. Re-execs the editor's // own --list-commands at completion time so newly-added flags // light up automatically without regenerating the script. std::string shell = argv[++i]; if (shell != "bash" && shell != "zsh") { std::fprintf(stderr, "gen-completion: shell must be 'bash' or 'zsh', got '%s'\n", shell.c_str()); return 1; } // Use argv[0] as the binary name in the completion so it // works whether the user installed it as 'wowee_editor' or // a custom alias. Strip directory components for the // completion-name registration (bash 'complete -F' expects // a basename). std::string self = argv[0]; auto slash = self.find_last_of('/'); std::string baseName = (slash != std::string::npos) ? self.substr(slash + 1) : self; if (shell == "bash") { std::printf( "# wowee_editor bash completion — source from ~/.bashrc:\n" "# source <(%s --gen-completion bash)\n" "_wowee_editor_complete() {\n" " local cur prev cmds\n" " COMPREPLY=()\n" " cur=\"${COMP_WORDS[COMP_CWORD]}\"\n" " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n" " # Cache the command list per shell session.\n" " if [[ -z \"$_WOWEE_EDITOR_CMDS\" ]]; then\n" " _WOWEE_EDITOR_CMDS=$(%s --list-commands 2>/dev/null)\n" " fi\n" " if [[ \"$cur\" == --* ]]; then\n" " COMPREPLY=( $(compgen -W \"$_WOWEE_EDITOR_CMDS\" -- \"$cur\") )\n" " return 0\n" " fi\n" " # Default: complete file paths for arg slots.\n" " COMPREPLY=( $(compgen -f -- \"$cur\") )\n" "}\n" "complete -F _wowee_editor_complete %s\n", self.c_str(), self.c_str(), baseName.c_str()); } else { // zsh — simpler descriptor-based completion. std::printf( "# wowee_editor zsh completion — source from ~/.zshrc:\n" "# source <(%s --gen-completion zsh)\n" "_wowee_editor_complete() {\n" " local -a cmds\n" " if [[ -z \"$_WOWEE_EDITOR_CMDS\" ]]; then\n" " export _WOWEE_EDITOR_CMDS=$(%s --list-commands 2>/dev/null)\n" " fi\n" " cmds=( ${(f)_WOWEE_EDITOR_CMDS} )\n" " _arguments \"*: :($cmds)\"\n" "}\n" "compdef _wowee_editor_complete %s\n", self.c_str(), self.c_str(), baseName.c_str()); } return 0; } else if (std::strcmp(argv[i], "--help") == 0 || std::strcmp(argv[i], "-h") == 0) { wowee::editor::cli::printUsage(argv[0]); return 0; } } if (dataPath.empty()) { dataPath = "Data"; LOG_INFO("No --data path specified, using default: ", dataPath); } wowee::editor::EditorApp app; if (!app.initialize(dataPath)) { LOG_ERROR("Failed to initialize editor"); return 1; } if (!adtMap.empty()) { app.loadADT(adtMap, adtX, adtY); } app.run(); app.shutdown(); return 0; }