#include "editor_app.hpp" #include "cli_gen_audio.hpp" #include "cli_zone_packs.hpp" #include "cli_audits.hpp" #include "cli_readmes.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. namespace wowee_sha256 { struct State { uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; uint64_t totalBits = 0; uint8_t buf[64] = {}; size_t bufLen = 0; }; static inline uint32_t rotr(uint32_t x, uint32_t n) { return (x >> n) | (x << (32 - n)); } static void compress(State& s, const uint8_t* block) { static const uint32_t K[64] = { 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2, }; uint32_t w[64]; for (int i = 0; i < 16; ++i) { w[i] = (uint32_t(block[i*4]) << 24) | (uint32_t(block[i*4+1]) << 16) | (uint32_t(block[i*4+2]) << 8) | uint32_t(block[i*4+3]); } for (int i = 16; i < 64; ++i) { uint32_t s0 = rotr(w[i-15], 7) ^ rotr(w[i-15], 18) ^ (w[i-15] >> 3); uint32_t s1 = rotr(w[i-2], 17) ^ rotr(w[i-2], 19) ^ (w[i-2] >> 10); w[i] = w[i-16] + s0 + w[i-7] + s1; } uint32_t a = s.h[0], b = s.h[1], c = s.h[2], d = s.h[3]; uint32_t e = s.h[4], f = s.h[5], g = s.h[6], h = s.h[7]; for (int i = 0; i < 64; ++i) { uint32_t S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); uint32_t ch = (e & f) ^ (~e & g); uint32_t t1 = h + S1 + ch + K[i] + w[i]; uint32_t S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); uint32_t mj = (a & b) ^ (a & c) ^ (b & c); uint32_t t2 = S0 + mj; h = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2; } s.h[0] += a; s.h[1] += b; s.h[2] += c; s.h[3] += d; s.h[4] += e; s.h[5] += f; s.h[6] += g; s.h[7] += h; } static void update(State& s, const uint8_t* data, size_t len) { s.totalBits += len * 8; while (len > 0) { size_t take = std::min(len, sizeof(s.buf) - s.bufLen); std::memcpy(s.buf + s.bufLen, data, take); s.bufLen += take; data += take; len -= take; if (s.bufLen == 64) { compress(s, s.buf); s.bufLen = 0; } } } static std::string hexFinal(State& s) { s.buf[s.bufLen++] = 0x80; if (s.bufLen > 56) { std::memset(s.buf + s.bufLen, 0, 64 - s.bufLen); compress(s, s.buf); s.bufLen = 0; } std::memset(s.buf + s.bufLen, 0, 56 - s.bufLen); for (int i = 7; i >= 0; --i) s.buf[56 + (7 - i)] = (s.totalBits >> (i * 8)) & 0xFF; compress(s, s.buf); char out[65] = {}; for (int i = 0; i < 8; ++i) { std::snprintf(out + i * 8, 9, "%08x", s.h[i]); } return std::string(out); } static std::string fileHex(const std::string& path) { std::ifstream in(path, std::ios::binary); if (!in) return ""; State s; char chunk[16384]; while (in.read(chunk, sizeof(chunk)) || in.gcount() > 0) { update(s, reinterpret_cast(chunk), static_cast(in.gcount())); } return hexFinal(s); } static std::string hex(const uint8_t* data, size_t len) { State s; update(s, data, len); return hexFinal(s); } } // namespace wowee_sha256 static std::vector validateWomErrors( const wowee::pipeline::WoweeModel& wom) { std::vector errors; if (wom.version < 1 || wom.version > 3) { errors.push_back("version " + std::to_string(wom.version) + " outside [1,3]"); } if (!wom.isValid()) errors.push_back("empty geometry (no verts/indices)"); if (wom.indices.size() % 3 != 0) { errors.push_back("indices.size()=" + std::to_string(wom.indices.size()) + " not divisible by 3"); } int oobIdx = 0; for (uint32_t idx : wom.indices) { if (idx >= wom.vertices.size()) { if (++oobIdx <= 3) { errors.push_back("index " + std::to_string(idx) + " >= vertexCount " + std::to_string(wom.vertices.size())); } } } if (oobIdx > 3) { errors.push_back("... and " + std::to_string(oobIdx - 3) + " more out-of-range indices"); } for (size_t b = 0; b < wom.bones.size(); ++b) { int16_t p = wom.bones[b].parentBone; if (p == -1) continue; if (p < 0 || p >= static_cast(wom.bones.size())) { errors.push_back("bone " + std::to_string(b) + " parent=" + std::to_string(p) + " out of range"); } else if (p >= static_cast(b)) { errors.push_back("bone " + std::to_string(b) + " parent=" + std::to_string(p) + " not strictly less (DAG order)"); } } int oobVB = 0; for (size_t v = 0; v < wom.vertices.size() && !wom.bones.empty(); ++v) { const auto& vert = wom.vertices[v]; for (int k = 0; k < 4; ++k) { if (vert.boneWeights[k] == 0) continue; if (vert.boneIndices[k] >= wom.bones.size()) { if (++oobVB <= 3) { errors.push_back("vertex " + std::to_string(v) + " boneIndex[" + std::to_string(k) + "]=" + std::to_string(vert.boneIndices[k]) + " >= boneCount " + std::to_string(wom.bones.size())); } } } } if (oobVB > 3) { errors.push_back("... and " + std::to_string(oobVB - 3) + " more out-of-range vertex bone refs"); } for (size_t a = 0; a < wom.animations.size(); ++a) { const auto& anim = wom.animations[a]; if (!anim.boneKeyframes.empty() && anim.boneKeyframes.size() != wom.bones.size()) { errors.push_back("animation " + std::to_string(a) + " boneKeyframes.size()=" + std::to_string(anim.boneKeyframes.size()) + " != boneCount " + std::to_string(wom.bones.size())); } } for (size_t b = 0; b < wom.batches.size(); ++b) { const auto& batch = wom.batches[b]; uint64_t end = uint64_t(batch.indexStart) + batch.indexCount; if (end > wom.indices.size()) { errors.push_back("batch " + std::to_string(b) + " indexStart+Count=" + std::to_string(end) + " > indexCount " + std::to_string(wom.indices.size())); } if (batch.indexCount % 3 != 0) { errors.push_back("batch " + std::to_string(b) + " indexCount=" + std::to_string(batch.indexCount) + " not divisible by 3"); } if (!wom.texturePaths.empty() && batch.textureIndex >= wom.texturePaths.size()) { errors.push_back("batch " + std::to_string(b) + " textureIndex=" + std::to_string(batch.textureIndex) + " >= textureCount " + std::to_string(wom.texturePaths.size())); } } if (wom.boundMin.x > wom.boundMax.x || wom.boundMin.y > wom.boundMax.y || wom.boundMin.z > wom.boundMax.z) { errors.push_back("boundMin > boundMax on at least one axis"); } if (wom.boundRadius < 0.0f) { errors.push_back("boundRadius=" + std::to_string(wom.boundRadius) + " is negative"); } return errors; } static std::vector validateWobErrors( const wowee::pipeline::WoweeBuilding& bld) { std::vector errors; if (!bld.isValid()) errors.push_back("empty building (no groups)"); int badMatTexCount = 0; for (size_t g = 0; g < bld.groups.size(); ++g) { const auto& grp = bld.groups[g]; if (grp.indices.size() % 3 != 0) { errors.push_back("group " + std::to_string(g) + " indices.size()=" + std::to_string(grp.indices.size()) + " not divisible by 3"); } int oobIdx = 0; for (uint32_t idx : grp.indices) { if (idx >= grp.vertices.size()) ++oobIdx; } if (oobIdx > 0) { errors.push_back("group " + std::to_string(g) + " has " + std::to_string(oobIdx) + " indices out of range (vertCount=" + std::to_string(grp.vertices.size()) + ")"); } for (size_t m = 0; m < grp.materials.size(); ++m) { if (grp.materials[m].texturePath.empty()) { badMatTexCount++; if (badMatTexCount <= 3) { errors.push_back("group " + std::to_string(g) + " material " + std::to_string(m) + " has empty texturePath"); } } } if (grp.boundMin.x > grp.boundMax.x || grp.boundMin.y > grp.boundMax.y || grp.boundMin.z > grp.boundMax.z) { errors.push_back("group " + std::to_string(g) + " boundMin > boundMax on at least one axis"); } } if (badMatTexCount > 3) { errors.push_back("... and " + std::to_string(badMatTexCount - 3) + " more empty material textures"); } int badPortal = 0; for (size_t p = 0; p < bld.portals.size(); ++p) { const auto& portal = bld.portals[p]; auto inRange = [&](int g) { return g == -1 || (g >= 0 && g < static_cast(bld.groups.size())); }; if (!inRange(portal.groupA) || !inRange(portal.groupB)) { if (++badPortal <= 3) { errors.push_back("portal " + std::to_string(p) + " refs out-of-range groups (" + std::to_string(portal.groupA) + ", " + std::to_string(portal.groupB) + ")"); } } if (portal.vertices.size() < 3) { if (++badPortal <= 3) { errors.push_back("portal " + std::to_string(p) + " has only " + std::to_string(portal.vertices.size()) + " verts (need >= 3 for a polygon)"); } } } if (badPortal > 3) { errors.push_back("... and " + std::to_string(badPortal - 3) + " more bad portal entries"); } int badDoodad = 0; for (size_t d = 0; d < bld.doodads.size(); ++d) { const auto& doodad = bld.doodads[d]; if (doodad.modelPath.empty()) { if (++badDoodad <= 3) { errors.push_back("doodad " + std::to_string(d) + " has empty modelPath"); } } if (!std::isfinite(doodad.scale) || doodad.scale <= 0.0f) { if (++badDoodad <= 3) { errors.push_back("doodad " + std::to_string(d) + " has non-positive scale " + std::to_string(doodad.scale)); } } } if (badDoodad > 3) { errors.push_back("... and " + std::to_string(badDoodad - 3) + " more bad doodad entries"); } if (bld.boundRadius < 0.0f) { errors.push_back("boundRadius=" + std::to_string(bld.boundRadius) + " is negative"); } return errors; } static std::vector validateWocErrors( const wowee::pipeline::WoweeCollision& woc) { std::vector errors; if (!woc.isValid()) errors.push_back("empty collision (no triangles)"); if (woc.tileX >= 64 || woc.tileY >= 64) { errors.push_back("tile coords out of WoW grid: (" + std::to_string(woc.tileX) + ", " + std::to_string(woc.tileY) + ") — must be < 64"); } int nanTris = 0, degenerate = 0, badFlags = 0; auto isFiniteVec = [](const glm::vec3& v) { return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); }; constexpr uint8_t kKnownFlags = 0x0F; // walkable|water|steep|indoor for (size_t t = 0; t < woc.triangles.size(); ++t) { const auto& tri = woc.triangles[t]; if (!isFiniteVec(tri.v0) || !isFiniteVec(tri.v1) || !isFiniteVec(tri.v2)) { if (++nanTris <= 3) { errors.push_back("triangle " + std::to_string(t) + " has non-finite vertex coord"); } } if (tri.v0 == tri.v1 || tri.v1 == tri.v2 || tri.v0 == tri.v2) { if (++degenerate <= 3) { errors.push_back("triangle " + std::to_string(t) + " is degenerate (two vertices identical)"); } } if (tri.flags & ~kKnownFlags) { if (++badFlags <= 3) { errors.push_back("triangle " + std::to_string(t) + " has unknown flag bits 0x" + [&]{ char b[8]; std::snprintf(b,sizeof b,"%02X",tri.flags); return std::string(b); }()); } } } if (nanTris > 3) errors.push_back("... and " + std::to_string(nanTris - 3) + " more non-finite triangles"); if (degenerate > 3) errors.push_back("... and " + std::to_string(degenerate - 3) + " more degenerate triangles"); if (badFlags > 3) errors.push_back("... and " + std::to_string(badFlags - 3) + " more triangles with unknown flag bits"); if (woc.bounds.min.x > woc.bounds.max.x || woc.bounds.min.y > woc.bounds.max.y || woc.bounds.min.z > woc.bounds.max.z) { errors.push_back("bounds.min > bounds.max on at least one axis"); } return errors; } static std::vector validateWhmErrors( const wowee::pipeline::ADTTerrain& terrain) { std::vector errors; if (!terrain.isLoaded()) { errors.push_back("terrain not loaded"); return errors; } if (terrain.coord.x < 0 || terrain.coord.x >= 64 || terrain.coord.y < 0 || terrain.coord.y >= 64) { errors.push_back("tile coord out of WoW grid: (" + std::to_string(terrain.coord.x) + ", " + std::to_string(terrain.coord.y) + ")"); } int nanHeightChunks = 0, nanPosChunks = 0; int loadedChunks = 0; float minH = 1e30f, maxH = -1e30f; for (size_t c = 0; c < 256; ++c) { const auto& chunk = terrain.chunks[c]; if (!chunk.heightMap.isLoaded()) continue; loadedChunks++; if (!std::isfinite(chunk.position[0]) || !std::isfinite(chunk.position[1]) || !std::isfinite(chunk.position[2])) { if (++nanPosChunks <= 3) { errors.push_back("chunk " + std::to_string(c) + " has non-finite position"); } } bool chunkHasBadHeight = false; for (float h : chunk.heightMap.heights) { if (!std::isfinite(h)) { chunkHasBadHeight = true; } else { if (h < minH) minH = h; if (h > maxH) maxH = h; } } if (chunkHasBadHeight) { if (++nanHeightChunks <= 3) { errors.push_back("chunk " + std::to_string(c) + " contains non-finite heights"); } } } if (nanHeightChunks > 3) { errors.push_back("... and " + std::to_string(nanHeightChunks - 3) + " more chunks with non-finite heights"); } if (nanPosChunks > 3) { errors.push_back("... and " + std::to_string(nanPosChunks - 3) + " more chunks with non-finite positions"); } if (loadedChunks == 0) { errors.push_back("no chunks loaded (heightmap empty)"); } // Heights outside the WoW world envelope often signal a units-confusion // bug — most maps stay in [-3000, 3000]. Warn-class, not fail. if (loadedChunks > 0 && (minH < -10000.0f || maxH > 10000.0f)) { errors.push_back("height range [" + std::to_string(minH) + ", " + std::to_string(maxH) + "] is outside reasonable WoW envelope"); } int badPlacements = 0; for (size_t p = 0; p < terrain.doodadPlacements.size(); ++p) { const auto& d = terrain.doodadPlacements[p]; if (!std::isfinite(d.position[0]) || !std::isfinite(d.position[1]) || !std::isfinite(d.position[2])) { if (++badPlacements <= 3) { errors.push_back("doodad placement " + std::to_string(p) + " has non-finite position"); } } if (d.scale == 0) { if (++badPlacements <= 3) { errors.push_back("doodad placement " + std::to_string(p) + " has scale=0"); } } if (!terrain.doodadNames.empty() && d.nameId >= terrain.doodadNames.size()) { if (++badPlacements <= 3) { errors.push_back("doodad placement " + std::to_string(p) + " nameId=" + std::to_string(d.nameId) + " >= doodadNames " + std::to_string(terrain.doodadNames.size())); } } } for (size_t p = 0; p < terrain.wmoPlacements.size(); ++p) { const auto& w = terrain.wmoPlacements[p]; if (!std::isfinite(w.position[0]) || !std::isfinite(w.position[1]) || !std::isfinite(w.position[2])) { if (++badPlacements <= 3) { errors.push_back("wmo placement " + std::to_string(p) + " has non-finite position"); } } if (!terrain.wmoNames.empty() && w.nameId >= terrain.wmoNames.size()) { if (++badPlacements <= 3) { errors.push_back("wmo placement " + std::to_string(p) + " nameId=" + std::to_string(w.nameId) + " >= wmoNames " + std::to_string(terrain.wmoNames.size())); } } } if (badPlacements > 3) { errors.push_back("... and " + std::to_string(badPlacements - 3) + " more bad placement entries"); } return errors; } static void printUsage(const char* argv0) { std::printf("Usage: %s --data [options]\n\n", argv0); std::printf("Options:\n"); std::printf(" --data Path to extracted WoW data (manifest.json)\n"); std::printf(" --adt Load an ADT tile on startup\n"); std::printf(" --convert-m2 Convert M2 model to WOM open format (no GUI)\n"); std::printf(" --convert-m2-batch \n"); std::printf(" Bulk M2→WOM conversion across every .m2 in (per-file pass/fail summary)\n"); std::printf(" --convert-wmo Convert WMO building to WOB open format (no GUI)\n"); std::printf(" --convert-wmo-batch \n"); std::printf(" Bulk WMO→WOB conversion across every .wmo in (skips _NNN group files)\n"); std::printf(" --convert-dbc-batch \n"); std::printf(" Bulk DBC→JSON conversion across every .dbc in (sidecars next to source)\n"); std::printf(" --migrate-data-tree \n"); std::printf(" Run all four bulk converters (m2/wmo/blp/dbc) end-to-end on an extracted Data tree\n"); std::printf(" --info-data-tree [--json]\n"); std::printf(" Per-format migration-progress report (m2 vs wom, wmo vs wob, blp vs png, dbc vs json)\n"); std::printf(" --strip-data-tree [--dry-run]\n"); std::printf(" Delete proprietary files (.m2/.wmo/.blp/.dbc) that already have an open sidecar\n"); std::printf(" --audit-data-tree \n"); std::printf(" CI gate: exit 1 if any proprietary file lacks an open sidecar (100%% migration check)\n"); std::printf(" --bench-migrate-data-tree [--json]\n"); std::printf(" Time each step of --migrate-data-tree (m2/wmo/blp/dbc) and report wall-clock per step\n"); std::printf(" --list-data-tree-largest [N]\n"); std::printf(" Top-N largest proprietary files (.m2/.wmo/.blp/.dbc) for migration prioritization\n"); std::printf(" --export-data-tree-md [out.md]\n"); std::printf(" Markdown migration-progress report (per-pair table, share %%, recommended next steps)\n"); std::printf(" --gen-texture [W H]\n"); std::printf(" Synthesize a placeholder texture (solid hex color or 'checker'/'grid'); default 256x256\n"); std::printf(" --gen-texture-gradient [vertical|horizontal] [W H]\n"); std::printf(" Synthesize a linear gradient PNG (default vertical, 256x256)\n"); std::printf(" --gen-texture-noise [seed] [W H]\n"); std::printf(" Synthesize a smooth value-noise PNG (deterministic from seed; default 256x256)\n"); std::printf(" --gen-texture-noise-color [seed] [W H]\n"); std::printf(" Same noise pattern but blended between two colors instead of grayscale\n"); std::printf(" --gen-texture-radial [W H]\n"); std::printf(" Synthesize a radial gradient PNG (center→edge, smooth distance-based blend)\n"); std::printf(" --gen-texture-stripes [stripePx] [diagonal|horizontal|vertical] [W H]\n"); std::printf(" Synthesize a two-color stripe pattern (default 16px diagonal, 256x256)\n"); std::printf(" --gen-texture-dots [dotRadius] [spacing] [W H]\n"); std::printf(" Synthesize a polka-dot pattern (default radius 8, spacing 32, 256x256)\n"); std::printf(" --gen-texture-rings [ringPx] [W H]\n"); std::printf(" Synthesize concentric ring pattern (target/seal style; default 16px rings, 256x256)\n"); std::printf(" --gen-texture-checker [cellPx] [W H]\n"); std::printf(" Synthesize checkerboard with custom colors (gen-texture's checker is BW only)\n"); std::printf(" --gen-texture-brick [brickW] [brickH] [mortarPx] [W H]\n"); std::printf(" Brick wall pattern with offset rows + mortar lines (default 64×24, 4px mortar)\n"); std::printf(" --gen-texture-wood [grainSpacing] [seed] [W H]\n"); std::printf(" Wood grain pattern with vertical streaks + knots (default spacing 12px, seed 1)\n"); std::printf(" --gen-texture-grass [density] [seed] [W H]\n"); std::printf(" Tiling grass texture with random blade highlights (default density=0.15, seed=1)\n"); std::printf(" --gen-texture-fabric [threadPx] [W H]\n"); std::printf(" Woven fabric pattern with alternating warp/weft threads (default thread=4px)\n"); std::printf(" --gen-texture-cobble [stonePx] [seed] [W H]\n"); std::printf(" Cobblestone street pattern: irregular packed stones (default stone=24px, seed 1)\n"); std::printf(" --gen-texture-marble [seed] [veinSharpness] [W H]\n"); std::printf(" Marble pattern with sinusoidal veining (default seed 1, sharpness 8)\n"); std::printf(" --gen-texture-metal [seed] [orientation] [W H]\n"); std::printf(" Brushed metal: directional anisotropic noise (orientation: horizontal|vertical)\n"); std::printf(" --gen-texture-leather [seed] [grainSize] [W H]\n"); std::printf(" Leather grain: irregular pebbled bumps via cellular noise (default grain=4px)\n"); std::printf(" --add-texture-to-zone [renameTo]\n"); std::printf(" Copy an existing PNG into (optionally renaming it on the way in)\n"); std::printf(" --gen-mesh [size]\n"); std::printf(" Synthesize a procedural WOM primitive with proper normals, UVs, and bounds\n"); std::printf(" --gen-mesh-textured [size]\n"); std::printf(" Compose a procedural mesh + matching PNG texture wired into the WOM's batch\n"); std::printf(" --gen-mesh-stairs [stepHeight] [stepDepth] [width]\n"); std::printf(" Procedural straight staircase along +X with N steps (default 5 / 0.2 / 0.3 / 1.0)\n"); std::printf(" --gen-mesh-grid [size]\n"); std::printf(" Subdivided flat plane on XY (NxN cells, 2N² triangles); useful for LOD demos\n"); std::printf(" --gen-mesh-disc [radius] [segments]\n"); std::printf(" Flat circular disc on XY centered at origin (default radius 1.0, 32 segments)\n"); std::printf(" --gen-mesh-tube [outerRadius] [innerRadius] [height] [segments]\n"); std::printf(" Hollow cylinder/pipe along Y axis (default 1.0/0.7/2.0, 24 segments)\n"); std::printf(" --gen-mesh-capsule [radius] [cylHeight] [segments] [stacks]\n"); std::printf(" Capsule along Y axis: cylinder body with hemispherical caps (default 0.5/1.0/16/8)\n"); std::printf(" --gen-mesh-arch [openingWidth] [openingHeight] [thickness] [depth] [segments]\n"); std::printf(" Doorway arch: two columns + semicircular top (default 1.0/1.5/0.2/0.3, 12 segs)\n"); std::printf(" --gen-mesh-pyramid [sides] [baseRadius] [height]\n"); std::printf(" N-sided polygonal pyramid with apex at +Y (default 4 sides, 1.0/1.0)\n"); std::printf(" --gen-mesh-fence [posts] [postSpacing] [postHeight] [railThick]\n"); std::printf(" Repeating fence: N posts along +X with two horizontal rails between\n"); std::printf(" --gen-mesh-tree [trunkRadius] [trunkHeight] [foliageRadius]\n"); std::printf(" --gen-mesh-rock [radius] [roughness] [subdiv] [seed]\n"); std::printf(" Procedural boulder via subdivided octahedron + smooth noise displacement\n"); std::printf(" --gen-mesh-pillar [radius] [height] [flutes] [capScale]\n"); std::printf(" Fluted classical column with concave flutes + flared cap/base (default 12 flutes)\n"); std::printf(" --gen-mesh-bridge [length] [width] [planks] [railHeight]\n"); std::printf(" Plank bridge with two side rails (default 6 planks across, rails on)\n"); std::printf(" --gen-mesh-tower [radius] [height] [battlements] [battlementH]\n"); std::printf(" Round castle tower with crenellated battlements (default 8 teeth, 0.5m tall)\n"); std::printf(" --gen-mesh-house [width] [depth] [height] [roofHeight]\n"); std::printf(" Simple house: cube body + pyramid roof (default 4×4×3 with 2m roof)\n"); std::printf(" --gen-mesh-fountain [basinRadius] [basinHeight] [spoutRadius] [spoutHeight]\n"); std::printf(" Round basin + center spout column (default 1.5/0.5 basin, 0.2/1.5 spout)\n"); std::printf(" --gen-mesh-statue [pedestalSize] [bodyHeight] [headRadius]\n"); std::printf(" Humanoid placeholder: pedestal block + tall body cylinder + head sphere\n"); std::printf(" --gen-mesh-altar [topRadius] [topHeight] [steps] [stepStride]\n"); std::printf(" Round altar: stacked stepped discs descending from a flat top (default 3 steps)\n"); std::printf(" --gen-mesh-portal [width] [height] [postThickness] [lintelHeight]\n"); std::printf(" Doorway frame: two side posts + top lintel (default 2.5w × 4h)\n"); std::printf(" --gen-mesh-archway [width] [pillarHeight] [thickness] [archSegs]\n"); std::printf(" Semicircular arched doorway: two pillars + curved keystone vault (default 12 segs)\n"); std::printf(" Procedural tree: cylindrical trunk + spherical foliage (default 0.1/2.0/0.7)\n"); std::printf(" --displace-mesh [scale]\n"); std::printf(" Offset each vertex along its normal by heightmap brightness × scale (default 1.0)\n"); std::printf(" --gen-mesh-from-heightmap [scaleXZ] [scaleY]\n"); std::printf(" Convert a grayscale PNG into a heightmap mesh (W×H verts, 2(W-1)(H-1) tris)\n"); std::printf(" --export-mesh-heightmap \n"); std::printf(" Extract a grayscale heightmap PNG from a row-major W×H heightmap mesh\n"); std::printf(" --add-texture-to-mesh [batchIdx]\n"); std::printf(" Bind an existing PNG into a WOM's texturePaths and point batchIdx (default 0) at it\n"); std::printf(" --scale-mesh \n"); std::printf(" Uniformly scale every vertex and bounds by (factor > 0)\n"); std::printf(" --translate-mesh \n"); std::printf(" Offset every vertex and bounds by (dx, dy, dz)\n"); std::printf(" --strip-mesh [--bones] [--anims] [--all]\n"); std::printf(" Drop bones / animations from a WOM in place (smaller file, static-only use)\n"); std::printf(" --rotate-mesh \n"); std::printf(" Rotate every vertex + normal around the chosen axis by \n"); std::printf(" --center-mesh \n"); std::printf(" Translate so the bounds center lands at origin (no scale/rotation change)\n"); std::printf(" --flip-mesh-normals \n"); std::printf(" Invert every vertex normal (use for inside-out meshes or two-sided pre-flip)\n"); std::printf(" --mirror-mesh \n"); std::printf(" Mirror every vertex + normal across the chosen axis (also flips winding)\n"); std::printf(" --smooth-mesh-normals \n"); std::printf(" Recompute per-vertex normals as area-weighted averages of incident face normals\n"); std::printf(" --merge-meshes \n"); std::printf(" Combine two WOMs into one (vertex/index buffers concatenated, batches preserved)\n"); std::printf(" --add-item [id] [quality] [displayId] [itemLevel]\n"); std::printf(" Append one item entry to /items.json (auto-creates the file)\n"); std::printf(" --random-populate-zone [--seed N] [--creatures N] [--objects N]\n"); std::printf(" Add random creatures/objects to a zone (seeded for reproducibility)\n"); std::printf(" --random-populate-items [--seed N] [--count N] [--max-quality Q]\n"); std::printf(" Generate random items.json entries (seeded; quality cap defaults to epic=4)\n"); std::printf(" --gen-zone-texture-pack [--seed N]\n"); std::printf(" Drop a starter texture pack (grass/dirt/stone/brick/wood/water) into /textures/\n"); std::printf(" --gen-zone-mesh-pack [--seed N]\n"); std::printf(" Drop a starter WOM mesh pack (rock/tree/fence) into /meshes/\n"); std::printf(" --gen-zone-starter-pack [--seed N]\n"); std::printf(" Run both texture-pack + mesh-pack in one pass — full open-format bootstrap\n"); std::printf(" --gen-project-starter-pack [--seed N]\n"); std::printf(" Run starter-pack + audio-pack across every zone — full project-scope bootstrap\n"); std::printf(" --info-zone-summary [--json]\n"); std::printf(" One-glance health digest for a zone: pack counts/bytes + audit pass/fail\n"); std::printf(" --info-zone-deps [--json]\n"); std::printf(" Find textures referenced by WOMs but missing from /textures/ (broken-ref audit)\n"); std::printf(" --info-project-deps \n"); std::printf(" Run --info-zone-deps across every zone; reports per-zone PASS/FAIL + grand total\n"); std::printf(" --info-project-summary [--json]\n"); std::printf(" One-glance status table per zone in a project (BOOTSTRAPPED/PARTIAL/EMPTY)\n"); std::printf(" --gen-zone-readme [--out ]\n"); std::printf(" Auto-generate README.md from zone.json + asset inventory (writes README.md by default)\n"); std::printf(" --gen-project-readme [--out ]\n"); std::printf(" Auto-generate PROJECT.md with per-zone status + asset count rollup\n"); std::printf(" --validate-zone-pack [--json]\n"); std::printf(" Audit a zone's open-format asset pack: textures/meshes/audio counts + WOM validity\n"); std::printf(" --validate-project-packs \n"); std::printf(" Run validate-zone-pack across every zone in a project; exits 1 if any fails\n"); std::printf(" --gen-audio-tone [sampleRate] [waveform]\n"); std::printf(" Synthesize a procedural WAV (PCM-16 mono). Waveform: sine|square|triangle|saw\n"); std::printf(" --gen-audio-noise [sampleRate] [color] [seed] [amplitude]\n"); std::printf(" Synthesize procedural noise WAV. Color: white|pink|brown (default white, amp 0.5)\n"); std::printf(" --gen-audio-sweep [sampleRate] [shape]\n"); std::printf(" Synthesize frequency sweep (chirp) WAV. Shape: linear|exp (default linear)\n"); std::printf(" --gen-zone-audio-pack \n"); std::printf(" Drop a starter WAV pack (drone/chime/click/alert) into /audio/\n"); std::printf(" --gen-random-zone [tx ty] [--seed N] [--creatures N] [--objects N] [--items N]\n"); std::printf(" End-to-end: scaffold-zone + random-populate-zone + random-populate-items\n"); std::printf(" --gen-random-project [--prefix N] [--seed N] [--creatures N] [--objects N] [--items N]\n"); std::printf(" Generate random zones at once (names like Zone1, Zone2...; tile coords step)\n"); std::printf(" --info-zone-audio [--json]\n"); std::printf(" Print zone audio config (music + ambience tracks, volumes)\n"); std::printf(" --info-project-audio [--json]\n"); std::printf(" Audio config table across every zone (which zones have music/ambience set)\n"); std::printf(" --snap-zone-to-ground \n"); std::printf(" Re-snap every creature/object in a zone to actual terrain height\n"); std::printf(" --audit-zone-spawns [--threshold yards]\n"); std::printf(" List spawns whose Z is more than yards off from the terrain (default 5)\n"); std::printf(" --list-zone-spawns [--json]\n"); std::printf(" Combined creature+object listing for a zone (kind, name, position, key fields)\n"); std::printf(" --diff-zone-spawns \n"); std::printf(" Compare two zones' creature+object lists (added/removed/moved)\n"); std::printf(" --info-spawn [--json]\n"); std::printf(" Detailed view of a single creature/object spawn by index\n"); std::printf(" --list-project-spawns [--json]\n"); std::printf(" Combined creature+object listing across every zone (zone column added)\n"); std::printf(" --audit-project-spawns [--threshold yards]\n"); std::printf(" Run --audit-zone-spawns across every zone (per-zone summary + total)\n"); std::printf(" --snap-project-to-ground \n"); std::printf(" Run --snap-zone-to-ground across every zone (per-zone summary + totals)\n"); std::printf(" --list-items [--json]\n"); std::printf(" Print every item in /items.json with quality colors and key fields\n"); std::printf(" --export-zone-items-md [out.md]\n"); std::printf(" Render items.json as a Markdown table grouped by quality (rare/epic/etc.)\n"); std::printf(" --export-project-items-md [out.md]\n"); std::printf(" Project-wide items markdown: per-zone sections, project quality histogram\n"); std::printf(" --export-project-items-csv [out.csv]\n"); std::printf(" Single CSV with every item across every zone (zone column added for grouping)\n"); std::printf(" --info-item [--json]\n"); std::printf(" Detail view for one item (lookup by id, or by index if prefixed with '#')\n"); std::printf(" --set-item [--name S] [--quality N] [--displayId N] [--itemLevel N] [--stackable N]\n"); std::printf(" Edit fields on an existing item in place; only specified flags are changed\n"); std::printf(" --remove-item \n"); std::printf(" Remove item at given 0-based index from /items.json\n"); std::printf(" --copy-zone-items [--merge]\n"); std::printf(" Copy items from one zone to another (default replaces; --merge appends with re-id)\n"); std::printf(" --clone-item [newName]\n"); std::printf(" Duplicate the item at index, assign next free id (and optional name override)\n"); std::printf(" --validate-items \n"); std::printf(" Schema check on items.json: duplicate ids, quality range, required fields\n"); std::printf(" --validate-project-items \n"); std::printf(" Run --validate-items across every zone (per-zone PASS/FAIL + aggregate)\n"); std::printf(" --info-project-items [--json]\n"); std::printf(" Aggregate item counts and quality histogram across every zone in a project\n"); std::printf(" --convert-dbc-json [out.json]\n"); std::printf(" Convert one DBC file to wowee JSON sidecar format\n"); std::printf(" --convert-json-dbc [out.dbc]\n"); std::printf(" Convert a wowee JSON DBC back to binary DBC for private-server compat\n"); std::printf(" --convert-blp-png [out.png]\n"); std::printf(" Convert one BLP texture to PNG sidecar\n"); std::printf(" --convert-blp-batch \n"); std::printf(" Bulk BLP→PNG conversion across every .blp in (sidecars next to source)\n"); std::printf(" --migrate-wom [out-base]\n"); std::printf(" Upgrade an older WOM (v1/v2) to WOM3 with a default single-batch entry\n"); std::printf(" --migrate-zone \n"); std::printf(" Run --migrate-wom in-place on every WOM under \n"); std::printf(" --migrate-project \n"); std::printf(" Run --migrate-zone across every zone in \n"); std::printf(" --migrate-jsondbc [out.json]\n"); std::printf(" Auto-fix a JSON DBC sidecar: add missing format/source, sync recordCount\n"); std::printf(" --list-zones [--json] List discovered custom zones and exit\n"); std::printf(" --zone-stats [--json]\n"); std::printf(" Aggregate counts across every zone in \n"); std::printf(" --info-tilemap [--json]\n"); std::printf(" ASCII-render the 64x64 WoW ADT grid showing tile claims by zone\n"); std::printf(" --list-project-orphans [--json]\n"); std::printf(" Find .wom/.wob files in zones not referenced by any objects.json or doodad list\n"); std::printf(" --remove-project-orphans [--dry-run]\n"); std::printf(" Delete the orphan .wom/.wob files surfaced by --list-project-orphans\n"); std::printf(" --list-zone-deps [--json]\n"); std::printf(" List external M2/WMO model paths a zone references (objects + WOB doodads)\n"); std::printf(" --export-zone-deps-md [out.md]\n"); std::printf(" Markdown dep table for a zone (with on-disk presence column)\n"); std::printf(" --export-zone-spawn-png [out.png]\n"); std::printf(" Top-down PNG of creature + object spawn positions (per-tile-bounded)\n"); std::printf(" --check-zone-refs [--json]\n"); std::printf(" Verify every referenced model/quest NPC actually exists; exit 1 on missing refs\n"); std::printf(" --check-project-refs [--json]\n"); std::printf(" Run --check-zone-refs across every zone in \n"); std::printf(" --check-zone-content [--json]\n"); std::printf(" Sanity-check creature/object/quest fields for plausible values\n"); std::printf(" --check-project-content [--json]\n"); std::printf(" Run --check-zone-content across every zone in \n"); std::printf(" --for-each-zone -- \n"); std::printf(" Run for every zone in ; '{}' in cmd is replaced with the zone path\n"); std::printf(" --for-each-tile -- \n"); std::printf(" Run for every tile in ; '{}' replaced with the tile-base path\n"); std::printf(" --scaffold-zone [tx ty] Create a blank zone in custom_zones// and exit\n"); std::printf(" --mvp-zone [tx ty]\n"); std::printf(" Scaffold + add a creature + object + quest (with objective+reward) for quick demos\n"); std::printf(" --add-tile [baseHeight]\n"); std::printf(" Add a new ADT tile to an existing zone (extends the manifest's tiles list)\n"); std::printf(" --remove-tile \n"); std::printf(" Remove a tile from a zone (drops manifest entry + deletes WHM/WOT/WOC files)\n"); std::printf(" --list-tiles [--json]\n"); std::printf(" List every tile in a zone manifest with on-disk file presence\n"); std::printf(" --add-creature [displayId] [level]\n"); std::printf(" Append one creature spawn to /creatures.json and exit\n"); std::printf(" --add-object [scale]\n"); std::printf(" Append one object placement to /objects.json and exit\n"); std::printf(" --add-quest [giverId] [turnInId] [xp] [level]\n"); std::printf(" Append one quest to <zoneDir>/quests.json and exit\n"); std::printf(" --add-quest-objective <zoneDir> <questIdx> <kill|collect|talk|explore|escort|use> <targetName> [count]\n"); std::printf(" Append one objective to a quest by index\n"); std::printf(" --remove-quest-objective <zoneDir> <questIdx> <objIdx>\n"); std::printf(" Remove the objective at given 0-based index from a quest\n"); std::printf(" --clone-quest <zoneDir> <questIdx> [newTitle]\n"); std::printf(" Duplicate a quest (with all objectives + rewards) and append it\n"); std::printf(" --clone-creature <zoneDir> <idx> [newName] [dx dy dz]\n"); std::printf(" Duplicate a creature spawn (defaults: '<orig> (copy)' offset by 5 yards)\n"); std::printf(" --clone-object <zoneDir> <idx> [dx dy dz]\n"); std::printf(" Duplicate an object placement (defaults: offset by 5 yards X)\n"); std::printf(" --add-quest-reward-item <zoneDir> <questIdx> <itemPath> [more...]\n"); std::printf(" Append item reward(s) to a quest's reward.itemRewards list\n"); std::printf(" --set-quest-reward <zoneDir> <questIdx> [--xp N] [--gold N] [--silver N] [--copper N]\n"); std::printf(" Update XP/coin reward fields on a quest by index\n"); std::printf(" --remove-creature <zoneDir> <index>\n"); std::printf(" Remove creature at given 0-based index from <zoneDir>/creatures.json\n"); std::printf(" --remove-object <zoneDir> <index>\n"); std::printf(" Remove object at given 0-based index from <zoneDir>/objects.json\n"); std::printf(" --remove-quest <zoneDir> <index>\n"); std::printf(" Remove quest at given 0-based index from <zoneDir>/quests.json\n"); std::printf(" --copy-zone <srcDir> <newName>\n"); std::printf(" Duplicate a zone to custom_zones/<slug>/ with renamed slug-prefixed files\n"); std::printf(" --rename-zone <srcDir> <newName>\n"); std::printf(" In-place rename (zone.json + slug-prefixed files + dir); no copy\n"); std::printf(" --remove-zone <zoneDir> [--confirm]\n"); std::printf(" Delete a zone directory entirely (requires --confirm to actually delete)\n"); std::printf(" --clear-zone-content <zoneDir> [--creatures] [--objects] [--quests] [--all]\n"); std::printf(" Wipe one or more content files (terrain + manifest preserved)\n"); std::printf(" --strip-zone <zoneDir> [--dry-run]\n"); std::printf(" Remove derived outputs (.glb/.obj/.stl/.html/.dot/.csv/ZONE.md/DEPS.md)\n"); std::printf(" --strip-project <projectDir> [--dry-run]\n"); std::printf(" Run --strip-zone across every zone (per-zone counts + aggregate freed bytes)\n"); std::printf(" --gen-makefile <zoneDir> [out.mk]\n"); std::printf(" Generate a Makefile that rebuilds every derived output for a zone\n"); std::printf(" --gen-project-makefile <projectDir> [out.mk]\n"); std::printf(" Generate a top-level Makefile that delegates to each zone's per-zone Makefile\n"); std::printf(" --repair-project <projectDir> [--dry-run]\n"); std::printf(" Run --repair-zone across every zone (manifest drift fixes, per-zone summary)\n"); std::printf(" --repair-zone <zoneDir> [--dry-run]\n"); std::printf(" Auto-fix manifest/disk drift (missing tiles in manifest, hasCreatures flag)\n"); std::printf(" --build-woc <wot-base> Generate a WOC collision mesh from WHM/WOT and exit\n"); std::printf(" --regen-collision <zoneDir> Rebuild every WOC under a zone dir and exit\n"); std::printf(" --fix-zone <zoneDir> Re-parse + re-save zone JSONs to apply latest scrubs/caps and exit\n"); std::printf(" --export-png <wot-base> Render heightmap, normal-map, and zone-map PNG previews\n"); std::printf(" --export-obj <wom-base> [out.obj]\n"); std::printf(" Convert a WOM model to Wavefront OBJ for use in Blender/MeshLab\n"); std::printf(" --export-glb <wom-base> [out.glb]\n"); std::printf(" Convert a WOM model to glTF 2.0 binary (.glb) — modern industry standard\n"); std::printf(" --export-stl <wom-base> [out.stl]\n"); std::printf(" Convert a WOM model to ASCII STL — works with any 3D printer slicer\n"); std::printf(" --import-stl <stl-path> [wom-base]\n"); std::printf(" Convert an ASCII STL back into WOM (round-trips with --export-stl)\n"); std::printf(" --export-wob-glb <wob-base> [out.glb]\n"); std::printf(" Convert a WOB building to glTF 2.0 binary (one mesh, per-group primitives)\n"); std::printf(" --export-whm-glb <wot-base> [out.glb]\n"); std::printf(" Convert WHM heightmap to glTF 2.0 binary terrain mesh (per-chunk primitives)\n"); std::printf(" --bake-zone-glb <zoneDir> [out.glb]\n"); std::printf(" Bake every WHM tile in a zone into one glTF (one node per tile)\n"); std::printf(" --bake-zone-stl <zoneDir> [out.stl]\n"); std::printf(" Bake every WHM tile in a zone into one STL for 3D-printing the terrain\n"); std::printf(" --bake-zone-obj <zoneDir> [out.obj]\n"); std::printf(" Bake every WHM tile in a zone into one Wavefront OBJ (one g-block per tile)\n"); std::printf(" --bake-project-obj <projectDir> [out.obj]\n"); std::printf(" Bake every zone in a project into one Wavefront OBJ (one g-block per zone)\n"); std::printf(" --bake-project-stl <projectDir> [out.stl]\n"); std::printf(" Bake every zone in a project into one ASCII STL for full-project printing\n"); std::printf(" --bake-project-glb <projectDir> [out.glb]\n"); std::printf(" Bake every zone in a project into one glTF 2.0 (one mesh per zone)\n"); std::printf(" --import-obj <obj-path> [wom-base]\n"); std::printf(" Convert a Wavefront OBJ back into WOM (round-trips with --export-obj)\n"); std::printf(" --export-wob-obj <wob-base> [out.obj]\n"); std::printf(" Convert a WOB building to Wavefront OBJ (one group per WOB group)\n"); std::printf(" --import-wob-obj <obj-path> [wob-base]\n"); std::printf(" Convert a Wavefront OBJ back into WOB (round-trips with --export-wob-obj)\n"); std::printf(" --export-woc-obj <woc-path> [out.obj]\n"); std::printf(" Convert a WOC collision mesh to OBJ for visualization (per-flag color groups)\n"); std::printf(" --export-whm-obj <wot-base> [out.obj]\n"); std::printf(" Convert a WHM heightmap to OBJ terrain mesh (9x9 outer grid per chunk)\n"); std::printf(" --validate <zoneDir> [--json]\n"); std::printf(" Score zone open-format completeness and exit\n"); std::printf(" --validate-wom <wom-base> [--json]\n"); std::printf(" Deep-check a WOM file for index/bone/batch/bound invariants\n"); std::printf(" --validate-wob <wob-base> [--json]\n"); std::printf(" Deep-check a WOB file for group/portal/doodad invariants\n"); std::printf(" --validate-woc <woc-path> [--json]\n"); std::printf(" Deep-check a WOC collision mesh for finite verts and degeneracy\n"); std::printf(" --validate-whm <wot-base> [--json]\n"); std::printf(" Deep-check a WHM/WOT terrain pair for NaN heights and bad placements\n"); std::printf(" --validate-all <dir> [--json]\n"); std::printf(" Recursively run all per-format validators on every file\n"); std::printf(" --validate-project <projectDir> [--json]\n"); std::printf(" Run --validate-all on every zone in <projectDir>; exit 1 if any zone fails\n"); std::printf(" --bench-validate-project <projectDir> [--json]\n"); std::printf(" Time --validate-project per zone; report avg/min/max latency\n"); std::printf(" --bench-bake-project <projectDir> [--json]\n"); std::printf(" Time WHM/WOT load per zone (proxy for bake cost); report timings\n"); std::printf(" --validate-glb <path> [--json]\n"); std::printf(" Verify a glTF 2.0 binary's structure (magic, chunks, JSON, accessors)\n"); std::printf(" --check-glb-bounds <path> [--json]\n"); std::printf(" Verify position accessor min/max in a .glb actually matches the data\n"); std::printf(" --validate-stl <path> [--json]\n"); std::printf(" Verify an ASCII STL's structure (solid framing, facet/vertex shape, no NaN)\n"); std::printf(" --validate-png <path> [--json]\n"); std::printf(" Verify a PNG's structure (signature, chunks, CRC, IHDR/IDAT/IEND order)\n"); std::printf(" --validate-blp <path> [--json]\n"); std::printf(" Verify a BLP texture (magic, dimensions, mip offsets within file)\n"); std::printf(" --validate-jsondbc <path> [--json]\n"); std::printf(" Verify a JSON DBC sidecar's full schema (per-cell types, row width, format tag)\n"); std::printf(" --info-glb <path> [--json]\n"); std::printf(" Print glTF 2.0 binary metadata (chunks, mesh/primitive counts, accessors)\n"); std::printf(" --info-glb-bytes <path> [--json]\n"); std::printf(" Per-section + per-bufferView byte breakdown of a .glb file\n"); std::printf(" --info-glb-tree <path>\n"); std::printf(" Render glTF structure as a tree (scenes/nodes/meshes/primitives)\n"); std::printf(" --zone-summary <zoneDir> [--json]\n"); std::printf(" One-shot validate + creature/object/quest counts and exit\n"); std::printf(" --info-zone-tree <zoneDir>\n"); std::printf(" Render a hierarchical tree view of a zone's contents (no --json)\n"); std::printf(" --info-project-tree <projectDir>\n"); std::printf(" Tree view of every zone in a project with quick counts (no --json)\n"); std::printf(" --info-project-bytes <projectDir> [--json]\n"); std::printf(" Per-zone byte rollup with proprietary-vs-open category split (size audit)\n"); std::printf(" --validate-project-open-only <projectDir>\n"); std::printf(" Exit 1 if any proprietary Blizzard assets (.m2/.wmo/.blp/.dbc) remain — release gate\n"); std::printf(" --audit-project <projectDir>\n"); std::printf(" Run validate-project + open-only + check-project-refs together; one PASS/FAIL\n"); std::printf(" --bench-audit-project <projectDir>\n"); std::printf(" Time each --audit-project sub-step; shows where the slow ones are\n"); std::printf(" --info-zone-bytes <zoneDir> [--json]\n"); std::printf(" Per-file size breakdown grouped by category, sorted largest-first\n"); std::printf(" --info-project-extents <projectDir> [--json]\n"); std::printf(" Combined spatial bounding box across every zone (per-zone table + project union)\n"); std::printf(" --info-zone-extents <zoneDir> [--json]\n"); std::printf(" Compute the zone's bounding box (XY tile range, Z height min/max)\n"); std::printf(" --info-project-water <projectDir> [--json]\n"); std::printf(" Aggregate water-layer stats across every zone (per-zone breakdown + project totals)\n"); std::printf(" --info-zone-water <zoneDir> [--json]\n"); std::printf(" Aggregate water-layer stats across all tiles (layer count, types, area)\n"); std::printf(" --info-project-density <projectDir> [--json]\n"); std::printf(" Per-zone content density rollup (creatures/objects/quests per tile, project totals)\n"); std::printf(" --info-zone-density <zoneDir> [--json]\n"); std::printf(" Per-tile density (creatures/objects/quests per tile + overall avg)\n"); std::printf(" --export-zone-summary-md <zoneDir> [out.md]\n"); std::printf(" Render a markdown documentation page for a zone (manifest + content)\n"); std::printf(" --export-zone-csv <zoneDir> [outDir]\n"); std::printf(" Emit creatures.csv / objects.csv / quests.csv / items.csv for spreadsheet workflows\n"); std::printf(" --export-zone-checksum <zoneDir> [out.sha256]\n"); std::printf(" Emit a SHA-256 manifest of every source file in a zone (for integrity checks)\n"); std::printf(" --export-project-checksum <projectDir> [out.sha256]\n"); std::printf(" Project-wide SHA-256 manifest (paths are zone-relative) + single project fingerprint\n"); std::printf(" --validate-project-checksum <projectDir> [in.sha256]\n"); std::printf(" Verify PROJECT_SHA256SUMS in-tool (cross-platform, no sha256sum dependency)\n"); std::printf(" --export-zone-html <zoneDir> [out.html]\n"); std::printf(" Emit a single-file HTML viewer next to the zone .glb (model-viewer based)\n"); std::printf(" --export-project-html <projectDir> [out.html]\n"); std::printf(" Generate an index.html linking to every zone's HTML viewer in <projectDir>\n"); std::printf(" --export-project-md <projectDir> [out.md]\n"); std::printf(" Generate a README.md indexing every zone with counts + viewer/bake status\n"); std::printf(" --export-quest-graph <zoneDir> [out.dot]\n"); std::printf(" Render quest-chain DAG as Graphviz DOT (pipe to `dot -Tpng -o quests.png`)\n"); std::printf(" --info <wom-base> [--json]\n"); std::printf(" Print WOM file metadata (version, counts) and exit\n"); std::printf(" --info-batches <wom-base> [--json]\n"); std::printf(" Per-batch breakdown of a WOM3 (index range, texture, blend mode, flags)\n"); std::printf(" --info-textures <wom-base> [--json]\n"); std::printf(" List every texture path referenced by a WOM (with on-disk presence)\n"); std::printf(" --info-doodads <wob-base> [--json]\n"); std::printf(" List every doodad placement in a WOB (model path, position, rotation, scale)\n"); std::printf(" --info-attachments <m2-path> [--json]\n"); std::printf(" List M2 attachment points (weapon mounts, etc.) with bone + offset\n"); std::printf(" --info-particles <m2-path> [--json]\n"); std::printf(" List M2 particle + ribbon emitters (texture, blend, bone)\n"); std::printf(" --info-sequences <m2-path> [--json]\n"); std::printf(" List M2 animation sequences (id, duration, flags)\n"); std::printf(" --info-bones <m2-path> [--json]\n"); std::printf(" List M2 bones with parent tree, key-bone IDs, pivot offsets\n"); std::printf(" --export-bones-dot <wom-base> [out.dot]\n"); std::printf(" Render WOM bone hierarchy as Graphviz DOT (pipe to `dot -Tpng -o bones.png`)\n"); std::printf(" --list-project-meshes <projectDir> [--json]\n"); std::printf(" Project-wide WOM inventory across every zone (vert/tri totals + per-zone breakdown)\n"); std::printf(" --list-project-audio <projectDir> [--json]\n"); std::printf(" Project-wide WAV inventory across every zone (duration/bytes per zone + grand total)\n"); std::printf(" --list-project-textures <projectDir> [--json]\n"); std::printf(" Aggregate texture refs across every WOM in a project (deduped, with zone breakdown)\n"); std::printf(" --list-zone-meshes <zoneDir> [--json]\n"); std::printf(" List every WOM in <zoneDir> with vert/tri/bone/anim/batch counts and file size\n"); std::printf(" --list-zone-audio <zoneDir> [--json]\n"); std::printf(" List every WAV under <zoneDir>/audio/ with format/duration/sample-rate/size\n"); std::printf(" --list-zone-textures <zoneDir> [--json]\n"); std::printf(" Aggregate texture refs across all WOM models in a zone (deduped)\n"); std::printf(" --info-zone-models-total <zoneDir> [--json]\n"); std::printf(" Aggregate WOM/WOB stats across a zone (verts, tris, bones, batches, doodads)\n"); std::printf(" --list-zone-meshes <zoneDir> [--json]\n"); std::printf(" Per-mesh listing of every .wom in a zone (verts, tris, bones, textures, bytes)\n"); std::printf(" --info-mesh <wom-base> [--json]\n"); std::printf(" Single-mesh detail: bounds, version, batches, bones, textures, attachments in one view\n"); std::printf(" --info-mesh-storage-budget <wom-base> [--json]\n"); std::printf(" Estimated bytes-per-category breakdown for a single WOM (vertices/indices/bones/...)\n"); std::printf(" --list-project-meshes-detail <projectDir> [--json]\n"); std::printf(" Per-mesh listing across every zone in a project (sorted by triangle count)\n"); std::printf(" --info-project-models-total <projectDir> [--json]\n"); std::printf(" Aggregate WOM/WOB stats across an entire project (per-zone breakdown + totals)\n"); std::printf(" --info-wob <wob-base> [--json]\n"); std::printf(" Print WOB building metadata (groups, portals, doodads) and exit\n"); std::printf(" --info-woc <woc-path> [--json]\n"); std::printf(" Print WOC collision metadata (triangle counts, bounds) and exit\n"); std::printf(" --info-wot <wot-base> [--json]\n"); std::printf(" Print WOT/WHM terrain metadata (tile, chunks, height range) and exit\n"); std::printf(" --info-extract <dir> [--json]\n"); std::printf(" Walk extracted asset tree and report open-format coverage and exit\n"); std::printf(" --info-extract-tree <dir>\n"); std::printf(" Hierarchical view of an extracted asset tree grouped by top-level dir + format\n"); std::printf(" --info-extract-budget <dir> [--json]\n"); std::printf(" Per-extension byte breakdown of an extract dir (sized largest-first)\n"); std::printf(" --info-png <path> [--json]\n"); std::printf(" Print PNG header (width, height, channels, bit depth) and exit\n"); std::printf(" --info-blp <path> [--json]\n"); std::printf(" Print BLP texture header (format, compression, mips, dimensions) and exit\n"); std::printf(" --info-m2 <path> [--json]\n"); std::printf(" Print proprietary M2 model metadata (verts, bones, anims, particles)\n"); std::printf(" --info-wmo <path> [--json]\n"); std::printf(" Print proprietary WMO building metadata (groups, portals, doodads)\n"); std::printf(" --info-adt <path> [--json]\n"); std::printf(" Print proprietary ADT terrain metadata (chunks, placements, textures)\n"); std::printf(" --info-jsondbc <path> [--json]\n"); std::printf(" Print JSON DBC sidecar metadata (records, fields, source) and exit\n"); std::printf(" --list-missing-sidecars <dir> [--json]\n"); std::printf(" List proprietary files lacking open-format sidecars (one per line)\n"); std::printf(" --info-zone <dir|json> [--json]\n"); std::printf(" Print zone.json fields (manifest, tiles, audio, flags) and exit\n"); std::printf(" --info-zone-overview <zoneDir> [--json]\n"); std::printf(" One-line compact zone summary (tiles, biome, counts, audio status)\n"); std::printf(" --info-project-overview <projectDir> [--json]\n"); std::printf(" One-line summary per zone in a project (single-page health check)\n"); std::printf(" --copy-project <fromDir> <toDir>\n"); std::printf(" Recursively copy a project tree (every zone subdir + manifests)\n"); std::printf(" --info-creatures <p> [--json]\n"); std::printf(" Print creatures.json summary (counts, behaviors) and exit\n"); std::printf(" --info-creatures-by-faction <p> [--json]\n"); std::printf(" Histogram of creature counts grouped by faction id\n"); std::printf(" --info-creatures-by-level <p> [--json]\n"); std::printf(" Distribution of creature levels (min/max/avg + per-level counts)\n"); std::printf(" --info-objects-by-path <p> [--json]\n"); std::printf(" Histogram of object placements grouped by model path (most-used first)\n"); std::printf(" --info-objects-by-type <p> [--json]\n"); std::printf(" M2 vs WMO breakdown plus scale distribution (min/max/avg)\n"); std::printf(" --info-objects <p> [--json]\n"); std::printf(" Print objects.json summary (counts, types, scale range) and exit\n"); std::printf(" --info-quests <p> [--json]\n"); std::printf(" Print quests.json summary (counts, rewards, chain errors) and exit\n"); std::printf(" --info-quests-by-level <p> [--json]\n"); std::printf(" Distribution of required levels across quests (min/max/avg + bar chart)\n"); std::printf(" --info-quests-by-xp <p> [--json]\n"); std::printf(" Distribution of XP rewards (min/max/avg + per-bucket histogram)\n"); std::printf(" --list-creatures <p> [--json]\n"); std::printf(" List every creature with index, name, position, level (for --remove-creature)\n"); std::printf(" --list-objects <p> [--json]\n"); std::printf(" List every object with index, type, path, position\n"); std::printf(" --list-quests <p> [--json]\n"); std::printf(" List every quest with index, title, giver, XP\n"); std::printf(" --list-quest-objectives <p> <questIdx> [--json]\n"); std::printf(" List every objective on a quest (for --remove-quest-objective)\n"); std::printf(" --list-quest-rewards <p> <questIdx> [--json]\n"); std::printf(" List XP/coin/item rewards on a quest\n"); std::printf(" --info-quest-graph-stats <p> [--json]\n"); std::printf(" Analyze quest chain graph (roots, leaves, depths, cycles, orphans)\n"); std::printf(" --info-creature <p> <idx> [--json]\n"); std::printf(" Print every field for one creature spawn (stats, behavior, AI, flags)\n"); std::printf(" --info-quest <p> <idx> [--json]\n"); std::printf(" Print every field for one quest (objectives + reward + chain in one shot)\n"); std::printf(" --info-object <p> <idx> [--json]\n"); std::printf(" Print every field for one object placement (type, path, transform)\n"); std::printf(" --info-wcp <wcp-path> [--json]\n"); std::printf(" Print WCP archive metadata (name, files) and exit\n"); std::printf(" --info-pack-budget <wcp-path> [--json]\n"); std::printf(" Per-extension byte breakdown of a WCP archive (sized largest-first)\n"); std::printf(" --info-pack-tree <wcp-path>\n"); std::printf(" Render a tree view of a WCP's directory structure with byte sizes\n"); std::printf(" --list-wcp <wcp-path> Print every file inside a WCP archive (sorted by path) and exit\n"); std::printf(" --diff-wcp <a> <b> [--json]\n"); std::printf(" Compare two WCPs file-by-file; exit 0 if identical, 1 otherwise\n"); std::printf(" --diff-zone <a> <b> [--json]\n"); std::printf(" Compare two zone dirs (creatures/objects/quests/manifest); exit 0 if identical\n"); std::printf(" --diff-glb <a> <b> [--json]\n"); std::printf(" Compare two glTF 2.0 binaries structurally; exit 0 if identical\n"); std::printf(" --diff-wom <a-base> <b-base> [--json]\n"); std::printf(" Compare two WOM models (verts, indices, bones, anims, batches, bounds)\n"); std::printf(" --diff-wob <a-base> <b-base> [--json]\n"); std::printf(" Compare two WOB buildings (groups, portals, doodads, totals)\n"); std::printf(" --diff-whm <a-base> <b-base> [--json]\n"); std::printf(" Compare two WHM/WOT terrain pairs (chunks, height range, placements)\n"); std::printf(" --diff-woc <a> <b> [--json]\n"); std::printf(" Compare two WOC collision meshes (triangles, walkable/steep counts, tile)\n"); std::printf(" --diff-jsondbc <a> <b> [--json]\n"); std::printf(" Compare two JSON DBC sidecars (format/source/recordCount/fieldCount)\n"); std::printf(" --diff-extract <a> <b> [--json]\n"); std::printf(" Compare two extracted asset directories (per-extension file count + bytes)\n"); std::printf(" --diff-checksum <a> <b> [--json]\n"); std::printf(" Diff two SHA256SUMS files; report added/removed/changed entries\n"); std::printf(" --pack-wcp <zone> [dst] Pack a zone dir/name into a .wcp archive and exit\n"); std::printf(" --unpack-wcp <wcp> [dst] Extract a WCP archive (default dst=custom_zones/) and exit\n"); std::printf(" --list-commands Print every recognized --flag, one per line, and exit\n"); std::printf(" --info-cli-stats [--json]\n"); std::printf(" Meta-stats on the CLI surface (command count by category prefix)\n"); std::printf(" --info-cli-categories\n"); std::printf(" Group every --flag by verb prefix (gen/info/list/...) for discovery\n"); std::printf(" --info-cli-help <pattern>\n"); std::printf(" Substring-search the help text and print matching command lines\n"); std::printf(" --validate-cli-help [--json]\n"); std::printf(" Self-check: every kArgRequired flag must appear in the help text\n"); std::printf(" --gen-completion <bash|zsh>\n"); std::printf(" Print a shell-completion script for wowee_editor (source it from your rc file)\n"); std::printf(" --version Show version and format info\n\n"); std::printf("Wowee World Editor v1.0.0 — by Kelsi Davis\n"); std::printf("Novel open formats: WOT/WHM/WOM/WOB/WOC/WCP + PNG/JSON\n"); } 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", "--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-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", "--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 <map> <x> <y>\n"); return 1; } if (std::strcmp(argv[i], "--diff-zone") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-zone requires <zoneA> <zoneB>\n"); return 1; } if (std::strcmp(argv[i], "--diff-glb") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-glb requires <a.glb> <b.glb>\n"); return 1; } if (std::strcmp(argv[i], "--diff-wom") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-wom requires <a-base> <b-base>\n"); return 1; } if (std::strcmp(argv[i], "--diff-wob") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-wob requires <a-base> <b-base>\n"); return 1; } if (std::strcmp(argv[i], "--diff-whm") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-whm requires <a-base> <b-base>\n"); return 1; } if (std::strcmp(argv[i], "--diff-woc") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-woc requires <a.woc> <b.woc>\n"); return 1; } if (std::strcmp(argv[i], "--diff-jsondbc") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-jsondbc requires <a.json> <b.json>\n"); return 1; } if (std::strcmp(argv[i], "--diff-extract") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-extract requires <dirA> <dirB>\n"); return 1; } if (std::strcmp(argv[i], "--diff-checksum") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--diff-checksum requires <a.sha256> <b.sha256>\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 <zoneDir> <name> <x> <y> <z>\n"); return 1; } if (std::strcmp(argv[i], "--add-object") == 0 && i + 6 >= argc) { std::fprintf(stderr, "--add-object requires <zoneDir> <m2|wmo> <gamePath> <x> <y> <z>\n"); return 1; } if (std::strcmp(argv[i], "--add-quest") == 0 && i + 2 >= argc) { std::fprintf(stderr, "--add-quest requires <zoneDir> <title>\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 (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") == 0 && i + 1 < argc) { std::string base = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; // Allow either "/path/to/file.wom" or "/path/to/file"; load() expects no extension. 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, "WOM not found: %s.wom\n", base.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(base); if (jsonOut) { nlohmann::json j; j["wom"] = base + ".wom"; j["version"] = wom.version; j["name"] = wom.name; j["vertices"] = wom.vertices.size(); j["indices"] = wom.indices.size(); j["triangles"] = wom.indices.size() / 3; j["textures"] = wom.texturePaths.size(); j["bones"] = wom.bones.size(); j["animations"] = wom.animations.size(); j["batches"] = wom.batches.size(); j["boundRadius"] = wom.boundRadius; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("WOM: %s.wom\n", base.c_str()); std::printf(" version : %u%s\n", wom.version, wom.version == 3 ? " (multi-batch)" : wom.version == 2 ? " (animated)" : " (static)"); std::printf(" name : %s\n", wom.name.c_str()); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" indices : %zu (%zu tris)\n", wom.indices.size(), wom.indices.size() / 3); std::printf(" textures : %zu\n", wom.texturePaths.size()); std::printf(" bones : %zu\n", wom.bones.size()); std::printf(" animations : %zu\n", wom.animations.size()); std::printf(" batches : %zu\n", wom.batches.size()); std::printf(" boundRadius: %.2f\n", wom.boundRadius); return 0; } else if (std::strcmp(argv[i], "--info-batches") == 0 && i + 1 < argc) { // Per-batch breakdown of a WOM3 (multi-material) model. // --info shows the total batch count; this drills into each // one's index range, texture, blend mode, and flags. Useful // for debugging 'why is this submesh transparent?' or // 'which batch has the bad UV?'. 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, "WOM not found: %s.wom\n", base.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(base); // Blend modes per WoweeModel::Batch comment: // 0=opaque, 1=alpha-test, 2=alpha, 3=add auto blendName = [](uint16_t b) { switch (b) { case 0: return "opaque"; case 1: return "alpha-test"; case 2: return "alpha"; case 3: return "add"; } return "?"; }; // Flags bits: // bit 0 (0x01) = unlit // bit 1 (0x02) = two-sided // bit 2 (0x04) = no z-write auto flagsStr = [](uint16_t f) { std::string s; if (f & 0x01) s += "unlit "; if (f & 0x02) s += "two-sided "; if (f & 0x04) s += "no-zwrite "; if (s.empty()) s = "-"; else s.pop_back(); // drop trailing space return s; }; if (jsonOut) { nlohmann::json j; j["wom"] = base + ".wom"; j["version"] = wom.version; j["totalBatches"] = wom.batches.size(); nlohmann::json arr = nlohmann::json::array(); for (size_t k = 0; k < wom.batches.size(); ++k) { const auto& b = wom.batches[k]; std::string tex = (b.textureIndex < wom.texturePaths.size()) ? wom.texturePaths[b.textureIndex] : std::string("<oob>"); arr.push_back({ {"index", k}, {"indexStart", b.indexStart}, {"indexCount", b.indexCount}, {"triangles", b.indexCount / 3}, {"textureIndex", b.textureIndex}, {"texturePath", tex}, {"blendMode", b.blendMode}, {"blendName", blendName(b.blendMode)}, {"flags", b.flags}, {"flagsStr", flagsStr(b.flags)}, }); } j["batches"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("WOM batches: %s.wom (v%u, %zu batches)\n", base.c_str(), wom.version, wom.batches.size()); if (wom.batches.empty()) { std::printf(" *no batches (WOM1/WOM2 single-material model)*\n"); return 0; } std::printf(" idx iStart iCount tris blend flags texture\n"); for (size_t k = 0; k < wom.batches.size(); ++k) { const auto& b = wom.batches[k]; std::string tex = (b.textureIndex < wom.texturePaths.size()) ? wom.texturePaths[b.textureIndex] : std::string("<oob>"); std::printf(" %3zu %6u %6u %5u %-10s %-13s %s\n", k, b.indexStart, b.indexCount, b.indexCount / 3, blendName(b.blendMode), flagsStr(b.flags).c_str(), tex.c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-textures") == 0 && i + 1 < argc) { // List every texture path a WOM references, with on-disk // presence for both BLP (proprietary) and PNG (sidecar) // forms. Useful for tracking which textures are missing // before --pack-wcp would fail at runtime. 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, "WOM not found: %s.wom\n", base.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(base); namespace fs = std::filesystem; // Texture paths in WOMs are usually game-relative // ('World/Generic/Tree.blp'); resolve them against the // common Data/ root for the on-disk presence check. Skip // the check when the path doesn't exist as either an // absolute or relative file (avoids false 'missing' // reports when the user runs from outside the data root). auto checkBlp = [&](const std::string& p) { if (fs::exists(p)) return true; std::string lower = p; for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c)); if (lower.size() < 4 || lower.substr(lower.size() - 4) != ".blp") { lower += ".blp"; } return fs::exists("Data/" + lower); }; auto sidecarPng = [&](const std::string& p) { std::string base = p; if (base.size() >= 4 && (base.substr(base.size() - 4) == ".blp" || base.substr(base.size() - 4) == ".BLP")) { base = base.substr(0, base.size() - 4); } std::string png = base + ".png"; if (fs::exists(png)) return true; std::string lower = png; for (auto& c : lower) c = std::tolower(static_cast<unsigned char>(c)); return fs::exists("Data/" + lower); }; if (jsonOut) { nlohmann::json j; j["wom"] = base + ".wom"; j["textureCount"] = wom.texturePaths.size(); nlohmann::json arr = nlohmann::json::array(); for (size_t k = 0; k < wom.texturePaths.size(); ++k) { const auto& p = wom.texturePaths[k]; arr.push_back({ {"index", k}, {"path", p}, {"blpPresent", checkBlp(p)}, {"pngPresent", sidecarPng(p)}, }); } j["textures"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("WOM textures: %s.wom (%zu textures)\n", base.c_str(), wom.texturePaths.size()); if (wom.texturePaths.empty()) { std::printf(" *no texture references*\n"); return 0; } std::printf(" idx blp png path\n"); for (size_t k = 0; k < wom.texturePaths.size(); ++k) { const auto& p = wom.texturePaths[k]; std::printf(" %3zu %s %s %s\n", k, checkBlp(p) ? "y" : "-", sidecarPng(p) ? "y" : "-", p.c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-doodads") == 0 && i + 1 < argc) { // List every doodad placement in a WOB (M2 instances inside // a building). Companion to --info-textures: where one // tracks GPU resources, this tracks scene composition. 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); if (jsonOut) { nlohmann::json j; j["wob"] = base + ".wob"; j["count"] = bld.doodads.size(); nlohmann::json arr = nlohmann::json::array(); for (size_t k = 0; k < bld.doodads.size(); ++k) { const auto& d = bld.doodads[k]; arr.push_back({ {"index", k}, {"modelPath", d.modelPath}, {"position", {d.position.x, d.position.y, d.position.z}}, {"rotation", {d.rotation.x, d.rotation.y, d.rotation.z}}, {"scale", d.scale}, }); } j["doodads"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("WOB doodads: %s.wob (%zu placements)\n", base.c_str(), bld.doodads.size()); if (bld.doodads.empty()) { std::printf(" *no doodad placements*\n"); return 0; } std::printf(" idx scale pos (x, y, z) rot (x, y, z) model\n"); for (size_t k = 0; k < bld.doodads.size(); ++k) { const auto& d = bld.doodads[k]; std::printf(" %3zu %5.2f (%6.1f, %6.1f, %6.1f) (%6.1f, %6.1f, %6.1f) %s\n", k, d.scale, d.position.x, d.position.y, d.position.z, d.rotation.x, d.rotation.y, d.rotation.z, d.modelPath.c_str()); } return 0; } else if ((std::strcmp(argv[i], "--info-attachments") == 0 || std::strcmp(argv[i], "--info-particles") == 0 || std::strcmp(argv[i], "--info-sequences") == 0) && i + 1 < argc) { // Three M2 inspectors share an entry point — they all need // the same M2Loader::load + skin merge dance, then differ // only in which sub-array they iterate. enum Kind { kAttach, kParticle, kSequence }; Kind kind; const char* cmdName; if (std::strcmp(argv[i], "--info-attachments") == 0) { kind = kAttach; cmdName = "info-attachments"; } else if (std::strcmp(argv[i], "--info-particles") == 0) { kind = kParticle; cmdName = "info-particles"; } else { kind = kSequence; cmdName = "info-sequences"; } std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "%s: cannot open %s\n", cmdName, path.c_str()); return 1; } std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); // Auto-merge skin for vertex/index counts to match render. std::vector<uint8_t> skinBytes; { std::string skinPath = path; auto dot = skinPath.rfind('.'); if (dot != std::string::npos) skinPath = skinPath.substr(0, dot) + "00.skin"; std::ifstream sf(skinPath, std::ios::binary); if (sf) { skinBytes.assign((std::istreambuf_iterator<char>(sf)), std::istreambuf_iterator<char>()); } } auto m2 = wowee::pipeline::M2Loader::load(bytes); if (!skinBytes.empty()) { wowee::pipeline::M2Loader::loadSkin(skinBytes, m2); } if (kind == kAttach) { if (jsonOut) { nlohmann::json j; j["m2"] = path; j["count"] = m2.attachments.size(); nlohmann::json arr = nlohmann::json::array(); for (size_t k = 0; k < m2.attachments.size(); ++k) { const auto& a = m2.attachments[k]; arr.push_back({ {"index", k}, {"id", a.id}, {"bone", a.bone}, {"position", {a.position.x, a.position.y, a.position.z}} }); } j["attachments"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("M2 attachments: %s (%zu)\n", path.c_str(), m2.attachments.size()); if (m2.attachments.empty()) { std::printf(" *no attachments*\n"); return 0; } std::printf(" idx id bone pos (x, y, z)\n"); for (size_t k = 0; k < m2.attachments.size(); ++k) { const auto& a = m2.attachments[k]; std::printf(" %3zu %3u %4u (%6.2f, %6.2f, %6.2f)\n", k, a.id, a.bone, a.position.x, a.position.y, a.position.z); } return 0; } if (kind == kParticle) { auto blendName = [](uint8_t b) { switch (b) { case 0: return "opaque"; case 1: return "alphakey"; case 2: return "alpha"; case 4: return "add"; } return "?"; }; if (jsonOut) { nlohmann::json j; j["m2"] = path; j["particleEmitters"] = m2.particleEmitters.size(); j["ribbonEmitters"] = m2.ribbonEmitters.size(); nlohmann::json parts = nlohmann::json::array(); for (size_t k = 0; k < m2.particleEmitters.size(); ++k) { const auto& p = m2.particleEmitters[k]; parts.push_back({ {"index", k}, {"particleId", p.particleId}, {"bone", p.bone}, {"texture", p.texture}, {"blendingType", p.blendingType}, {"blendName", blendName(p.blendingType)}, {"emitterType", p.emitterType}, {"position", {p.position.x, p.position.y, p.position.z}} }); } j["particles"] = parts; nlohmann::json ribbons = nlohmann::json::array(); for (size_t k = 0; k < m2.ribbonEmitters.size(); ++k) { const auto& r = m2.ribbonEmitters[k]; ribbons.push_back({ {"index", k}, {"ribbonId", r.ribbonId}, {"bone", r.bone}, {"textureIndex", r.textureIndex}, {"materialIndex", r.materialIndex}, {"position", {r.position.x, r.position.y, r.position.z}} }); } j["ribbons"] = ribbons; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("M2 emitters: %s\n", path.c_str()); std::printf(" particles: %zu, ribbons: %zu\n", m2.particleEmitters.size(), m2.ribbonEmitters.size()); if (!m2.particleEmitters.empty()) { std::printf("\n Particles:\n"); std::printf(" idx id bone tex blend type pos (x, y, z)\n"); for (size_t k = 0; k < m2.particleEmitters.size(); ++k) { const auto& p = m2.particleEmitters[k]; std::printf(" %3zu %3d %4u %3u %-8s %4u (%5.1f, %5.1f, %5.1f)\n", k, p.particleId, p.bone, p.texture, blendName(p.blendingType), p.emitterType, p.position.x, p.position.y, p.position.z); } } if (!m2.ribbonEmitters.empty()) { std::printf("\n Ribbons:\n"); std::printf(" idx id bone tex mat pos (x, y, z)\n"); for (size_t k = 0; k < m2.ribbonEmitters.size(); ++k) { const auto& r = m2.ribbonEmitters[k]; std::printf(" %3zu %3d %4u %3u %3u (%5.1f, %5.1f, %5.1f)\n", k, r.ribbonId, r.bone, r.textureIndex, r.materialIndex, r.position.x, r.position.y, r.position.z); } } return 0; } // kind == kSequence if (jsonOut) { nlohmann::json j; j["m2"] = path; j["count"] = m2.sequences.size(); nlohmann::json arr = nlohmann::json::array(); for (size_t k = 0; k < m2.sequences.size(); ++k) { const auto& s = m2.sequences[k]; arr.push_back({ {"index", k}, {"id", s.id}, {"variation", s.variationIndex}, {"durationMs", s.duration}, {"flags", s.flags}, {"movingSpeed", s.movingSpeed}, {"frequency", s.frequency}, {"blendTimeMs", s.blendTime} }); } j["sequences"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("M2 sequences: %s (%zu)\n", path.c_str(), m2.sequences.size()); if (m2.sequences.empty()) { std::printf(" *no sequences*\n"); return 0; } std::printf(" idx id var duration flags speed blend\n"); for (size_t k = 0; k < m2.sequences.size(); ++k) { const auto& s = m2.sequences[k]; std::printf(" %3zu %3u %3u %8u %5u %5.2f %5u\n", k, s.id, s.variationIndex, s.duration, s.flags, s.movingSpeed, s.blendTime); } return 0; } else if (std::strcmp(argv[i], "--info-bones") == 0 && i + 1 < argc) { // Inspect M2 bone tree. Shows parent index, key-bone ID // (-1 if not a named bone), pivot offset, and a depth // indicator computed by walking up parents — useful for // debugging skeleton structure when something looks wrong // in the renderer ('why is this bone not following its parent?'). std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "info-bones: cannot open %s\n", path.c_str()); return 1; } std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); auto m2 = wowee::pipeline::M2Loader::load(bytes); // Compute depth per bone — guard against cycles by capping // walk length at boneCount (a real DAG can't exceed that). std::vector<int> depths(m2.bones.size(), -1); for (size_t k = 0; k < m2.bones.size(); ++k) { int d = 0; int idx = static_cast<int>(k); while (idx >= 0 && d <= static_cast<int>(m2.bones.size())) { int parent = m2.bones[idx].parentBone; if (parent < 0) break; idx = parent; d++; } depths[k] = d; } if (jsonOut) { nlohmann::json j; j["m2"] = path; j["count"] = m2.bones.size(); nlohmann::json arr = nlohmann::json::array(); for (size_t k = 0; k < m2.bones.size(); ++k) { const auto& b = m2.bones[k]; arr.push_back({ {"index", k}, {"keyBoneId", b.keyBoneId}, {"parent", b.parentBone}, {"flags", b.flags}, {"depth", depths[k]}, {"pivot", {b.pivot.x, b.pivot.y, b.pivot.z}} }); } j["bones"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("M2 bones: %s (%zu)\n", path.c_str(), m2.bones.size()); if (m2.bones.empty()) { std::printf(" *no bones (static model)*\n"); return 0; } std::printf(" idx parent depth keyBone flags pivot (x, y, z)\n"); for (size_t k = 0; k < m2.bones.size(); ++k) { const auto& b = m2.bones[k]; // Indent the keyBone column by depth so the tree shape // is visible at a glance. std::printf(" %3zu %6d %5d %7d %5u (%6.2f, %6.2f, %6.2f)\n", k, b.parentBone, depths[k], b.keyBoneId, b.flags, b.pivot.x, b.pivot.y, b.pivot.z); } return 0; } else if (std::strcmp(argv[i], "--export-bones-dot") == 0 && i + 1 < argc) { // Render WOM bone hierarchy as Graphviz DOT. Mirrors // --export-quest-graph for skeleton trees: trying to read // a 50-bone tree from --info-bones output is painful; // pipe this through `dot -Tpng` for the picture. 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) == ".wom") base = base.substr(0, base.size() - 4); if (!wowee::pipeline::WoweeModelLoader::exists(base)) { std::fprintf(stderr, "export-bones-dot: WOM not found: %s.wom\n", base.c_str()); return 1; } if (outPath.empty()) outPath = base + ".bones.dot"; auto wom = wowee::pipeline::WoweeModelLoader::load(base); std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-bones-dot: cannot write %s\n", outPath.c_str()); return 1; } out << "digraph BoneTree {\n"; out << " // Generated by wowee_editor --export-bones-dot\n"; out << " rankdir=TB;\n"; out << " node [shape=box, style=filled, fontname=\"sans-serif\", fontsize=10];\n"; // Color: green for keybones (named anchor points), gray for // internal/blend bones. Root bones (parent=-1) get yellow border. for (size_t k = 0; k < wom.bones.size(); ++k) { const auto& b = wom.bones[k]; bool isKey = (b.keyBoneId >= 0); std::string fill = isKey ? "lightgreen" : "lightgrey"; std::string label = "[" + std::to_string(k) + "]"; if (isKey) label += "\\nkey=" + std::to_string(b.keyBoneId); out << " b" << k << " [label=\"" << label << "\", fillcolor=" << fill; if (b.parentBone == -1) out << ", penwidth=2, color=goldenrod"; out << "];\n"; } // Edges: child -> parent (parent is up). int rootCount = 0; for (size_t k = 0; k < wom.bones.size(); ++k) { int16_t p = wom.bones[k].parentBone; if (p < 0 || p >= static_cast<int16_t>(wom.bones.size())) { rootCount++; continue; } out << " b" << p << " -> b" << k << ";\n"; } out << "}\n"; out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" %zu bones, %d root(s)\n", wom.bones.size(), rootCount); std::printf(" next: dot -Tpng %s -o bones.png\n", outPath.c_str()); return 0; } else if (std::strcmp(argv[i], "--list-zone-meshes") == 0 && i + 1 < argc) { // Inventory every WOM in a zone with quick stats: file // size, vert/tri/bone/anim/batch counts. Companion to // --list-zone-textures (which counts inbound texture // refs); this answers "which meshes ship with this // zone and how heavy is each." 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-meshes: %s has no zone.json\n", zoneDir.c_str()); return 1; } struct Row { std::string path; uint64_t bytes = 0; size_t verts = 0, tris = 0; size_t bones = 0, anims = 0, batches = 0, textures = 0; }; 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; Row r; r.path = fs::relative(e.path(), zoneDir).string(); r.bytes = static_cast<uint64_t>(e.file_size()); std::string base = e.path().string(); base = base.substr(0, base.size() - 4); auto wom = wowee::pipeline::WoweeModelLoader::load(base); r.verts = wom.vertices.size(); r.tris = wom.indices.size() / 3; r.bones = wom.bones.size(); r.anims = wom.animations.size(); r.batches = wom.batches.size(); r.textures = wom.texturePaths.size(); rows.push_back(std::move(r)); } std::sort(rows.begin(), rows.end(), [](const Row& a, const Row& b) { return a.path < b.path; }); uint64_t totalBytes = 0; size_t totalVerts = 0, totalTris = 0; for (const auto& r : rows) { totalBytes += r.bytes; totalVerts += r.verts; totalTris += r.tris; } if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["meshCount"] = rows.size(); j["totalBytes"] = totalBytes; j["totalVerts"] = totalVerts; j["totalTris"] = totalTris; nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({ {"path", r.path}, {"bytes", r.bytes}, {"verts", r.verts}, {"tris", r.tris}, {"bones", r.bones}, {"anims", r.anims}, {"batches", r.batches}, {"textures", r.textures}, }); } 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(" total bytes : %llu\n", static_cast<unsigned long long>(totalBytes)); std::printf(" total verts : %zu\n", totalVerts); std::printf(" total tris : %zu\n", totalTris); if (rows.empty()) { std::printf(" *no .wom files found*\n"); return 0; } std::printf("\n %8s %7s %7s %4s %4s %4s %4s %s\n", "bytes", "verts", "tris", "bone", "anim", "batc", "tex", "path"); for (const auto& r : rows) { std::printf(" %8llu %7zu %7zu %4zu %4zu %4zu %4zu %s\n", static_cast<unsigned long long>(r.bytes), r.verts, r.tris, r.bones, r.anims, r.batches, r.textures, r.path.c_str()); } return 0; } else if (std::strcmp(argv[i], "--list-zone-audio") == 0 && i + 1 < argc) { // Inventory every WAV under <zoneDir>/audio/ with quick // stats parsed straight from the RIFF/WAVE header: // sample rate, channel count, bits per sample, duration. // Companion to --list-zone-meshes / --list-zone-textures // — completes the per-zone asset accounting trio. 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-audio: %s has no zone.json\n", zoneDir.c_str()); return 1; } struct Row { std::string path; uint64_t bytes = 0; uint32_t sampleRate = 0; uint16_t channels = 0; uint16_t bitsPerSample = 0; float duration = 0.0f; bool valid = false; }; std::vector<Row> rows; std::error_code ec; // Limit the search to <zoneDir>/audio/ (matches the // gen-zone-audio-pack output convention) so we don't // walk the entire zone tree looking for stray WAVs. fs::path audioDir = fs::path(zoneDir) / "audio"; if (!fs::exists(audioDir)) { std::printf("Zone audio: %s\n", zoneDir.c_str()); std::printf(" *no audio/ subdirectory*\n"); return 0; } for (const auto& e : fs::recursive_directory_iterator(audioDir, ec)) { if (!e.is_regular_file()) continue; if (e.path().extension() != ".wav") continue; Row r; r.path = fs::relative(e.path(), zoneDir).string(); r.bytes = static_cast<uint64_t>(e.file_size()); FILE* f = std::fopen(e.path().c_str(), "rb"); if (f) { // RIFF header: 12 bytes (RIFF + size + WAVE). // fmt chunk follows: "fmt " + 16 + format(2) + // channels(2) + sampleRate(4) + byteRate(4) + // blockAlign(2) + bitsPerSample(2). We only // peek the 4-byte fields we care about. char hdr[44]; if (std::fread(hdr, 1, 44, f) == 44 && std::memcmp(hdr, "RIFF", 4) == 0 && std::memcmp(hdr + 8, "WAVE", 4) == 0 && std::memcmp(hdr + 12, "fmt ", 4) == 0) { std::memcpy(&r.channels, hdr + 22, 2); std::memcpy(&r.sampleRate, hdr + 24, 4); std::memcpy(&r.bitsPerSample, hdr + 34, 2); uint32_t dataBytes = 0; std::memcpy(&dataBytes, hdr + 40, 4); if (r.sampleRate > 0 && r.channels > 0 && r.bitsPerSample > 0) { uint32_t bytesPerSample = static_cast<uint32_t>(r.channels) * (r.bitsPerSample / 8); if (bytesPerSample > 0) { r.duration = static_cast<float>(dataBytes) / (r.sampleRate * bytesPerSample); } r.valid = true; } } std::fclose(f); } rows.push_back(std::move(r)); } std::sort(rows.begin(), rows.end(), [](const Row& a, const Row& b) { return a.path < b.path; }); uint64_t totalBytes = 0; float totalDuration = 0.0f; for (const auto& r : rows) { totalBytes += r.bytes; totalDuration += r.duration; } if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["wavCount"] = rows.size(); j["totalBytes"] = totalBytes; j["totalDuration"] = totalDuration; nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({ {"path", r.path}, {"bytes", r.bytes}, {"sampleRate", r.sampleRate}, {"channels", r.channels}, {"bitsPerSample", r.bitsPerSample}, {"duration", r.duration}, {"valid", r.valid}, }); } j["audio"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone audio: %s\n", zoneDir.c_str()); std::printf(" WAVs : %zu\n", rows.size()); std::printf(" total bytes : %llu\n", static_cast<unsigned long long>(totalBytes)); std::printf(" total duration : %.2f sec\n", totalDuration); if (rows.empty()) { std::printf(" *no .wav files found in audio/*\n"); return 0; } std::printf("\n %8s %6s %4s %4s %7s %s\n", "bytes", "rate", "ch", "bit", "sec", "path"); for (const auto& r : rows) { if (r.valid) { std::printf(" %8llu %6u %4u %4u %7.2f %s\n", static_cast<unsigned long long>(r.bytes), r.sampleRate, static_cast<unsigned>(r.channels), static_cast<unsigned>(r.bitsPerSample), r.duration, r.path.c_str()); } else { std::printf(" %8llu ? ? ? ? %s (invalid header)\n", static_cast<unsigned long long>(r.bytes), r.path.c_str()); } } return 0; } else if (std::strcmp(argv[i], "--list-zone-textures") == 0 && i + 1 < argc) { // Aggregate texture references across every WOM model in a // zone directory. Companion to --list-zone-deps (which lists // model paths) — this lists the textures those models pull in. // Useful for verifying every BLP/PNG ships with the zone. 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-textures: %s has no zone.json\n", zoneDir.c_str()); return 1; } std::map<std::string, int> texHist; // path -> count of WOMs that ref it int womCount = 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 != ".wom") continue; womCount++; std::string base = e.path().string(); if (base.size() >= 4) base = base.substr(0, base.size() - 4); auto wom = wowee::pipeline::WoweeModelLoader::load(base); std::unordered_set<std::string> seenInThisWom; for (const auto& tp : wom.texturePaths) { if (tp.empty()) continue; if (seenInThisWom.insert(tp).second) { texHist[tp]++; } } } if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["womCount"] = womCount; j["uniqueTextures"] = texHist.size(); nlohmann::json arr = nlohmann::json::array(); for (const auto& [path, count] : texHist) { arr.push_back({{"path", path}, {"refCount", count}}); } j["textures"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone textures: %s\n", zoneDir.c_str()); std::printf(" WOMs scanned : %d\n", womCount); std::printf(" unique textures : %zu\n", texHist.size()); if (texHist.empty()) { std::printf(" *no texture references*\n"); return 0; } std::printf("\n refs path\n"); for (const auto& [path, count] : texHist) { std::printf(" %4d %s\n", count, path.c_str()); } return 0; } else if (std::strcmp(argv[i], "--list-project-meshes") == 0 && i + 1 < argc) { // Project-wide companion to --list-zone-meshes. Walks // every zone in <projectDir> and reports a per-zone // WOM count + total bytes/verts/tris row, plus a project // grand total. Useful for "how heavy is the whole project // mesh-wise" budgeting before packaging. 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-meshes: %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 meshCount = 0; uint64_t bytes = 0; size_t verts = 0; size_t tris = 0; }; std::vector<ZRow> rows; std::error_code ec; for (const auto& z : zones) { ZRow r; r.name = fs::path(z).filename().string(); fs::path meshDir = fs::path(z) / "meshes"; if (fs::exists(meshDir)) { for (const auto& e : fs::recursive_directory_iterator(meshDir, ec)) { if (!e.is_regular_file()) continue; if (e.path().extension() != ".wom") continue; r.meshCount++; r.bytes += e.file_size(); std::string base = e.path().string(); base = base.substr(0, base.size() - 4); auto wom = wowee::pipeline::WoweeModelLoader::load(base); r.verts += wom.vertices.size(); r.tris += wom.indices.size() / 3; } } rows.push_back(std::move(r)); } int totalMeshes = 0; uint64_t totalBytes = 0; size_t totalVerts = 0, totalTris = 0; for (const auto& r : rows) { totalMeshes += r.meshCount; totalBytes += r.bytes; totalVerts += r.verts; totalTris += r.tris; } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = rows.size(); j["totalMeshes"] = totalMeshes; j["totalBytes"] = totalBytes; j["totalVerts"] = totalVerts; j["totalTris"] = totalTris; nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({ {"zone", r.name}, {"meshes", r.meshCount}, {"bytes", r.bytes}, {"verts", r.verts}, {"tris", r.tris}, }); } j["zones"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project meshes: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", rows.size()); std::printf(" total meshes : %d\n", totalMeshes); std::printf(" total bytes : %llu\n", static_cast<unsigned long long>(totalBytes)); std::printf(" total verts : %zu\n", totalVerts); std::printf(" total tris : %zu\n", totalTris); if (rows.empty()) { std::printf(" *no zones found*\n"); return 0; } std::printf("\n %5s %8s %8s %8s %s\n", "wom", "bytes", "verts", "tris", "zone"); for (const auto& r : rows) { std::printf(" %5d %8llu %8zu %8zu %s\n", r.meshCount, static_cast<unsigned long long>(r.bytes), r.verts, r.tris, r.name.c_str()); } return 0; } else if (std::strcmp(argv[i], "--list-project-audio") == 0 && i + 1 < argc) { // Project-wide companion to --list-zone-audio. Walks // every zone in <projectDir> and reports per-zone WAV // count + total bytes/duration, plus a project grand // total. Uses the same RIFF/WAVE header parse as // list-zone-audio. Completes the project-scope // inventory trio (meshes, textures, audio). 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-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 ZRow { std::string name; int wavCount = 0; uint64_t bytes = 0; float duration = 0.0f; }; std::vector<ZRow> rows; std::error_code ec; for (const auto& z : zones) { ZRow r; r.name = fs::path(z).filename().string(); fs::path audDir = fs::path(z) / "audio"; if (fs::exists(audDir)) { for (const auto& e : fs::recursive_directory_iterator(audDir, ec)) { if (!e.is_regular_file()) continue; if (e.path().extension() != ".wav") continue; r.wavCount++; r.bytes += e.file_size(); FILE* f = std::fopen(e.path().c_str(), "rb"); if (f) { char hdr[44]; if (std::fread(hdr, 1, 44, f) == 44 && std::memcmp(hdr, "RIFF", 4) == 0 && std::memcmp(hdr + 8, "WAVE", 4) == 0) { uint16_t channels = 0, bps = 0; uint32_t rate = 0, dataBytes = 0; std::memcpy(&channels, hdr + 22, 2); std::memcpy(&rate, hdr + 24, 4); std::memcpy(&bps, hdr + 34, 2); std::memcpy(&dataBytes, hdr + 40, 4); if (rate > 0 && channels > 0 && bps > 0) { uint32_t bytesPerSample = static_cast<uint32_t>(channels) * (bps / 8); if (bytesPerSample > 0) { r.duration += static_cast<float>(dataBytes) / (rate * bytesPerSample); } } } std::fclose(f); } } } rows.push_back(std::move(r)); } int totalWavs = 0; uint64_t totalBytes = 0; float totalDuration = 0.0f; for (const auto& r : rows) { totalWavs += r.wavCount; totalBytes += r.bytes; totalDuration += r.duration; } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = rows.size(); j["totalWavs"] = totalWavs; j["totalBytes"] = totalBytes; j["totalDuration"] = totalDuration; nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({ {"zone", r.name}, {"wavs", r.wavCount}, {"bytes", r.bytes}, {"duration", r.duration}, }); } 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", rows.size()); std::printf(" total wavs : %d\n", totalWavs); std::printf(" total bytes : %llu\n", static_cast<unsigned long long>(totalBytes)); std::printf(" total duration : %.2f sec\n", totalDuration); if (rows.empty()) { std::printf(" *no zones found*\n"); return 0; } std::printf("\n %5s %8s %8s %s\n", "wavs", "bytes", "sec", "zone"); for (const auto& r : rows) { std::printf(" %5d %8llu %8.2f %s\n", r.wavCount, static_cast<unsigned long long>(r.bytes), r.duration, r.name.c_str()); } return 0; } else if (std::strcmp(argv[i], "--list-project-textures") == 0 && i + 1 < argc) { // Project-wide companion to --list-zone-textures. Walks every // zone in <projectDir>, collects unique texture refs across // all WOMs, and reports a per-zone WOM/texture count plus // the global deduped texture set with usage counts. Useful // for "how many textures do I need to ship across the whole // project" — texture sharing across zones often makes the // global set much smaller than the per-zone sum. 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-textures: %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; int uniqueTextures = 0; }; std::vector<ZRow> rows; // path -> count of WOMs that ref it (project-wide) std::map<std::string, int> globalHist; int totalWoms = 0; for (const auto& zoneDir : zones) { ZRow r; r.name = fs::path(zoneDir).filename().string(); std::unordered_set<std::string> zoneSet; 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; r.womCount++; std::string base = e.path().string(); if (base.size() >= 4) base = base.substr(0, base.size() - 4); auto wom = wowee::pipeline::WoweeModelLoader::load(base); std::unordered_set<std::string> seenInThisWom; for (const auto& tp : wom.texturePaths) { if (tp.empty()) continue; if (seenInThisWom.insert(tp).second) { globalHist[tp]++; zoneSet.insert(tp); } } } r.uniqueTextures = static_cast<int>(zoneSet.size()); totalWoms += r.womCount; rows.push_back(r); } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = zones.size(); j["totalWoms"] = totalWoms; j["uniqueTextures"] = globalHist.size(); nlohmann::json zarr = nlohmann::json::array(); for (const auto& r : rows) { zarr.push_back({{"name", r.name}, {"womCount", r.womCount}, {"uniqueTextures", r.uniqueTextures}}); } j["zones"] = zarr; nlohmann::json tarr = nlohmann::json::array(); for (const auto& [p, c] : globalHist) { tarr.push_back({{"path", p}, {"refCount", c}}); } j["textures"] = tarr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project textures: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" WOMs scanned : %d\n", totalWoms); std::printf(" unique textures : %zu (deduped project-wide)\n", globalHist.size()); std::printf("\n zone WOMs uniq-tex\n"); for (const auto& r : rows) { std::printf(" %-26s %4d %7d\n", r.name.substr(0, 26).c_str(), r.womCount, r.uniqueTextures); } if (globalHist.empty()) { std::printf("\n *no texture references*\n"); return 0; } std::printf("\n refs texture path (project-global)\n"); for (const auto& [path, count] : globalHist) { std::printf(" %4d %s\n", count, path.c_str()); } return 0; } 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") == 0 && i + 1 < argc) { // Per-mesh breakdown of every .wom file in <zoneDir>. // Complements --info-zone-models-total (aggregate) // by surfacing individual mesh metrics — useful for // spotting outliers ("which mesh is using 80% of my // triangle budget?") and for content audits. // // Sorted by triangle count descending so the heaviest // meshes float to the top of the table. 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: %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], "--list-project-meshes-detail") == 0 && i + 1 < argc) { // Per-mesh sorted listing across an entire project. // Walks every zone in <projectDir>, collects every .wom, // sorts by triangle count descending, and reports a // global per-mesh table with the originating zone in // the first column. // // Useful for project-wide outlier detection ("which mesh // anywhere in the project is the heaviest?") and for // mesh-sharing audits. Companion to --list-project-meshes // (which gives the per-zone aggregate view). 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-meshes-detail: %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 zone, path; size_t verts, tris, bones, batches, textures; uint64_t bytes; uint32_t version; }; std::vector<Row> rows; 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; 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.zone = zoneName; 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["project"] = projectDir; j["zoneCount"] = zones.size(); 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({{"zone", r.zone}, {"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("Project meshes: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); 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 any zone*\n"); return 0; } std::printf("\n zone v verts tris bones bytes path\n"); for (const auto& r : rows) { std::printf(" %-22s v%u %6zu %6zu %5zu %7llu %s\n", r.zone.substr(0, 22).c_str(), r.version, r.verts, r.tris, r.bones, 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], "--info-quests") == 0 && i + 1 < argc) { std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "Failed to load quests.json: %s\n", path.c_str()); return 1; } const auto& quests = qe.getQuests(); int chained = 0, withReward = 0, withItems = 0; int objKill = 0, objCollect = 0, objTalk = 0; uint32_t totalXp = 0; for (const auto& q : quests) { if (q.nextQuestId != 0) chained++; if (q.reward.xp > 0 || q.reward.gold > 0 || q.reward.silver > 0 || q.reward.copper > 0) withReward++; if (!q.reward.itemRewards.empty()) withItems++; totalXp += q.reward.xp; using OT = wowee::editor::QuestObjectiveType; for (const auto& obj : q.objectives) { if (obj.type == OT::KillCreature) objKill++; else if (obj.type == OT::CollectItem) objCollect++; else if (obj.type == OT::TalkToNPC) objTalk++; } } std::vector<std::string> errors; qe.validateChains(errors); if (jsonOut) { nlohmann::json j; j["file"] = path; j["total"] = quests.size(); j["chained"] = chained; j["withReward"] = withReward; j["withItems"] = withItems; j["totalXp"] = totalXp; j["avgXpPerQuest"] = quests.empty() ? 0.0 : double(totalXp) / quests.size(); j["objectives"] = {{"kill", objKill}, {"collect", objCollect}, {"talk", objTalk}}; j["chainErrors"] = errors; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("quests.json: %s\n", path.c_str()); std::printf(" total : %zu\n", quests.size()); std::printf(" chained : %d (have nextQuestId)\n", chained); std::printf(" with reward : %d\n", withReward); std::printf(" with items : %d\n", withItems); std::printf(" total XP : %u (avg %.0f per quest)\n", totalXp, quests.empty() ? 0.0 : double(totalXp) / quests.size()); std::printf(" objectives : %d kill, %d collect, %d talk\n", objKill, objCollect, objTalk); if (!errors.empty()) { std::printf(" chain errors: %zu\n", errors.size()); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-objects") == 0 && i + 1 < argc) { std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::ObjectPlacer placer; if (!placer.loadFromFile(path)) { std::fprintf(stderr, "Failed to load objects.json: %s\n", path.c_str()); return 1; } const auto& objs = placer.getObjects(); int m2Count = 0, wmoCount = 0; std::unordered_map<std::string, int> pathHist; float minScale = 1e30f, maxScale = -1e30f; for (const auto& o : objs) { if (o.type == wowee::editor::PlaceableType::M2) m2Count++; else if (o.type == wowee::editor::PlaceableType::WMO) wmoCount++; pathHist[o.path]++; if (o.scale < minScale) minScale = o.scale; if (o.scale > maxScale) maxScale = o.scale; } if (jsonOut) { nlohmann::json j; j["file"] = path; j["total"] = objs.size(); j["m2"] = m2Count; j["wmo"] = wmoCount; j["uniquePaths"] = pathHist.size(); if (!objs.empty()) { j["scaleMin"] = minScale; j["scaleMax"] = maxScale; } std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("objects.json: %s\n", path.c_str()); std::printf(" total : %zu\n", objs.size()); std::printf(" M2 doodads : %d\n", m2Count); std::printf(" WMO buildings: %d\n", wmoCount); std::printf(" unique paths: %zu\n", pathHist.size()); if (!objs.empty()) { std::printf(" scale range : [%.2f, %.2f]\n", minScale, maxScale); } return 0; } else if (std::strcmp(argv[i], "--info-extract") == 0 && i + 1 < argc) { // Walk an extracted-asset directory and report counts by // extension + open-format coverage. Useful for seeing whether // a user ran asset_extract with --emit-open. std::string dataDir = 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(dataDir)) { std::fprintf(stderr, "info-extract: %s does not exist\n", dataDir.c_str()); return 1; } // Per-format counts. Pair proprietary with open-format sidecar // so the report can show coverage percentages. Track bytes // separately for proprietary vs open so the user can see how // much disk a "purge proprietary after open conversion" // workflow would save (or cost — open formats are sometimes // larger, e.g. PNG vs DXT-compressed BLP). uint64_t blpCount = 0, pngSidecar = 0; uint64_t dbcCount = 0, jsonSidecar = 0; uint64_t m2Count = 0, womSidecar = 0; uint64_t wmoCount = 0, wobSidecar = 0; uint64_t adtCount = 0, whmSidecar = 0; uint64_t totalBytes = 0; uint64_t propBytes = 0, openBytes = 0; for (auto& entry : fs::recursive_directory_iterator(dataDir)) { if (!entry.is_regular_file()) continue; uint64_t fsz = entry.file_size(); totalBytes += fsz; std::string ext = entry.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); std::string base = entry.path().string(); if (base.size() > ext.size()) base = base.substr(0, base.size() - ext.size()); auto sidecarExists = [&](const char* sidecarExt) { return fs::exists(base + sidecarExt); }; if (ext == ".blp") { blpCount++; propBytes += fsz; if (sidecarExists(".png")) pngSidecar++; } else if (ext == ".dbc") { dbcCount++; propBytes += fsz; if (sidecarExists(".json")) jsonSidecar++; } else if (ext == ".m2") { m2Count++; propBytes += fsz; if (sidecarExists(".wom")) womSidecar++; } else if (ext == ".wmo") { propBytes += fsz; std::string fname = entry.path().filename().string(); auto under = fname.rfind('_'); bool isGroup = (under != std::string::npos && fname.size() - under == 8); if (!isGroup) { wmoCount++; if (sidecarExists(".wob")) wobSidecar++; } } else if (ext == ".adt") { adtCount++; propBytes += fsz; if (sidecarExists(".whm")) whmSidecar++; } else if (ext == ".png" || ext == ".json" || ext == ".wom" || ext == ".wob" || ext == ".whm" || ext == ".wot" || ext == ".woc") { openBytes += fsz; } } auto pct = [](uint64_t x, uint64_t total) { return total == 0 ? 0.0 : (100.0 * x) / total; }; if (jsonOut) { // Machine-readable summary for CI scripts; matches the // structure of the human-readable lines below. nlohmann::json j; j["dir"] = dataDir; j["totalBytes"] = totalBytes; j["proprietaryBytes"] = propBytes; j["openBytes"] = openBytes; auto fmtFmt = [&](const char* name, uint64_t prop, uint64_t open) { nlohmann::json f; f["proprietary"] = prop; f["sidecar"] = open; f["coverage"] = pct(open, prop); j[name] = f; }; fmtFmt("blp_png", blpCount, pngSidecar); fmtFmt("dbc_json", dbcCount, jsonSidecar); fmtFmt("m2_wom", m2Count, womSidecar); fmtFmt("wmo_wob", wmoCount, wobSidecar); fmtFmt("adt_whm", adtCount, whmSidecar); uint64_t openTotal = pngSidecar + jsonSidecar + womSidecar + wobSidecar + whmSidecar; uint64_t propTotal = blpCount + dbcCount + m2Count + wmoCount + adtCount; j["overallCoverage"] = pct(openTotal, propTotal); std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Extracted asset tree: %s\n", dataDir.c_str()); std::printf(" total bytes : %.2f GB\n", totalBytes / (1024.0 * 1024.0 * 1024.0)); std::printf(" BLP textures : %lu (%lu PNG sidecar = %.1f%% open)\n", blpCount, pngSidecar, pct(pngSidecar, blpCount)); std::printf(" DBC tables : %lu (%lu JSON sidecar = %.1f%% open)\n", dbcCount, jsonSidecar, pct(jsonSidecar, dbcCount)); std::printf(" M2 models : %lu (%lu WOM sidecar = %.1f%% open)\n", m2Count, womSidecar, pct(womSidecar, m2Count)); std::printf(" WMO buildings: %lu (%lu WOB sidecar = %.1f%% open)\n", wmoCount, wobSidecar, pct(wobSidecar, wmoCount)); std::printf(" ADT terrain : %lu (%lu WHM sidecar = %.1f%% open)\n", adtCount, whmSidecar, pct(whmSidecar, adtCount)); uint64_t openTotal = pngSidecar + jsonSidecar + womSidecar + wobSidecar + whmSidecar; uint64_t propTotal = blpCount + dbcCount + m2Count + wmoCount + adtCount; std::printf(" overall open-format coverage: %.1f%%\n", pct(openTotal, propTotal)); // Disk-usage breakdown: shows roughly how big a purge-proprietary // workflow would shrink the tree (or how much extra a dual-format // extraction costs). const double mb = 1024.0 * 1024.0; std::printf(" proprietary bytes: %.1f MB\n", propBytes / mb); std::printf(" open-format bytes: %.1f MB", openBytes / mb); if (propBytes > 0) { std::printf(" (%.1f%% of proprietary)", 100.0 * static_cast<double>(openBytes) / propBytes); } std::printf("\n"); std::printf(" (run `asset_extract --emit-open` to fill missing sidecars)\n"); return 0; } else if (std::strcmp(argv[i], "--info-extract-tree") == 0 && i + 1 < argc) { // Hierarchical view of an extracted asset directory grouped // by top-level subdirectory and format. Useful for getting // oriented after asset_extract finishes — '17 dirs, 142k // files' is hard to reason about; this groups them for // at-a-glance comprehension. std::string dataDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(dataDir) || !fs::is_directory(dataDir)) { std::fprintf(stderr, "info-extract-tree: %s is not a directory\n", dataDir.c_str()); return 1; } // Per-top-level-dir aggregation: per-extension count + bytes. // Top-level discovery: every immediate child dir of dataDir. struct ExtStats { int count = 0; uint64_t bytes = 0; }; struct DirStats { std::string name; int totalFiles = 0; uint64_t totalBytes = 0; std::map<std::string, ExtStats> byExt; }; std::vector<DirStats> dirs; std::error_code ec; for (const auto& entry : fs::directory_iterator(dataDir, ec)) { if (entry.is_regular_file()) continue; // skip top-level files if (!entry.is_directory()) continue; DirStats d; d.name = entry.path().filename().string(); for (const auto& f : fs::recursive_directory_iterator(entry.path(), ec)) { if (!f.is_regular_file()) continue; std::string ext = f.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); if (ext.empty()) ext = "(no-ext)"; uint64_t sz = f.file_size(ec); if (ec) continue; d.totalFiles++; d.totalBytes += sz; auto& es = d.byExt[ext]; es.count++; es.bytes += sz; } dirs.push_back(std::move(d)); } std::sort(dirs.begin(), dirs.end(), [](const DirStats& a, const DirStats& b) { return a.totalBytes > b.totalBytes; }); int totalDirs = static_cast<int>(dirs.size()); int totalFiles = 0; uint64_t totalBytes = 0; for (const auto& d : dirs) { totalFiles += d.totalFiles; totalBytes += d.totalBytes; } std::printf("%s/ (%d dirs, %d files, %.1f MB)\n", dataDir.c_str(), totalDirs, totalFiles, totalBytes / (1024.0 * 1024.0)); for (size_t k = 0; k < dirs.size(); ++k) { bool lastDir = (k == dirs.size() - 1); const auto& d = dirs[k]; const char* dBranch = lastDir ? "└─ " : "├─ "; const char* dCont = lastDir ? " " : "│ "; std::printf("%s%s/ (%d files, %.1f MB)\n", dBranch, d.name.c_str(), d.totalFiles, d.totalBytes / (1024.0 * 1024.0)); // Sort extensions by byte size descending — heaviest first. std::vector<std::pair<std::string, ExtStats>> exts( d.byExt.begin(), d.byExt.end()); std::sort(exts.begin(), exts.end(), [](const auto& a, const auto& b) { return a.second.bytes > b.second.bytes; }); for (size_t e = 0; e < exts.size(); ++e) { bool lastE = (e == exts.size() - 1); const char* eBranch = lastE ? "└─ " : "├─ "; const auto& [ext, st] = exts[e]; std::printf("%s%s%-10s %5d files %8.1f KB\n", dCont, eBranch, ext.c_str(), st.count, st.bytes / 1024.0); } } return 0; } else if (std::strcmp(argv[i], "--info-extract-budget") == 0 && i + 1 < argc) { // Per-extension byte breakdown of an extract dir, sorted // largest-first. Companion to --info-pack-budget (which // operates on .wcp archives) — this answers 'where did my // 31 GB extract go?' with a flat sortable table. std::string dataDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(dataDir) || !fs::is_directory(dataDir)) { std::fprintf(stderr, "info-extract-budget: %s is not a directory\n", dataDir.c_str()); return 1; } std::map<std::string, std::pair<int, uint64_t>> byExt; uint64_t totalBytes = 0; int totalFiles = 0; std::error_code ec; for (const auto& entry : fs::recursive_directory_iterator(dataDir, ec)) { if (!entry.is_regular_file()) continue; std::string ext = entry.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); if (ext.empty()) ext = "(no-ext)"; uint64_t sz = entry.file_size(ec); if (ec) continue; byExt[ext].first++; byExt[ext].second += sz; totalBytes += sz; totalFiles++; } std::vector<std::pair<std::string, std::pair<int, uint64_t>>> sorted( byExt.begin(), byExt.end()); std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.second.second > b.second.second; }); if (jsonOut) { nlohmann::json j; j["dir"] = dataDir; j["totalFiles"] = totalFiles; j["totalBytes"] = totalBytes; nlohmann::json arr = nlohmann::json::array(); for (const auto& [ext, cb] : sorted) { arr.push_back({{"ext", ext}, {"count", cb.first}, {"bytes", cb.second}}); } j["byExtension"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Extract budget: %s\n", dataDir.c_str()); std::printf(" total: %d file(s), %.2f MB\n", totalFiles, totalBytes / (1024.0 * 1024.0)); std::printf("\n ext count bytes MB share\n"); // Cap to top 30 to keep output manageable on huge extracts; // suppressed entries roll into 'other'. const size_t kTopN = 30; uint64_t otherBytes = 0; int otherCount = 0; for (size_t k = 0; k < sorted.size(); ++k) { if (k < kTopN) { const auto& [ext, cb] = sorted[k]; double pct = totalBytes > 0 ? 100.0 * cb.second / totalBytes : 0.0; std::printf(" %-12s %6d %11llu %8.1f %5.1f%%\n", ext.c_str(), cb.first, static_cast<unsigned long long>(cb.second), cb.second / (1024.0 * 1024.0), pct); } else { otherBytes += sorted[k].second.second; otherCount += sorted[k].second.first; } } if (otherCount > 0) { double pct = totalBytes > 0 ? 100.0 * otherBytes / totalBytes : 0.0; std::printf(" %-12s %6d %11llu %8.1f %5.1f%% (%zu more extensions)\n", "(other)", otherCount, static_cast<unsigned long long>(otherBytes), otherBytes / (1024.0 * 1024.0), pct, sorted.size() - kTopN); } return 0; } else if (std::strcmp(argv[i], "--list-missing-sidecars") == 0 && i + 1 < argc) { // Actionable counterpart to --info-extract: emit one line per // proprietary file lacking its open-format sidecar. Pipe into // xargs to drive a targeted re-extract: // wowee_editor --list-missing-sidecars Data/ | // awk '/\.blp$/ {print}' | // xargs asset_extract --emit-png-only std::string dataDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(dataDir)) { std::fprintf(stderr, "list-missing-sidecars: %s does not exist\n", dataDir.c_str()); return 1; } std::vector<std::string> missingPng, missingJson, missingWom, missingWob, missingWhm; for (auto& entry : fs::recursive_directory_iterator(dataDir)) { if (!entry.is_regular_file()) continue; std::string ext = entry.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); std::string base = entry.path().string(); if (base.size() > ext.size()) base = base.substr(0, base.size() - ext.size()); auto missing = [&](const char* sidecarExt) { return !fs::exists(base + sidecarExt); }; if (ext == ".blp" && missing(".png")) missingPng.push_back(entry.path().string()); else if (ext == ".dbc" && missing(".json")) missingJson.push_back(entry.path().string()); else if (ext == ".m2" && missing(".wom")) missingWom.push_back(entry.path().string()); else if (ext == ".wmo") { // Group files (Foo_NNN.wmo) don't get individual sidecars // — only the parent file gets a .wob. std::string fname = entry.path().filename().string(); auto under = fname.rfind('_'); bool isGroup = (under != std::string::npos && fname.size() - under == 8); if (!isGroup && missing(".wob")) missingWob.push_back(entry.path().string()); } else if (ext == ".adt" && missing(".whm")) missingWhm.push_back(entry.path().string()); } size_t total = missingPng.size() + missingJson.size() + missingWom.size() + missingWob.size() + missingWhm.size(); if (jsonOut) { nlohmann::json j; j["dir"] = dataDir; j["totalMissing"] = total; j["missing"] = { {"png", missingPng}, {"json", missingJson}, {"wom", missingWom}, {"wob", missingWob}, {"whm", missingWhm}, }; std::printf("%s\n", j.dump(2).c_str()); return total == 0 ? 0 : 1; } // Plain mode: one path per line, sorted by group, prefixed with // the missing extension so awk/grep can filter. auto emit = [](const char* tag, const std::vector<std::string>& files) { for (const auto& f : files) std::printf("%s\t%s\n", tag, f.c_str()); }; emit("png", missingPng); emit("json", missingJson); emit("wom", missingWom); emit("wob", missingWob); emit("whm", missingWhm); std::fprintf(stderr, "%zu missing (PNG=%zu JSON=%zu WOM=%zu WOB=%zu WHM=%zu)\n", total, missingPng.size(), missingJson.size(), missingWom.size(), missingWob.size(), missingWhm.size()); return total == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--info-png") == 0 && i + 1 < argc) { // Inspect a PNG sidecar — width, height, channels, bit depth. // Reads only the IHDR chunk (16 bytes after the 8-byte // signature) so it works on huge files instantly without // decoding pixels. Useful for verifying that the BLP→PNG // emitter produced the expected dimensions. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "info-png: cannot open %s\n", path.c_str()); return 1; } uint8_t buf[24]; in.read(reinterpret_cast<char*>(buf), 24); if (!in || in.gcount() < 24) { std::fprintf(stderr, "info-png: %s too short to be a PNG\n", path.c_str()); return 1; } // Validate the 8-byte PNG signature: 89 50 4E 47 0D 0A 1A 0A static const uint8_t kSig[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; if (std::memcmp(buf, kSig, 8) != 0) { std::fprintf(stderr, "info-png: %s missing PNG signature\n", path.c_str()); return 1; } // IHDR chunk follows: 4-byte length, 4-byte type ('IHDR'), // then 13-byte payload (width:4, height:4, bitDepth:1, // colorType:1, compression:1, filter:1, interlace:1). // All multi-byte ints in PNG are big-endian. auto be32 = [](const uint8_t* p) { return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | uint32_t(p[3]); }; uint32_t width = be32(buf + 16); uint32_t height = be32(buf + 20); // Need bit depth + color type — read the next 5 bytes. uint8_t extra[5]; in.read(reinterpret_cast<char*>(extra), 5); uint8_t bitDepth = extra[0]; uint8_t colorType = extra[1]; // Channel count derives from color type (PNG spec table 11.1). int channels = 0; const char* colorName = "?"; switch (colorType) { case 0: channels = 1; colorName = "grayscale"; break; case 2: channels = 3; colorName = "rgb"; break; case 3: channels = 1; colorName = "palette"; break; case 4: channels = 2; colorName = "grayscale+alpha"; break; case 6: channels = 4; colorName = "rgba"; break; } // File size for a quick sanity check — a 1024x1024 RGBA PNG // shouldn't be 12 bytes, that would mean truncation. std::error_code ec; uint64_t fsz = std::filesystem::file_size(path, ec); if (jsonOut) { nlohmann::json j; j["png"] = path; j["width"] = width; j["height"] = height; j["bitDepth"] = bitDepth; j["channels"] = channels; j["colorType"] = colorType; j["colorTypeName"] = colorName; j["fileSize"] = fsz; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("PNG: %s\n", path.c_str()); std::printf(" size : %u x %u\n", width, height); std::printf(" bit depth : %u\n", bitDepth); std::printf(" color : %s (%d channel%s)\n", colorName, channels, channels == 1 ? "" : "s"); std::printf(" file bytes: %llu\n", static_cast<unsigned long long>(fsz)); return 0; } else if (std::strcmp(argv[i], "--info-blp") == 0 && i + 1 < argc) { // Inspect a BLP texture: format/compression/mips/dimensions. // Loads the full image (which decompresses pixels) since we // also report channel count and decoded byte size — useful // for verifying the source before --convert-blp-png. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "info-blp: cannot open %s\n", path.c_str()); return 1; } std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); // Quick magic check before full decode — saves a confusing // 'invalid' from the loader when the user feeds a non-BLP. if (bytes.size() < 4 || !(bytes[0] == 'B' && bytes[1] == 'L' && bytes[2] == 'P' && (bytes[3] == '1' || bytes[3] == '2'))) { std::fprintf(stderr, "info-blp: %s is not a BLP1/BLP2 file\n", path.c_str()); return 1; } std::string magicVer = std::string(bytes.begin(), bytes.begin() + 4); auto img = wowee::pipeline::BLPLoader::load(bytes); if (!img.isValid()) { std::fprintf(stderr, "info-blp: failed to decode %s\n", path.c_str()); return 1; } std::error_code ec; uint64_t fsz = std::filesystem::file_size(path, ec); const char* fmtName = wowee::pipeline::BLPLoader::getFormatName(img.format); const char* compName = wowee::pipeline::BLPLoader::getCompressionName(img.compression); if (jsonOut) { nlohmann::json j; j["blp"] = path; j["magic"] = magicVer; j["width"] = img.width; j["height"] = img.height; j["channels"] = img.channels; j["mipLevels"] = img.mipLevels; j["format"] = fmtName; j["compression"] = compName; j["decodedBytes"] = img.data.size(); j["fileSize"] = fsz; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("BLP: %s (%s)\n", path.c_str(), magicVer.c_str()); std::printf(" size : %d x %d\n", img.width, img.height); std::printf(" channels : %d\n", img.channels); std::printf(" format : %s\n", fmtName); std::printf(" compression: %s\n", compName); std::printf(" mip levels : %d\n", img.mipLevels); std::printf(" file bytes : %llu\n", static_cast<unsigned long long>(fsz)); std::printf(" decoded RGBA bytes: %zu\n", img.data.size()); return 0; } else if (std::strcmp(argv[i], "--info-m2") == 0 && i + 1 < argc) { // Inspect a proprietary M2 model. Pairs with --info to inspect // the WOM equivalent, so users can see what was preserved/lost // by the M2 -> WOM conversion (e.g. M2 has particles + ribbons, // WOM doesn't yet). std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "info-m2: cannot open %s\n", path.c_str()); return 1; } std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); // Auto-merge matching <base>00.skin if present (WotLK+ models // store geometry there) so vertex/index counts match what // gets rendered. std::vector<uint8_t> skinBytes; { std::string skinPath = path; auto dot = skinPath.rfind('.'); if (dot != std::string::npos) skinPath = skinPath.substr(0, dot) + "00.skin"; std::ifstream sf(skinPath, std::ios::binary); if (sf) { skinBytes.assign((std::istreambuf_iterator<char>(sf)), std::istreambuf_iterator<char>()); } } auto m2 = wowee::pipeline::M2Loader::load(bytes); if (!skinBytes.empty()) { wowee::pipeline::M2Loader::loadSkin(skinBytes, m2); } if (!m2.isValid()) { std::fprintf(stderr, "info-m2: failed to parse %s\n", path.c_str()); return 1; } std::error_code ec; uint64_t fsz = std::filesystem::file_size(path, ec); if (jsonOut) { nlohmann::json j; j["m2"] = path; j["name"] = m2.name; j["version"] = m2.version; j["fileSize"] = fsz; j["skinFound"] = !skinBytes.empty(); j["vertices"] = m2.vertices.size(); j["indices"] = m2.indices.size(); j["triangles"] = m2.indices.size() / 3; j["bones"] = m2.bones.size(); j["sequences"] = m2.sequences.size(); j["batches"] = m2.batches.size(); j["textures"] = m2.textures.size(); j["materials"] = m2.materials.size(); j["attachments"] = m2.attachments.size(); j["particles"] = m2.particleEmitters.size(); j["ribbons"] = m2.ribbonEmitters.size(); j["collisionTris"] = m2.collisionIndices.size() / 3; j["boundRadius"] = m2.boundRadius; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("M2: %s\n", path.c_str()); std::printf(" name : %s\n", m2.name.c_str()); std::printf(" version : %u\n", m2.version); std::printf(" file bytes : %llu\n", static_cast<unsigned long long>(fsz)); std::printf(" skin file : %s\n", skinBytes.empty() ? "not found" : "loaded"); std::printf(" vertices : %zu\n", m2.vertices.size()); std::printf(" triangles : %zu (%zu indices)\n", m2.indices.size() / 3, m2.indices.size()); std::printf(" bones : %zu\n", m2.bones.size()); std::printf(" sequences : %zu (animations)\n", m2.sequences.size()); std::printf(" batches : %zu\n", m2.batches.size()); std::printf(" textures : %zu\n", m2.textures.size()); std::printf(" materials : %zu\n", m2.materials.size()); std::printf(" attachments : %zu\n", m2.attachments.size()); std::printf(" particles : %zu\n", m2.particleEmitters.size()); std::printf(" ribbons : %zu\n", m2.ribbonEmitters.size()); std::printf(" collision : %zu tris\n", m2.collisionIndices.size() / 3); std::printf(" boundRadius : %.2f\n", m2.boundRadius); return 0; } else if (std::strcmp(argv[i], "--info-wmo") == 0 && i + 1 < argc) { // Inspect a proprietary WMO building. Like --info-m2 this // pairs with --info-wob (the open WOB equivalent inspector) // so users can verify the conversion preserves group counts, // portal counts, and doodad references. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "info-wmo: cannot open %s\n", path.c_str()); return 1; } std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); auto wmo = wowee::pipeline::WMOLoader::load(bytes); // Try to locate group files (Foo_NNN.wmo) sitting next to the // root file and merge their geometry. Without this the // group/vertex counts would all be 0 since the root file only // has metadata. namespace fs = std::filesystem; std::string base = path; if (base.size() >= 4 && base.substr(base.size() - 4) == ".wmo") base = base.substr(0, base.size() - 4); // Pre-allocate the groups array — loadGroup writes into // model.groups[gi] and bails if the slot doesn't exist. if (wmo.groups.size() < wmo.nGroups) wmo.groups.resize(wmo.nGroups); int groupsLoaded = 0; for (uint32_t gi = 0; gi < wmo.nGroups; ++gi) { // "_000.wmo" is 8 chars + NUL = 9 bytes; previous 8-byte // buffer was truncating to "_000.wm" and silently failing // every lookup. char buf[16]; std::snprintf(buf, sizeof(buf), "_%03u.wmo", gi); std::string gp = base + buf; std::ifstream gf(gp, std::ios::binary); if (!gf) continue; std::vector<uint8_t> gd((std::istreambuf_iterator<char>(gf)), std::istreambuf_iterator<char>()); if (wowee::pipeline::WMOLoader::loadGroup(gd, wmo, gi)) groupsLoaded++; } if (!wmo.isValid()) { std::fprintf(stderr, "info-wmo: failed to parse %s\n", path.c_str()); return 1; } // Total vertex/index counts across loaded groups — this is the // useful number for sizing comparisons against WOB. size_t totalV = 0, totalI = 0; for (const auto& g : wmo.groups) { totalV += g.vertices.size(); totalI += g.indices.size(); } std::error_code ec; uint64_t fsz = fs::file_size(path, ec); if (jsonOut) { nlohmann::json j; j["wmo"] = path; j["version"] = wmo.version; j["fileSize"] = fsz; j["groups"] = wmo.nGroups; j["groupsLoaded"] = groupsLoaded; j["portals"] = wmo.nPortals; j["lights"] = wmo.nLights; j["doodadDefs"] = wmo.doodads.size(); j["doodadSets"] = wmo.doodadSets.size(); j["materials"] = wmo.materials.size(); j["textures"] = wmo.textures.size(); j["totalVerts"] = totalV; j["totalTris"] = totalI / 3; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("WMO: %s\n", path.c_str()); std::printf(" version : %u\n", wmo.version); std::printf(" file bytes : %llu\n", static_cast<unsigned long long>(fsz)); std::printf(" groups : %u (%d loaded from group files)\n", wmo.nGroups, groupsLoaded); std::printf(" portals : %u\n", wmo.nPortals); std::printf(" lights : %u\n", wmo.nLights); std::printf(" doodad defs : %zu (%zu sets)\n", wmo.doodads.size(), wmo.doodadSets.size()); std::printf(" materials : %zu\n", wmo.materials.size()); std::printf(" textures : %zu\n", wmo.textures.size()); std::printf(" total verts : %zu\n", totalV); std::printf(" total tris : %zu\n", totalI / 3); return 0; } else if (std::strcmp(argv[i], "--info-adt") == 0 && i + 1 < argc) { // Inspect a proprietary ADT terrain tile. Pairs with // --info-wot/--info-whm (open WOT/WHM equivalents) so users // can verify the conversion preserves chunk/doodad/wmo counts. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "info-adt: cannot open %s\n", path.c_str()); return 1; } std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); auto terrain = wowee::pipeline::ADTLoader::load(bytes); if (!terrain.isLoaded()) { std::fprintf(stderr, "info-adt: failed to parse %s\n", path.c_str()); return 1; } // Walk chunks and tally height range + loaded count + water/holes. int loadedChunks = 0, holeChunks = 0, waterChunks = 0; float minH = 1e30f, maxH = -1e30f; for (size_t c = 0; c < 256; ++c) { const auto& chunk = terrain.chunks[c]; if (!chunk.heightMap.isLoaded()) continue; loadedChunks++; if (chunk.holes != 0) holeChunks++; if (terrain.waterData[c].hasWater()) waterChunks++; for (float h : chunk.heightMap.heights) { if (std::isfinite(h)) { if (h < minH) minH = h; if (h > maxH) maxH = h; } } } std::error_code ec; uint64_t fsz = std::filesystem::file_size(path, ec); if (jsonOut) { nlohmann::json j; j["adt"] = path; j["version"] = terrain.version; j["fileSize"] = fsz; j["coord"] = {terrain.coord.x, terrain.coord.y}; j["loadedChunks"] = loadedChunks; j["holeChunks"] = holeChunks; j["waterChunks"] = waterChunks; j["heightMin"] = (loadedChunks > 0) ? minH : 0.0f; j["heightMax"] = (loadedChunks > 0) ? maxH : 0.0f; j["textures"] = terrain.textures.size(); j["doodadNames"] = terrain.doodadNames.size(); j["wmoNames"] = terrain.wmoNames.size(); j["doodadPlacements"] = terrain.doodadPlacements.size(); j["wmoPlacements"] = terrain.wmoPlacements.size(); std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("ADT: %s\n", path.c_str()); std::printf(" version : %u\n", terrain.version); std::printf(" file bytes : %llu\n", static_cast<unsigned long long>(fsz)); std::printf(" coord : (%d, %d)\n", terrain.coord.x, terrain.coord.y); std::printf(" chunks loaded : %d/256\n", loadedChunks); if (loadedChunks > 0) { std::printf(" height range : [%.2f, %.2f]\n", minH, maxH); } std::printf(" hole chunks : %d (with cave/gap masks)\n", holeChunks); std::printf(" water chunks : %d\n", waterChunks); std::printf(" textures : %zu\n", terrain.textures.size()); std::printf(" doodad names : %zu (%zu placements)\n", terrain.doodadNames.size(), terrain.doodadPlacements.size()); std::printf(" wmo names : %zu (%zu placements)\n", terrain.wmoNames.size(), terrain.wmoPlacements.size()); return 0; } else if (std::strcmp(argv[i], "--info-jsondbc") == 0 && i + 1 < argc) { // Inspect a JSON DBC sidecar (the JSON output of asset_extract // --emit-json-dbc). Reports recordCount, fieldCount, source // filename, and format version — useful for verifying the // sidecar tracks the proprietary file's row count. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path); if (!in) { std::fprintf(stderr, "info-jsondbc: cannot open %s\n", path.c_str()); return 1; } nlohmann::json doc; try { in >> doc; } catch (const std::exception& e) { std::fprintf(stderr, "info-jsondbc: bad JSON in %s (%s)\n", path.c_str(), e.what()); return 1; } // The wowee JSON DBC schema (from open_format_emitter.cpp): // {format, source, recordCount, fieldCount, records:[[...], ...]}. // Tolerate missing fields rather than crashing — old sidecars // may predate a field addition. std::string format = doc.value("format", std::string{}); std::string source = doc.value("source", std::string{}); uint32_t recordCount = doc.value("recordCount", 0u); uint32_t fieldCount = doc.value("fieldCount", 0u); uint32_t actualRecs = 0; if (doc.contains("records") && doc["records"].is_array()) { actualRecs = static_cast<uint32_t>(doc["records"].size()); } bool countMismatch = (recordCount != actualRecs); if (jsonOut) { nlohmann::json j; j["jsondbc"] = path; j["format"] = format; j["source"] = source; j["recordCount"] = recordCount; j["fieldCount"] = fieldCount; j["actualRecords"] = actualRecs; j["countMismatch"] = countMismatch; std::printf("%s\n", j.dump(2).c_str()); return countMismatch ? 1 : 0; } std::printf("JSON DBC: %s\n", path.c_str()); std::printf(" format : %s\n", format.empty() ? "?" : format.c_str()); std::printf(" source : %s\n", source.empty() ? "?" : source.c_str()); std::printf(" records : %u (header) / %u (actual)%s\n", recordCount, actualRecs, countMismatch ? " [MISMATCH]" : ""); std::printf(" fields : %u\n", fieldCount); return countMismatch ? 1 : 0; } else if (std::strcmp(argv[i], "--info-zone") == 0 && i + 1 < argc) { // Parse a zone.json and print every manifest field. Useful when // diffing two zones or auditing the audio/flag setup before // packing into a WCP. std::string zonePath = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; // Accept either a directory or the zone.json itself. if (fs::is_directory(zonePath)) zonePath += "/zone.json"; wowee::editor::ZoneManifest manifest; if (!manifest.load(zonePath)) { std::fprintf(stderr, "Failed to load zone.json: %s\n", zonePath.c_str()); return 1; } if (jsonOut) { nlohmann::json j; j["file"] = zonePath; j["mapName"] = manifest.mapName; j["displayName"] = manifest.displayName; j["mapId"] = manifest.mapId; j["biome"] = manifest.biome; j["baseHeight"] = manifest.baseHeight; j["hasCreatures"] = manifest.hasCreatures; j["description"] = manifest.description; nlohmann::json tilesArr = nlohmann::json::array(); for (const auto& t : manifest.tiles) tilesArr.push_back({t.first, t.second}); j["tiles"] = tilesArr; j["flags"] = {{"allowFlying", manifest.allowFlying}, {"pvpEnabled", manifest.pvpEnabled}, {"isIndoor", manifest.isIndoor}, {"isSanctuary", manifest.isSanctuary}}; if (!manifest.musicTrack.empty() || !manifest.ambienceDay.empty()) { nlohmann::json audio; if (!manifest.musicTrack.empty()) { audio["music"] = manifest.musicTrack; audio["musicVolume"] = manifest.musicVolume; } if (!manifest.ambienceDay.empty()) { audio["ambienceDay"] = manifest.ambienceDay; audio["ambienceVolume"] = manifest.ambienceVolume; } if (!manifest.ambienceNight.empty()) audio["ambienceNight"] = manifest.ambienceNight; j["audio"] = audio; } std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("zone.json: %s\n", zonePath.c_str()); std::printf(" mapName : %s\n", manifest.mapName.c_str()); std::printf(" displayName : %s\n", manifest.displayName.c_str()); std::printf(" mapId : %u\n", manifest.mapId); std::printf(" biome : %s\n", manifest.biome.c_str()); std::printf(" baseHeight : %.2f\n", manifest.baseHeight); std::printf(" hasCreatures: %s\n", manifest.hasCreatures ? "yes" : "no"); std::printf(" description : %s\n", manifest.description.c_str()); std::printf(" tiles : %zu\n", manifest.tiles.size()); for (const auto& t : manifest.tiles) std::printf(" (%d, %d)\n", t.first, t.second); std::printf(" flags : %s%s%s%s\n", manifest.allowFlying ? "fly " : "", manifest.pvpEnabled ? "pvp " : "", manifest.isIndoor ? "indoor " : "", manifest.isSanctuary ? "sanctuary" : ""); if (!manifest.musicTrack.empty() || !manifest.ambienceDay.empty()) { std::printf(" audio :\n"); if (!manifest.musicTrack.empty()) std::printf(" music : %s (vol=%.2f)\n", manifest.musicTrack.c_str(), manifest.musicVolume); if (!manifest.ambienceDay.empty()) std::printf(" ambience : %s (vol=%.2f)\n", manifest.ambienceDay.c_str(), manifest.ambienceVolume); if (!manifest.ambienceNight.empty()) std::printf(" night amb : %s\n", manifest.ambienceNight.c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-zone-overview") == 0 && i + 1 < argc) { // One-line compact zone summary. Where --info-zone dumps // every manifest field, this gives a tweet-length status: // tile count, biome, content counts, audio status. Easy // to grep through `--for-each-zone` output to spot // outliers. 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-overview: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "info-zone-overview: failed to parse %s\n", manifestPath.c_str()); return 1; } // Cheap content counts via direct JSON parse — avoids // standing up the full editor classes for an overview. auto countArray = [&](const std::string& fname, const std::string& key) { std::string p = zoneDir + "/" + fname; if (!fs::exists(p)) return size_t{0}; try { nlohmann::json doc; std::ifstream in(p); in >> doc; if (doc.is_array()) return doc.size(); if (doc.contains(key) && doc[key].is_array()) return doc[key].size(); } catch (...) {} return size_t{0}; }; size_t creatures = countArray("creatures.json", "creatures"); size_t objects = countArray("objects.json", "objects"); size_t quests = countArray("quests.json", "quests"); size_t items = countArray("items.json", "items"); bool hasAudio = !zm.musicTrack.empty() || !zm.ambienceDay.empty() || !zm.ambienceNight.empty(); if (jsonOut) { nlohmann::json j; j["zone"] = fs::path(zoneDir).filename().string(); j["mapName"] = zm.mapName; j["biome"] = zm.biome; j["tileCount"] = zm.tiles.size(); j["counts"] = {{"creatures", creatures}, {"objects", objects}, {"quests", quests}, {"items", items}}; j["hasAudio"] = hasAudio; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("%s [%s] %zut/%zuc/%zuo/%zuq/%zui%s\n", fs::path(zoneDir).filename().string().c_str(), zm.biome.empty() ? "?" : zm.biome.c_str(), zm.tiles.size(), creatures, objects, quests, items, hasAudio ? " +audio" : ""); return 0; } else if (std::strcmp(argv[i], "--info-project-overview") == 0 && i + 1 < argc) { // Project-wide overview table: one row per zone with the // same compact stats as --info-zone-overview. Single-page // health check for "what's in this 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-overview: %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 countArray = [](const std::string& path, const std::string& key) { if (!fs::exists(path)) return size_t{0}; try { nlohmann::json doc; std::ifstream in(path); in >> doc; if (doc.is_array()) return doc.size(); if (doc.contains(key) && doc[key].is_array()) return doc[key].size(); } catch (...) {} return size_t{0}; }; struct Row { std::string name, biome; size_t tiles, creatures, objects, quests, items; bool hasAudio; }; std::vector<Row> rows; size_t totC = 0, totO = 0, totQ = 0, totI = 0, totT = 0; int audioCount = 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.biome = zm.biome; r.tiles = zm.tiles.size(); r.creatures = countArray(zoneDir + "/creatures.json", "creatures"); r.objects = countArray(zoneDir + "/objects.json", "objects"); r.quests = countArray(zoneDir + "/quests.json", "quests"); r.items = countArray(zoneDir + "/items.json", "items"); r.hasAudio = !zm.musicTrack.empty() || !zm.ambienceDay.empty() || !zm.ambienceNight.empty(); if (r.hasAudio) audioCount++; totT += r.tiles; totC += r.creatures; totO += r.objects; totQ += r.quests; totI += r.items; rows.push_back(r); } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = zones.size(); j["totals"] = {{"tiles", totT}, {"creatures", totC}, {"objects", totO}, {"quests", totQ}, {"items", totI}, {"withAudio", audioCount}}; nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({{"name", r.name}, {"biome", r.biome}, {"tiles", r.tiles}, {"creatures", r.creatures}, {"objects", r.objects}, {"quests", r.quests}, {"items", r.items}, {"hasAudio", r.hasAudio}}); } j["zones"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project overview: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" totals : %zut, %zuc, %zuo, %zuq, %zui (%d with audio)\n", totT, totC, totO, totQ, totI, audioCount); std::printf("\n zone biome tiles creat obj quest items audio\n"); for (const auto& r : rows) { std::printf(" %-20s %-10s %5zu %5zu %3zu %5zu %5zu %s\n", r.name.substr(0, 20).c_str(), r.biome.empty() ? "?" : r.biome.substr(0, 10).c_str(), r.tiles, r.creatures, r.objects, r.quests, r.items, r.hasAudio ? "yes" : "no"); } 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-creatures") == 0 && i + 1 < argc) { std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::NpcSpawner spawner; if (!spawner.loadFromFile(path)) { std::fprintf(stderr, "Failed to load creatures.json: %s\n", path.c_str()); return 1; } const auto& spawns = spawner.getSpawns(); int hostile = 0, vendor = 0, questgiver = 0, trainer = 0; int patrol = 0, wander = 0, stationary = 0; std::unordered_map<uint32_t, int> displayIdHist; for (const auto& s : spawns) { if (s.hostile) hostile++; if (s.vendor) vendor++; if (s.questgiver) questgiver++; if (s.trainer) trainer++; using B = wowee::editor::CreatureBehavior; if (s.behavior == B::Patrol) patrol++; else if (s.behavior == B::Wander) wander++; else if (s.behavior == B::Stationary) stationary++; displayIdHist[s.displayId]++; } if (jsonOut) { nlohmann::json j; j["file"] = path; j["total"] = spawns.size(); j["hostile"] = hostile; j["questgiver"] = questgiver; j["vendor"] = vendor; j["trainer"] = trainer; j["behavior"] = {{"stationary", stationary}, {"wander", wander}, {"patrol", patrol}}; j["uniqueDisplayIds"] = displayIdHist.size(); std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("creatures.json: %s\n", path.c_str()); std::printf(" total : %zu\n", spawns.size()); std::printf(" hostile : %d\n", hostile); std::printf(" questgiver : %d\n", questgiver); std::printf(" vendor : %d\n", vendor); std::printf(" trainer : %d\n", trainer); std::printf(" behavior : %d stationary, %d wander, %d patrol\n", stationary, wander, patrol); std::printf(" unique displayIds: %zu\n", displayIdHist.size()); return 0; } else if (std::strcmp(argv[i], "--info-creatures-by-faction") == 0 && i + 1 < argc) { // Faction histogram for combat balance analysis. AzerothCore // factions: 7=human, 14=monster, 16=alliance-friendly, 35=neutral, // etc. A zone with all faction=14 is going to be one giant // free-for-all; a mixed-faction zone needs combat-tuning. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::NpcSpawner sp; if (!sp.loadFromFile(path)) { std::fprintf(stderr, "info-creatures-by-faction: failed to load %s\n", path.c_str()); return 1; } std::map<uint32_t, int> hist; for (const auto& s : sp.getSpawns()) hist[s.faction]++; if (jsonOut) { nlohmann::json j; j["file"] = path; j["totalCreatures"] = sp.spawnCount(); j["uniqueFactions"] = hist.size(); nlohmann::json arr = nlohmann::json::array(); for (const auto& [f, c] : hist) { arr.push_back({{"faction", f}, {"count", c}}); } j["factions"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Creatures by faction: %s (%zu total)\n", path.c_str(), sp.spawnCount()); std::printf(" faction count share\n"); for (const auto& [f, c] : hist) { double pct = sp.spawnCount() > 0 ? 100.0 * c / sp.spawnCount() : 0.0; std::printf(" %7u %5d %5.1f%%\n", f, c, pct); } std::printf(" (factions: 7=human, 14=monster, 35=neutral, etc.)\n"); return 0; } else if (std::strcmp(argv[i], "--info-creatures-by-level") == 0 && i + 1 < argc) { // Level distribution for difficulty-curve analysis. Min/max/ // avg + per-level histogram. A zone with all level-1 spawns // is a starter area; one with all 60s is endgame; spikes in // the middle suggest content-tuning issues. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::NpcSpawner sp; if (!sp.loadFromFile(path)) { std::fprintf(stderr, "info-creatures-by-level: failed to load %s\n", path.c_str()); return 1; } std::map<uint32_t, int> hist; uint32_t minL = std::numeric_limits<uint32_t>::max(); uint32_t maxL = 0; uint64_t sumL = 0; for (const auto& s : sp.getSpawns()) { hist[s.level]++; if (s.level < minL) minL = s.level; if (s.level > maxL) maxL = s.level; sumL += s.level; } double avgL = sp.spawnCount() > 0 ? double(sumL) / sp.spawnCount() : 0.0; if (sp.spawnCount() == 0) minL = 0; if (jsonOut) { nlohmann::json j; j["file"] = path; j["totalCreatures"] = sp.spawnCount(); j["minLevel"] = minL; j["maxLevel"] = maxL; j["avgLevel"] = avgL; nlohmann::json arr = nlohmann::json::array(); for (const auto& [l, c] : hist) { arr.push_back({{"level", l}, {"count", c}}); } j["levels"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Creatures by level: %s (%zu total)\n", path.c_str(), sp.spawnCount()); std::printf(" range : %u to %u (avg %.1f)\n", minL, maxL, avgL); std::printf("\n level count bar\n"); int maxBarCount = 0; for (const auto& [_, c] : hist) maxBarCount = std::max(maxBarCount, c); for (const auto& [l, c] : hist) { int barLen = maxBarCount > 0 ? (40 * c) / maxBarCount : 0; std::printf(" %5u %5d ", l, c); for (int b = 0; b < barLen; ++b) std::printf("█"); std::printf("\n"); } return 0; } else if (std::strcmp(argv[i], "--info-objects-by-path") == 0 && i + 1 < argc) { // Most-used model paths with counts. Designers can quickly // spot which trees/lamps/walls dominate a zone — helps with // both texture-budget audits and 'this looks repetitive, // diversify the doodads' design feedback. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::ObjectPlacer placer; if (!placer.loadFromFile(path)) { std::fprintf(stderr, "info-objects-by-path: failed to load %s\n", path.c_str()); return 1; } std::map<std::string, int> hist; for (const auto& o : placer.getObjects()) hist[o.path]++; // Sort by count descending. std::vector<std::pair<std::string, int>> sorted(hist.begin(), hist.end()); std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); int total = static_cast<int>(placer.getObjects().size()); if (jsonOut) { nlohmann::json j; j["file"] = path; j["totalObjects"] = total; j["uniquePaths"] = hist.size(); nlohmann::json arr = nlohmann::json::array(); for (const auto& [p, c] : sorted) { arr.push_back({{"path", p}, {"count", c}}); } j["paths"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Objects by path: %s (%d total, %zu unique)\n", path.c_str(), total, hist.size()); std::printf(" count share path\n"); for (const auto& [p, c] : sorted) { double pct = total > 0 ? 100.0 * c / total : 0.0; std::printf(" %5d %5.1f%% %s\n", c, pct, p.c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-objects-by-type") == 0 && i + 1 < argc) { // M2 vs WMO split + per-type scale stats. Catches scale // outliers ('this WMO is at 0.001 scale, did you mean 1.0?') // and gives a sense of zone composition (mostly props vs // mostly buildings). std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::ObjectPlacer placer; if (!placer.loadFromFile(path)) { std::fprintf(stderr, "info-objects-by-type: failed to load %s\n", path.c_str()); return 1; } int m2Count = 0, wmoCount = 0; float m2Min = 1e30f, m2Max = -1e30f; float wmoMin = 1e30f, wmoMax = -1e30f; double m2SumScale = 0, wmoSumScale = 0; for (const auto& o : placer.getObjects()) { if (o.type == wowee::editor::PlaceableType::M2) { m2Count++; m2Min = std::min(m2Min, o.scale); m2Max = std::max(m2Max, o.scale); m2SumScale += o.scale; } else { wmoCount++; wmoMin = std::min(wmoMin, o.scale); wmoMax = std::max(wmoMax, o.scale); wmoSumScale += o.scale; } } double m2Avg = m2Count > 0 ? m2SumScale / m2Count : 0.0; double wmoAvg = wmoCount > 0 ? wmoSumScale / wmoCount : 0.0; if (m2Count == 0) { m2Min = 0; m2Max = 0; } if (wmoCount == 0) { wmoMin = 0; wmoMax = 0; } if (jsonOut) { nlohmann::json j; j["file"] = path; j["totalObjects"] = m2Count + wmoCount; j["m2"] = {{"count", m2Count}, {"scaleMin", m2Min}, {"scaleMax", m2Max}, {"scaleAvg", m2Avg}}; j["wmo"] = {{"count", wmoCount}, {"scaleMin", wmoMin}, {"scaleMax", wmoMax}, {"scaleAvg", wmoAvg}}; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Objects by type: %s\n", path.c_str()); std::printf(" M2 : %d (scale %.2f-%.2f, avg %.2f)\n", m2Count, m2Min, m2Max, m2Avg); std::printf(" WMO : %d (scale %.2f-%.2f, avg %.2f)\n", wmoCount, wmoMin, wmoMax, wmoAvg); return 0; } else if (std::strcmp(argv[i], "--info-quests-by-level") == 0 && i + 1 < argc) { // Required-level distribution. Catches difficulty-curve // issues where every quest is requiredLevel=1 (player skips // the chain) or every quest is requiredLevel=60 (no early // game), and outliers (a level-30 quest dropped into a // starter zone). std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "info-quests-by-level: failed to load %s\n", path.c_str()); return 1; } std::map<uint32_t, int> hist; uint32_t minL = std::numeric_limits<uint32_t>::max(); uint32_t maxL = 0; uint64_t sumL = 0; for (const auto& q : qe.getQuests()) { hist[q.requiredLevel]++; if (q.requiredLevel < minL) minL = q.requiredLevel; if (q.requiredLevel > maxL) maxL = q.requiredLevel; sumL += q.requiredLevel; } double avgL = qe.questCount() > 0 ? double(sumL) / qe.questCount() : 0.0; if (qe.questCount() == 0) minL = 0; if (jsonOut) { nlohmann::json j; j["file"] = path; j["totalQuests"] = qe.questCount(); j["minLevel"] = minL; j["maxLevel"] = maxL; j["avgLevel"] = avgL; nlohmann::json arr = nlohmann::json::array(); for (const auto& [l, c] : hist) { arr.push_back({{"level", l}, {"count", c}}); } j["levels"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Quests by required level: %s (%zu total)\n", path.c_str(), qe.questCount()); std::printf(" range : %u to %u (avg %.1f)\n", minL, maxL, avgL); std::printf("\n level count bar\n"); int maxBarCount = 0; for (const auto& [_, c] : hist) maxBarCount = std::max(maxBarCount, c); for (const auto& [l, c] : hist) { int barLen = maxBarCount > 0 ? (40 * c) / maxBarCount : 0; std::printf(" %5u %5d ", l, c); for (int b = 0; b < barLen; ++b) std::printf("█"); std::printf("\n"); } return 0; } else if (std::strcmp(argv[i], "--info-quests-by-xp") == 0 && i + 1 < argc) { // XP reward distribution. Bucket into 100-XP groups so a // 10000-XP quest doesn't make the histogram unreadable. // Catches no-reward quests + cluster analysis (mostly // 100-XP smalls vs mostly 5000-XP boss kills). std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "info-quests-by-xp: failed to load %s\n", path.c_str()); return 1; } uint32_t minXp = std::numeric_limits<uint32_t>::max(); uint32_t maxXp = 0; uint64_t sumXp = 0; int zeroXp = 0; // Bucket size grows with max — keeps the histogram readable // for both starter zones (10-100 XP) and endgame (5000+). std::map<uint32_t, int> buckets; for (const auto& q : qe.getQuests()) { if (q.reward.xp < minXp) minXp = q.reward.xp; if (q.reward.xp > maxXp) maxXp = q.reward.xp; sumXp += q.reward.xp; if (q.reward.xp == 0) zeroXp++; } uint32_t bucketSize = 100; if (maxXp > 1000) bucketSize = 250; if (maxXp > 5000) bucketSize = 500; if (maxXp > 20000) bucketSize = 1000; for (const auto& q : qe.getQuests()) { buckets[(q.reward.xp / bucketSize) * bucketSize]++; } double avgXp = qe.questCount() > 0 ? double(sumXp) / qe.questCount() : 0.0; if (qe.questCount() == 0) minXp = 0; if (jsonOut) { nlohmann::json j; j["file"] = path; j["totalQuests"] = qe.questCount(); j["minXp"] = minXp; j["maxXp"] = maxXp; j["avgXp"] = avgXp; j["zeroXpQuests"] = zeroXp; j["bucketSize"] = bucketSize; nlohmann::json arr = nlohmann::json::array(); for (const auto& [b, c] : buckets) { arr.push_back({{"bucket", b}, {"count", c}}); } j["buckets"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Quests by XP reward: %s (%zu total)\n", path.c_str(), qe.questCount()); std::printf(" range : %u to %u (avg %.0f, %d with 0 XP)\n", minXp, maxXp, avgXp, zeroXp); std::printf("\n bucket (≥XP) count bar\n"); int maxBarCount = 0; for (const auto& [_, c] : buckets) maxBarCount = std::max(maxBarCount, c); for (const auto& [b, c] : buckets) { int barLen = maxBarCount > 0 ? (40 * c) / maxBarCount : 0; std::printf(" %12u %5d ", b, c); for (int x = 0; x < barLen; ++x) std::printf("█"); std::printf("\n"); } std::printf(" (bucket size: %u XP)\n", bucketSize); return 0; } else if (std::strcmp(argv[i], "--list-creatures") == 0 && i + 1 < argc) { // Verbose enumeration of every spawn — needed because // --remove-creature takes a 0-based index but --info-creatures // only shows aggregate counts. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::NpcSpawner spawner; if (!spawner.loadFromFile(path)) { std::fprintf(stderr, "Failed to load creatures.json: %s\n", path.c_str()); return 1; } const auto& spawns = spawner.getSpawns(); if (jsonOut) { nlohmann::json j; j["file"] = path; j["total"] = spawns.size(); nlohmann::json arr = nlohmann::json::array(); for (size_t k = 0; k < spawns.size(); ++k) { const auto& s = spawns[k]; arr.push_back({ {"index", k}, {"name", s.name}, {"displayId", s.displayId}, {"level", s.level}, {"position", {s.position.x, s.position.y, s.position.z}}, {"hostile", s.hostile}, }); } j["spawns"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("creatures.json: %s (%zu total)\n", path.c_str(), spawns.size()); std::printf(" idx name lvl display pos (x, y, z)\n"); for (size_t k = 0; k < spawns.size(); ++k) { const auto& s = spawns[k]; std::printf(" %3zu %-30s %3u %7u (%.1f, %.1f, %.1f)%s\n", k, s.name.substr(0, 30).c_str(), s.level, s.displayId, s.position.x, s.position.y, s.position.z, s.hostile ? " [hostile]" : ""); } return 0; } else if (std::strcmp(argv[i], "--list-objects") == 0 && i + 1 < argc) { std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::ObjectPlacer placer; if (!placer.loadFromFile(path)) { std::fprintf(stderr, "Failed to load objects.json: %s\n", path.c_str()); return 1; } const auto& objs = placer.getObjects(); auto typeStr = [](wowee::editor::PlaceableType t) { return t == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"; }; if (jsonOut) { nlohmann::json j; j["file"] = path; j["total"] = objs.size(); nlohmann::json arr = nlohmann::json::array(); for (size_t k = 0; k < objs.size(); ++k) { const auto& o = objs[k]; arr.push_back({ {"index", k}, {"type", typeStr(o.type)}, {"path", o.path}, {"position", {o.position.x, o.position.y, o.position.z}}, {"scale", o.scale}, }); } j["objects"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("objects.json: %s (%zu total)\n", path.c_str(), objs.size()); std::printf(" idx type scale path pos (x, y, z)\n"); for (size_t k = 0; k < objs.size(); ++k) { const auto& o = objs[k]; std::printf(" %3zu %-4s %5.2f %-38s (%.1f, %.1f, %.1f)\n", k, typeStr(o.type), o.scale, o.path.substr(0, 38).c_str(), o.position.x, o.position.y, o.position.z); } return 0; } else if (std::strcmp(argv[i], "--list-quests") == 0 && i + 1 < argc) { std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "Failed to load quests.json: %s\n", path.c_str()); return 1; } const auto& quests = qe.getQuests(); if (jsonOut) { nlohmann::json j; j["file"] = path; j["total"] = quests.size(); nlohmann::json arr = nlohmann::json::array(); for (size_t k = 0; k < quests.size(); ++k) { const auto& q = quests[k]; arr.push_back({ {"index", k}, {"title", q.title}, {"giver", q.questGiverNpcId}, {"turnIn", q.turnInNpcId}, {"requiredLevel", q.requiredLevel}, {"xp", q.reward.xp}, {"nextQuestId", q.nextQuestId}, }); } j["quests"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("quests.json: %s (%zu total)\n", path.c_str(), quests.size()); std::printf(" idx lvl giver turnIn xp title\n"); for (size_t k = 0; k < quests.size(); ++k) { const auto& q = quests[k]; std::printf(" %3zu %3u %7u %7u %5u %s%s\n", k, q.requiredLevel, q.questGiverNpcId, q.turnInNpcId, q.reward.xp, q.title.c_str(), q.nextQuestId ? " [chained]" : ""); } return 0; } else if (std::strcmp(argv[i], "--list-quest-objectives") == 0 && i + 2 < argc) { // Per-quest objective listing — pairs with --remove-quest-objective // (which takes objIdx). Tabulates type, target, count, description. std::string path = argv[++i]; std::string idxStr = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; int qIdx; try { qIdx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "list-quest-objectives: bad questIdx '%s'\n", idxStr.c_str()); return 1; } wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "list-quest-objectives: failed to load %s\n", path.c_str()); return 1; } if (qIdx < 0 || qIdx >= static_cast<int>(qe.questCount())) { std::fprintf(stderr, "list-quest-objectives: questIdx %d out of range [0, %zu)\n", qIdx, qe.questCount()); return 1; } const auto& q = qe.getQuests()[qIdx]; 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 "?"; }; if (jsonOut) { nlohmann::json j; j["file"] = path; j["questIdx"] = qIdx; j["title"] = q.title; j["count"] = q.objectives.size(); nlohmann::json arr = nlohmann::json::array(); for (size_t o = 0; o < q.objectives.size(); ++o) { const auto& ob = q.objectives[o]; arr.push_back({ {"index", o}, {"type", typeName(ob.type)}, {"target", ob.targetName}, {"count", ob.targetCount}, {"description", ob.description}, }); } j["objectives"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Quest %d ('%s'): %zu objective(s)\n", qIdx, q.title.c_str(), q.objectives.size()); std::printf(" idx type count target description\n"); for (size_t o = 0; o < q.objectives.size(); ++o) { const auto& ob = q.objectives[o]; std::printf(" %3zu %-7s %5u %-18s %s\n", o, typeName(ob.type), ob.targetCount, ob.targetName.substr(0, 18).c_str(), ob.description.c_str()); } return 0; } else if (std::strcmp(argv[i], "--list-quest-rewards") == 0 && i + 2 < argc) { // Per-quest reward listing. Shows XP/coin breakdown plus the // full itemRewards list (which --info-quests only counts). std::string path = argv[++i]; std::string idxStr = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; int qIdx; try { qIdx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "list-quest-rewards: bad questIdx '%s'\n", idxStr.c_str()); return 1; } wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "list-quest-rewards: failed to load %s\n", path.c_str()); return 1; } if (qIdx < 0 || qIdx >= static_cast<int>(qe.questCount())) { std::fprintf(stderr, "list-quest-rewards: questIdx %d out of range [0, %zu)\n", qIdx, qe.questCount()); return 1; } const auto& q = qe.getQuests()[qIdx]; const auto& r = q.reward; if (jsonOut) { nlohmann::json j; j["file"] = path; j["questIdx"] = qIdx; j["title"] = q.title; j["xp"] = r.xp; j["gold"] = r.gold; j["silver"] = r.silver; j["copper"] = r.copper; j["items"] = r.itemRewards; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Quest %d ('%s') rewards:\n", qIdx, q.title.c_str()); std::printf(" xp : %u\n", r.xp); std::printf(" coin : %ug %us %uc\n", r.gold, r.silver, r.copper); std::printf(" items : %zu\n", r.itemRewards.size()); for (size_t k = 0; k < r.itemRewards.size(); ++k) { std::printf(" [%zu] %s\n", k, r.itemRewards[k].c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-quest-graph-stats") == 0 && i + 1 < argc) { // Topology analysis of the quest dependency graph. Where // --export-quest-graph visualizes it, this quantifies it: // roots = quests no one chains TO (entry points) // leaves = quests with no nextQuestId (terminal) // orphans = roots that are also leaves (one-shot quests) // cycles = circular chain detected // maxDepth = longest path from any root // avgDepth = mean path length across all roots std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "info-quest-graph-stats: failed to load %s\n", path.c_str()); return 1; } const auto& quests = qe.getQuests(); // Build id -> nextId and reverse adjacency. std::unordered_map<uint32_t, uint32_t> nextOf; std::unordered_set<uint32_t> hasInbound; std::unordered_set<uint32_t> validIds; for (const auto& q : quests) { validIds.insert(q.id); nextOf[q.id] = q.nextQuestId; } for (const auto& q : quests) { if (q.nextQuestId != 0 && validIds.count(q.nextQuestId)) { hasInbound.insert(q.nextQuestId); } } int roots = 0, leaves = 0, orphans = 0; int cycles = 0; int maxDepth = 0; int sumDepths = 0; for (const auto& q : quests) { bool isRoot = (hasInbound.count(q.id) == 0); bool isLeaf = (q.nextQuestId == 0 || validIds.count(q.nextQuestId) == 0); if (isRoot) roots++; if (isLeaf) leaves++; if (isRoot && isLeaf) orphans++; if (isRoot) { // Walk the chain forward, counting depth + cycle-guarding. std::unordered_set<uint32_t> visited; int depth = 1; uint32_t current = q.id; while (current != 0 && validIds.count(current)) { if (!visited.insert(current).second) { cycles++; break; } auto it = nextOf.find(current); if (it == nextOf.end() || it->second == 0) break; current = it->second; depth++; } if (depth > maxDepth) maxDepth = depth; sumDepths += depth; } } double avgDepth = (roots > 0) ? double(sumDepths) / roots : 0.0; if (jsonOut) { nlohmann::json j; j["file"] = path; j["totalQuests"] = quests.size(); j["roots"] = roots; j["leaves"] = leaves; j["orphans"] = orphans; j["cycles"] = cycles; j["maxDepth"] = maxDepth; j["avgDepth"] = avgDepth; std::printf("%s\n", j.dump(2).c_str()); return cycles == 0 ? 0 : 1; } std::printf("Quest graph: %s\n", path.c_str()); std::printf(" total quests : %zu\n", quests.size()); std::printf(" roots : %d (no inbound chain — entry points)\n", roots); std::printf(" leaves : %d (no outbound chain — terminal)\n", leaves); std::printf(" orphans : %d (root AND leaf — one-shot)\n", orphans); std::printf(" cycles : %d %s\n", cycles, cycles == 0 ? "" : "(BROKEN — chains loop back)"); std::printf(" max depth : %d\n", maxDepth); std::printf(" avg depth : %.2f (chain length per root)\n", avgDepth); return cycles == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--info-creature") == 0 && i + 2 < argc) { // Single-creature deep dive — every CreatureSpawn field for // one entry. Companion to --list-creatures (which is a // table view); useful for digging into 'why is this NPC // not behaving like I expect?'. std::string path = argv[++i]; std::string idxStr = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "info-creature: bad idx '%s'\n", idxStr.c_str()); return 1; } wowee::editor::NpcSpawner sp; if (!sp.loadFromFile(path)) { std::fprintf(stderr, "info-creature: failed to load %s\n", path.c_str()); return 1; } if (idx < 0 || idx >= static_cast<int>(sp.spawnCount())) { std::fprintf(stderr, "info-creature: idx %d out of range [0, %zu)\n", idx, sp.spawnCount()); return 1; } const auto& s = sp.getSpawns()[idx]; using B = wowee::editor::CreatureBehavior; const char* behavior = s.behavior == B::Patrol ? "patrol" : s.behavior == B::Wander ? "wander" : "stationary"; if (jsonOut) { nlohmann::json j; j["index"] = idx; j["id"] = s.id; j["name"] = s.name; j["modelPath"] = s.modelPath; j["displayId"] = s.displayId; j["position"] = {s.position.x, s.position.y, s.position.z}; j["orientation"] = s.orientation; j["level"] = s.level; j["health"] = s.health; j["mana"] = s.mana; j["minDamage"] = s.minDamage; j["maxDamage"] = s.maxDamage; j["armor"] = s.armor; j["faction"] = s.faction; j["scale"] = s.scale; j["behavior"] = behavior; j["wanderRadius"] = s.wanderRadius; j["aggroRadius"] = s.aggroRadius; j["leashRadius"] = s.leashRadius; j["respawnTimeMs"] = s.respawnTimeMs; j["patrolPoints"] = s.patrolPath.size(); j["hostile"] = s.hostile; j["questgiver"] = s.questgiver; j["vendor"] = s.vendor; j["trainer"] = s.trainer; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Creature [%d] '%s'\n", idx, s.name.c_str()); std::printf(" id : %u\n", s.id); std::printf(" displayId : %u\n", s.displayId); std::printf(" modelPath : %s\n", s.modelPath.empty() ? "(uses displayId)" : s.modelPath.c_str()); std::printf(" position : (%.2f, %.2f, %.2f)\n", s.position.x, s.position.y, s.position.z); std::printf(" orientation : %.2f deg\n", s.orientation); std::printf(" scale : %.2f\n", s.scale); std::printf(" level : %u\n", s.level); std::printf(" health/mana : %u / %u\n", s.health, s.mana); std::printf(" damage : %u-%u\n", s.minDamage, s.maxDamage); std::printf(" armor : %u\n", s.armor); std::printf(" faction : %u\n", s.faction); std::printf(" behavior : %s\n", behavior); std::printf(" wander rad : %.1f\n", s.wanderRadius); std::printf(" aggro rad : %.1f\n", s.aggroRadius); std::printf(" leash rad : %.1f\n", s.leashRadius); std::printf(" respawn ms : %u\n", s.respawnTimeMs); std::printf(" patrol points : %zu\n", s.patrolPath.size()); std::printf(" flags : %s%s%s%s\n", s.hostile ? "hostile " : "", s.questgiver ? "questgiver " : "", s.vendor ? "vendor " : "", s.trainer ? "trainer " : ""); return 0; } else if (std::strcmp(argv[i], "--info-quest") == 0 && i + 2 < argc) { // Single-quest deep dive — combines what --list-quest-objectives // and --list-quest-rewards show into one view, plus the chain // pointer + descriptions that neither covers. std::string path = argv[++i]; std::string idxStr = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "info-quest: bad idx '%s'\n", idxStr.c_str()); return 1; } wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "info-quest: failed to load %s\n", path.c_str()); return 1; } if (idx < 0 || idx >= static_cast<int>(qe.questCount())) { std::fprintf(stderr, "info-quest: idx %d out of range [0, %zu)\n", idx, qe.questCount()); return 1; } const auto& q = qe.getQuests()[idx]; 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 "?"; }; if (jsonOut) { nlohmann::json j; j["index"] = idx; j["id"] = q.id; j["title"] = q.title; j["description"] = q.description; j["completionText"] = q.completionText; j["requiredLevel"] = q.requiredLevel; j["questGiverNpcId"] = q.questGiverNpcId; j["turnInNpcId"] = q.turnInNpcId; j["nextQuestId"] = q.nextQuestId; j["reward"] = { {"xp", q.reward.xp}, {"gold", q.reward.gold}, {"silver", q.reward.silver}, {"copper", q.reward.copper}, {"items", q.reward.itemRewards} }; nlohmann::json objs = nlohmann::json::array(); for (const auto& obj : q.objectives) { objs.push_back({ {"type", typeName(obj.type)}, {"target", obj.targetName}, {"count", obj.targetCount}, {"description", obj.description} }); } j["objectives"] = objs; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Quest [%d] '%s'\n", idx, q.title.c_str()); std::printf(" id : %u\n", q.id); std::printf(" required level : %u\n", q.requiredLevel); std::printf(" giver NPC id : %u\n", q.questGiverNpcId); std::printf(" turn-in NPC id : %u\n", q.turnInNpcId); std::printf(" next quest id : %u%s\n", q.nextQuestId, q.nextQuestId == 0 ? " (terminal)" : ""); if (!q.description.empty()) { std::printf(" description : %s\n", q.description.c_str()); } if (!q.completionText.empty()) { std::printf(" completion text : %s\n", q.completionText.c_str()); } std::printf(" reward : %u XP, %ug %us %uc, %zu item(s)\n", q.reward.xp, q.reward.gold, q.reward.silver, q.reward.copper, q.reward.itemRewards.size()); for (size_t k = 0; k < q.reward.itemRewards.size(); ++k) { std::printf(" item[%zu] : %s\n", k, q.reward.itemRewards[k].c_str()); } std::printf(" objectives : %zu\n", q.objectives.size()); for (size_t k = 0; k < q.objectives.size(); ++k) { const auto& o = q.objectives[k]; std::printf(" [%zu] %-7s ×%u %s%s%s\n", k, typeName(o.type), o.targetCount, o.targetName.c_str(), o.description.empty() ? "" : " — ", o.description.c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-object") == 0 && i + 2 < argc) { // Single-object deep dive — every PlacedObject field for one // entry. Completes the single-entity inspector trio // (creature/quest/object). std::string path = argv[++i]; std::string idxStr = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; int idx; try { idx = std::stoi(idxStr); } catch (...) { std::fprintf(stderr, "info-object: bad idx '%s'\n", idxStr.c_str()); return 1; } wowee::editor::ObjectPlacer placer; if (!placer.loadFromFile(path)) { std::fprintf(stderr, "info-object: failed to load %s\n", path.c_str()); return 1; } const auto& objs = placer.getObjects(); if (idx < 0 || idx >= static_cast<int>(objs.size())) { std::fprintf(stderr, "info-object: idx %d out of range [0, %zu)\n", idx, objs.size()); return 1; } const auto& o = objs[idx]; const char* typeStr = o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"; if (jsonOut) { nlohmann::json j; j["index"] = idx; j["type"] = typeStr; j["path"] = o.path; j["nameId"] = o.nameId; j["uniqueId"] = o.uniqueId; j["position"] = {o.position.x, o.position.y, o.position.z}; j["rotation"] = {o.rotation.x, o.rotation.y, o.rotation.z}; j["scale"] = o.scale; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Object [%d]\n", idx); std::printf(" type : %s\n", typeStr); std::printf(" path : %s\n", o.path.c_str()); std::printf(" nameId : %u\n", o.nameId); std::printf(" uniqueId : %u%s\n", o.uniqueId, o.uniqueId == 0 ? " (unassigned)" : ""); std::printf(" position : (%.3f, %.3f, %.3f)\n", o.position.x, o.position.y, o.position.z); std::printf(" rotation : (%.2f, %.2f, %.2f) deg\n", o.rotation.x, o.rotation.y, o.rotation.z); std::printf(" scale : %.3f\n", o.scale); return 0; } else if (std::strcmp(argv[i], "--diff-wcp") == 0 && i + 2 < argc) { // Print which files differ between two WCP archives. Useful // when verifying that an authoring tweak only changed what // it claimed to change, or when comparing pack-WCP output // across editor versions for regression detection. std::string aPath = argv[++i]; std::string bPath = argv[++i]; // Optional --json after both paths for machine-readable output. bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::ContentPackInfo aInfo, bInfo; if (!wowee::editor::ContentPacker::readInfo(aPath, aInfo) || !wowee::editor::ContentPacker::readInfo(bPath, bInfo)) { std::fprintf(stderr, "Failed to read WCP info\n"); return 1; } std::unordered_map<std::string, uint64_t> aFiles, bFiles; for (const auto& f : aInfo.files) aFiles[f.path] = f.size; for (const auto& f : bInfo.files) bFiles[f.path] = f.size; int onlyA = 0, onlyB = 0, sizeChanged = 0, identical = 0; std::vector<std::string> onlyAList, onlyBList, changedList; // For JSON we want size-change rows as structured records, not // pre-formatted strings — collect both forms in one pass. struct ChangedRow { std::string path; uint64_t aSize, bSize; }; std::vector<ChangedRow> changedRows; for (const auto& [p, sz] : aFiles) { auto it = bFiles.find(p); if (it == bFiles.end()) { onlyA++; onlyAList.push_back(p); } else if (it->second != sz) { sizeChanged++; changedList.push_back(p + " (" + std::to_string(sz) + " -> " + std::to_string(it->second) + ")"); changedRows.push_back({p, sz, it->second}); } else identical++; } for (const auto& [p, sz] : bFiles) { if (aFiles.find(p) == aFiles.end()) { onlyB++; onlyBList.push_back(p); } } std::sort(onlyAList.begin(), onlyAList.end()); std::sort(onlyBList.begin(), onlyBList.end()); std::sort(changedList.begin(), changedList.end()); if (jsonOut) { nlohmann::json j; j["a"] = aPath; j["b"] = bPath; j["identical"] = identical; j["changed"] = sizeChanged; j["onlyA"] = onlyA; j["onlyB"] = onlyB; std::sort(changedRows.begin(), changedRows.end(), [](const auto& x, const auto& y) { return x.path < y.path; }); nlohmann::json changedArr = nlohmann::json::array(); for (const auto& c : changedRows) { changedArr.push_back({{"path", c.path}, {"aSize", c.aSize}, {"bSize", c.bSize}}); } j["changedFiles"] = changedArr; j["onlyAFiles"] = onlyAList; j["onlyBFiles"] = onlyBList; std::printf("%s\n", j.dump(2).c_str()); return (onlyA + onlyB + sizeChanged) == 0 ? 0 : 1; } std::printf("Diff: %s vs %s\n", aPath.c_str(), bPath.c_str()); std::printf(" identical : %d\n", identical); std::printf(" changed : %d\n", sizeChanged); std::printf(" only in A : %d\n", onlyA); std::printf(" only in B : %d\n", onlyB); for (const auto& s : changedList) std::printf(" ~ %s\n", s.c_str()); for (const auto& s : onlyAList) std::printf(" - %s\n", s.c_str()); for (const auto& s : onlyBList) std::printf(" + %s\n", s.c_str()); return (onlyA + onlyB + sizeChanged) == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--diff-zone") == 0 && i + 2 < argc) { // Compare two unpacked zone directories: zone.json fields, // creature names, object paths, quest titles. Useful when a // designer wants to see what changed between an upstream // template (--copy-zone source) and their customized variant, // or to verify a refactor only touched what it claimed to. std::string aDir = argv[++i]; std::string bDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; for (const auto& d : {aDir, bDir}) { if (!fs::exists(d + "/zone.json")) { std::fprintf(stderr, "diff-zone: %s has no zone.json — not a zone dir\n", d.c_str()); return 1; } } wowee::editor::ZoneManifest aZ, bZ; aZ.load(aDir + "/zone.json"); bZ.load(bDir + "/zone.json"); // Helper: load a sub-file if present, returning empty container // when missing — both sides may legitimately omit a content // file (e.g. a quest-free zone) without that being a diff per se. auto loadCreatures = [](const std::string& dir) { std::vector<std::string> names; wowee::editor::NpcSpawner sp; if (sp.loadFromFile(dir + "/creatures.json")) { for (const auto& s : sp.getSpawns()) names.push_back(s.name); } std::sort(names.begin(), names.end()); return names; }; auto loadObjectPaths = [](const std::string& dir) { std::vector<std::string> paths; wowee::editor::ObjectPlacer op; if (op.loadFromFile(dir + "/objects.json")) { for (const auto& o : op.getObjects()) paths.push_back(o.path); } std::sort(paths.begin(), paths.end()); return paths; }; auto loadQuestTitles = [](const std::string& dir) { std::vector<std::string> titles; wowee::editor::QuestEditor qe; if (qe.loadFromFile(dir + "/quests.json")) { for (const auto& q : qe.getQuests()) titles.push_back(q.title); } std::sort(titles.begin(), titles.end()); return titles; }; auto aCreatures = loadCreatures(aDir); auto bCreatures = loadCreatures(bDir); auto aObjects = loadObjectPaths(aDir); auto bObjects = loadObjectPaths(bDir); auto aQuests = loadQuestTitles(aDir); auto bQuests = loadQuestTitles(bDir); // Set diff: returns (onlyA, onlyB) where each is a sorted list. auto setDiff = [](const std::vector<std::string>& a, const std::vector<std::string>& b) { std::vector<std::string> onlyA, onlyB; std::set_difference(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(onlyA)); std::set_difference(b.begin(), b.end(), a.begin(), a.end(), std::back_inserter(onlyB)); return std::pair{onlyA, onlyB}; }; auto [creatOnlyA, creatOnlyB] = setDiff(aCreatures, bCreatures); auto [objOnlyA, objOnlyB] = setDiff(aObjects, bObjects); auto [questOnlyA, questOnlyB] = setDiff(aQuests, bQuests); // Manifest field diffs. std::vector<std::string> manifestDiffs; auto cmp = [&](const char* field, const std::string& a, const std::string& b) { if (a != b) { manifestDiffs.push_back(std::string(field) + ": '" + a + "' -> '" + b + "'"); } }; cmp("mapName", aZ.mapName, bZ.mapName); cmp("displayName", aZ.displayName, bZ.displayName); cmp("biome", aZ.biome, bZ.biome); cmp("musicTrack", aZ.musicTrack, bZ.musicTrack); if (aZ.mapId != bZ.mapId) { manifestDiffs.push_back("mapId: " + std::to_string(aZ.mapId) + " -> " + std::to_string(bZ.mapId)); } if (aZ.tiles.size() != bZ.tiles.size()) { manifestDiffs.push_back("tile count: " + std::to_string(aZ.tiles.size()) + " -> " + std::to_string(bZ.tiles.size())); } int diffs = manifestDiffs.size() + creatOnlyA.size() + creatOnlyB.size() + objOnlyA.size() + objOnlyB.size() + questOnlyA.size() + questOnlyB.size(); if (jsonOut) { nlohmann::json j; j["a"] = aDir; j["b"] = bDir; j["identical"] = (diffs == 0); j["manifestDiffs"] = manifestDiffs; j["creatures"] = {{"a", aCreatures.size()}, {"b", bCreatures.size()}, {"onlyA", creatOnlyA}, {"onlyB", creatOnlyB}}; j["objects"] = {{"a", aObjects.size()}, {"b", bObjects.size()}, {"onlyA", objOnlyA}, {"onlyB", objOnlyB}}; j["quests"] = {{"a", aQuests.size()}, {"b", bQuests.size()}, {"onlyA", questOnlyA}, {"onlyB", questOnlyB}}; j["totalDiffs"] = diffs; std::printf("%s\n", j.dump(2).c_str()); return diffs == 0 ? 0 : 1; } std::printf("Diff: %s vs %s\n", aDir.c_str(), bDir.c_str()); if (diffs == 0) { std::printf(" IDENTICAL\n"); return 0; } std::printf(" manifest : %zu field diff(s)\n", manifestDiffs.size()); for (const auto& d : manifestDiffs) std::printf(" ~ %s\n", d.c_str()); std::printf(" creatures : %zu vs %zu\n", aCreatures.size(), bCreatures.size()); for (const auto& s : creatOnlyA) std::printf(" - %s\n", s.c_str()); for (const auto& s : creatOnlyB) std::printf(" + %s\n", s.c_str()); std::printf(" objects : %zu vs %zu\n", aObjects.size(), bObjects.size()); for (const auto& s : objOnlyA) std::printf(" - %s\n", s.c_str()); for (const auto& s : objOnlyB) std::printf(" + %s\n", s.c_str()); std::printf(" quests : %zu vs %zu\n", aQuests.size(), bQuests.size()); for (const auto& s : questOnlyA) std::printf(" - %s\n", s.c_str()); for (const auto& s : questOnlyB) std::printf(" + %s\n", s.c_str()); return 1; } else if (std::strcmp(argv[i], "--diff-glb") == 0 && i + 2 < argc) { // Structural compare of two .glb files. Useful for confirming // that an alternate export path produces equivalent output // (e.g. --bake-zone-glb vs concatenated --export-whm-glbs) // or that a re-export of the same source is byte-equivalent. // Compares structure (mesh/primitive/accessor counts + // chunk sizes), NOT byte-level — JSON key ordering can vary. std::string aPath = argv[++i]; std::string bPath = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; // Reuse the parser from --info-glb. Inline here since it's // small and the alternative is a 3-way handler refactor. auto loadGlb = [](const std::string& path, uint32_t& outJsonLen, uint32_t& outBinLen, std::string& outJsonStr) -> bool { std::ifstream in(path, std::ios::binary); if (!in) return false; std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); if (bytes.size() < 20) return false; uint32_t magic, version, totalLen; std::memcpy(&magic, &bytes[0], 4); std::memcpy(&version, &bytes[4], 4); std::memcpy(&totalLen, &bytes[8], 4); if (magic != 0x46546C67 || version != 2) return false; std::memcpy(&outJsonLen, &bytes[12], 4); if (20 + outJsonLen > bytes.size()) return false; outJsonStr.assign(bytes.begin() + 20, bytes.begin() + 20 + outJsonLen); size_t binOff = 20 + outJsonLen; if (binOff + 8 <= bytes.size()) { std::memcpy(&outBinLen, &bytes[binOff], 4); } else { outBinLen = 0; } return true; }; uint32_t aJsonLen = 0, aBinLen = 0; uint32_t bJsonLen = 0, bBinLen = 0; std::string aJsonStr, bJsonStr; if (!loadGlb(aPath, aJsonLen, aBinLen, aJsonStr)) { std::fprintf(stderr, "diff-glb: failed to read %s\n", aPath.c_str()); return 1; } if (!loadGlb(bPath, bJsonLen, bBinLen, bJsonStr)) { std::fprintf(stderr, "diff-glb: failed to read %s\n", bPath.c_str()); return 1; } // Pull structural counts from JSON. Skip if parse fails on // either side — diff is meaningless then. auto countOf = [](const nlohmann::json& j, const char* key) { if (j.contains(key) && j[key].is_array()) { return static_cast<int>(j[key].size()); } return 0; }; int aMesh = 0, aPrim = 0, aAcc = 0, aBV = 0, aBuf = 0; int bMesh = 0, bPrim = 0, bAcc = 0, bBV = 0, bBuf = 0; try { auto aj = nlohmann::json::parse(aJsonStr); auto bj = nlohmann::json::parse(bJsonStr); aMesh = countOf(aj, "meshes"); bMesh = countOf(bj, "meshes"); if (aj.contains("meshes") && aj["meshes"].is_array()) { for (const auto& m : aj["meshes"]) { if (m.contains("primitives") && m["primitives"].is_array()) { aPrim += static_cast<int>(m["primitives"].size()); } } } if (bj.contains("meshes") && bj["meshes"].is_array()) { for (const auto& m : bj["meshes"]) { if (m.contains("primitives") && m["primitives"].is_array()) { bPrim += static_cast<int>(m["primitives"].size()); } } } aAcc = countOf(aj, "accessors"); bAcc = countOf(bj, "accessors"); aBV = countOf(aj, "bufferViews"); bBV = countOf(bj, "bufferViews"); aBuf = countOf(aj, "buffers"); bBuf = countOf(bj, "buffers"); } catch (const std::exception&) { std::fprintf(stderr, "diff-glb: JSON parse failed on one side\n"); return 1; } int diffs = (aMesh != bMesh) + (aPrim != bPrim) + (aAcc != bAcc) + (aBV != bBV) + (aBuf != bBuf) + (aBinLen != bBinLen); if (jsonOut) { nlohmann::json j; j["a"] = aPath; j["b"] = bPath; j["meshes"] = {{"a", aMesh}, {"b", bMesh}}; j["primitives"] = {{"a", aPrim}, {"b", bPrim}}; j["accessors"] = {{"a", aAcc}, {"b", bAcc}}; j["bufferViews"] = {{"a", aBV}, {"b", bBV}}; j["buffers"] = {{"a", aBuf}, {"b", bBuf}}; j["binBytes"] = {{"a", aBinLen},{"b", bBinLen}}; j["jsonBytes"] = {{"a", aJsonLen},{"b", bJsonLen}}; j["totalDiffs"] = diffs; j["identical"] = (diffs == 0); std::printf("%s\n", j.dump(2).c_str()); return diffs == 0 ? 0 : 1; } auto cmp = [](const char* name, int a, int b) { std::printf(" %-12s: %6d %6d %s\n", name, a, b, a == b ? "" : "DIFF"); }; std::printf("Diff: %s vs %s\n", aPath.c_str(), bPath.c_str()); std::printf(" a b\n"); cmp("meshes", aMesh, bMesh); cmp("primitives", aPrim, bPrim); cmp("accessors", aAcc, bAcc); cmp("bufferViews", aBV, bBV); cmp("buffers", aBuf, bBuf); cmp("BIN bytes", static_cast<int>(aBinLen), static_cast<int>(bBinLen)); if (diffs == 0) { std::printf(" IDENTICAL\n"); return 0; } return 1; } else if (std::strcmp(argv[i], "--diff-wom") == 0 && i + 2 < argc) { // Structural compare of two WOM models. Useful for verifying // that a --migrate-wom or round-trip through OBJ/glTF/STL // preserved the right counts. Compares sizes only — point- // wise vertex compare would be O(n²) and brittle to minor // float diffs from format conversions. std::string aBase = argv[++i]; std::string bBase = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; for (auto* base : {&aBase, &bBase}) { if (base->size() >= 4 && base->substr(base->size() - 4) == ".wom") { *base = base->substr(0, base->size() - 4); } } for (const auto& base : {aBase, bBase}) { if (!wowee::pipeline::WoweeModelLoader::exists(base)) { std::fprintf(stderr, "diff-wom: WOM not found: %s.wom\n", base.c_str()); return 1; } } auto a = wowee::pipeline::WoweeModelLoader::load(aBase); auto b = wowee::pipeline::WoweeModelLoader::load(bBase); // Each row is (label, a-value, b-value) so the table renders // straight. struct Row { const char* label; long long av, bv; }; std::vector<Row> rows = { {"version", a.version, b.version}, {"vertices", (long long)a.vertices.size(), (long long)b.vertices.size()}, {"indices", (long long)a.indices.size(), (long long)b.indices.size()}, {"triangles", (long long)(a.indices.size()/3),(long long)(b.indices.size()/3)}, {"textures", (long long)a.texturePaths.size(),(long long)b.texturePaths.size()}, {"bones", (long long)a.bones.size(), (long long)b.bones.size()}, {"animations",(long long)a.animations.size(),(long long)b.animations.size()}, {"batches", (long long)a.batches.size(), (long long)b.batches.size()}, }; // Bounds compare with float epsilon since round-trips through // text formats can perturb the last bit. 0.01-unit slop is // generous (positions are typically in yards, ~1m). auto closeBounds = [](const glm::vec3& x, const glm::vec3& y) { return std::abs(x.x - y.x) < 0.01f && std::abs(x.y - y.y) < 0.01f && std::abs(x.z - y.z) < 0.01f; }; bool boundsMatch = closeBounds(a.boundMin, b.boundMin) && closeBounds(a.boundMax, b.boundMax) && std::abs(a.boundRadius - b.boundRadius) < 0.01f; int diffs = 0; for (const auto& r : rows) if (r.av != r.bv) diffs++; if (!boundsMatch) diffs++; bool nameMatch = (a.name == b.name); if (!nameMatch) diffs++; if (jsonOut) { nlohmann::json j; j["a"] = aBase + ".wom"; j["b"] = bBase + ".wom"; for (const auto& r : rows) { j[r.label] = {{"a", r.av}, {"b", r.bv}}; } j["name"] = {{"a", a.name}, {"b", b.name}}; j["boundsMatch"] = boundsMatch; j["totalDiffs"] = diffs; j["identical"] = (diffs == 0); std::printf("%s\n", j.dump(2).c_str()); return diffs == 0 ? 0 : 1; } std::printf("Diff: %s.wom vs %s.wom\n", aBase.c_str(), bBase.c_str()); std::printf(" a b\n"); for (const auto& r : rows) { std::printf(" %-12s: %12lld %12lld %s\n", r.label, r.av, r.bv, r.av == r.bv ? "" : "DIFF"); } std::printf(" %-12s: %-13s %-13s %s\n", "name", a.name.substr(0, 13).c_str(), b.name.substr(0, 13).c_str(), nameMatch ? "" : "DIFF"); std::printf(" %-12s: %s\n", "bounds", boundsMatch ? "match" : "DIFF"); if (diffs == 0) { std::printf(" IDENTICAL\n"); return 0; } return 1; } else if (std::strcmp(argv[i], "--diff-wob") == 0 && i + 2 < argc) { // Companion to --diff-wom for buildings. Same shape: count- // based compare so round-trips through OBJ/glTF can be // validated without false positives from float perturbation. std::string aBase = argv[++i]; std::string bBase = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; for (auto* base : {&aBase, &bBase}) { if (base->size() >= 4 && base->substr(base->size() - 4) == ".wob") { *base = base->substr(0, base->size() - 4); } } for (const auto& base : {aBase, bBase}) { if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) { std::fprintf(stderr, "diff-wob: WOB not found: %s.wob\n", base.c_str()); return 1; } } auto a = wowee::pipeline::WoweeBuildingLoader::load(aBase); auto b = wowee::pipeline::WoweeBuildingLoader::load(bBase); // Aggregate vertex+index counts across all groups for the // headline 'totalVerts/totalTris' metric (matches what // --info-wob reports). auto sumGroupVerts = [](const auto& bld) { size_t s = 0; for (const auto& g : bld.groups) s += g.vertices.size(); return s; }; auto sumGroupIdx = [](const auto& bld) { size_t s = 0; for (const auto& g : bld.groups) s += g.indices.size(); return s; }; struct Row { const char* label; long long av, bv; }; // WoweeBuilding doesn't have a top-level textures vector or // doodadSets — materials and textures are per-group, doodad // sets are flattened. Aggregate the per-group counts. long long aMats = 0, bMats = 0; long long aGroupTex = 0, bGroupTex = 0; for (const auto& g : a.groups) { aMats += static_cast<long long>(g.materials.size()); aGroupTex += static_cast<long long>(g.texturePaths.size()); } for (const auto& g : b.groups) { bMats += static_cast<long long>(g.materials.size()); bGroupTex += static_cast<long long>(g.texturePaths.size()); } std::vector<Row> rows = { {"groups", (long long)a.groups.size(), (long long)b.groups.size()}, {"portals", (long long)a.portals.size(), (long long)b.portals.size()}, {"doodads", (long long)a.doodads.size(), (long long)b.doodads.size()}, {"materials", aMats, bMats}, {"groupTex", aGroupTex, bGroupTex}, {"totalVerts", (long long)sumGroupVerts(a), (long long)sumGroupVerts(b)}, {"totalIdx", (long long)sumGroupIdx(a), (long long)sumGroupIdx(b)}, }; int diffs = 0; for (const auto& r : rows) if (r.av != r.bv) diffs++; bool nameMatch = (a.name == b.name); if (!nameMatch) diffs++; bool radMatch = (std::abs(a.boundRadius - b.boundRadius) < 0.01f); if (!radMatch) diffs++; if (jsonOut) { nlohmann::json j; j["a"] = aBase + ".wob"; j["b"] = bBase + ".wob"; for (const auto& r : rows) { j[r.label] = {{"a", r.av}, {"b", r.bv}}; } j["name"] = {{"a", a.name}, {"b", b.name}}; j["boundRadiusMatch"] = radMatch; j["totalDiffs"] = diffs; j["identical"] = (diffs == 0); std::printf("%s\n", j.dump(2).c_str()); return diffs == 0 ? 0 : 1; } std::printf("Diff: %s.wob vs %s.wob\n", aBase.c_str(), bBase.c_str()); std::printf(" a b\n"); for (const auto& r : rows) { std::printf(" %-12s: %12lld %12lld %s\n", r.label, r.av, r.bv, r.av == r.bv ? "" : "DIFF"); } std::printf(" %-12s: %-13s %-13s %s\n", "name", a.name.substr(0, 13).c_str(), b.name.substr(0, 13).c_str(), nameMatch ? "" : "DIFF"); std::printf(" %-12s: %s\n", "boundRadius", radMatch ? "match" : "DIFF"); if (diffs == 0) { std::printf(" IDENTICAL\n"); return 0; } return 1; } else if (std::strcmp(argv[i], "--diff-whm") == 0 && i + 2 < argc) { // Terrain diff. Catches the common "did my edit actually // change anything?" question for heightmap tweaks. Compares // chunk presence + height range + placement counts; not // pointwise height compare since float perturbation from // round-trips would false-flag. std::string aBase = argv[++i]; std::string bBase = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; for (auto* base : {&aBase, &bBase}) { for (const char* ext : {".wot", ".whm"}) { if (base->size() >= 4 && base->substr(base->size() - 4) == ext) { *base = base->substr(0, base->size() - 4); break; } } } for (const auto& base : {aBase, bBase}) { if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) { std::fprintf(stderr, "diff-whm: WHM/WOT not found: %s.{whm,wot}\n", base.c_str()); return 1; } } wowee::pipeline::ADTTerrain a, b; wowee::pipeline::WoweeTerrainLoader::load(aBase, a); wowee::pipeline::WoweeTerrainLoader::load(bBase, b); // Per-side height range walk — same as --info-whm. auto stats = [](const wowee::pipeline::ADTTerrain& t) { struct S { int loaded; float minH, maxH; } s{0, 1e30f, -1e30f}; for (const auto& c : t.chunks) { if (!c.heightMap.isLoaded()) continue; s.loaded++; for (float h : c.heightMap.heights) { if (std::isfinite(h)) { s.minH = std::min(s.minH, h); s.maxH = std::max(s.maxH, h); } } } if (s.loaded == 0) { s.minH = 0; s.maxH = 0; } return s; }; auto sa = stats(a); auto sb = stats(b); struct Row { const char* label; long long av, bv; }; std::vector<Row> rows = { {"loadedChunks", sa.loaded, sb.loaded}, {"doodadPlace", (long long)a.doodadPlacements.size(),(long long)b.doodadPlacements.size()}, {"wmoPlace", (long long)a.wmoPlacements.size(), (long long)b.wmoPlacements.size()}, {"textures", (long long)a.textures.size(), (long long)b.textures.size()}, {"doodadNames", (long long)a.doodadNames.size(), (long long)b.doodadNames.size()}, {"wmoNames", (long long)a.wmoNames.size(), (long long)b.wmoNames.size()}, }; int diffs = 0; for (const auto& r : rows) if (r.av != r.bv) diffs++; // Tile coords + height range comparison (epsilon for floats). bool tileMatch = (a.coord.x == b.coord.x && a.coord.y == b.coord.y); if (!tileMatch) diffs++; bool heightMatch = (std::abs(sa.minH - sb.minH) < 0.01f && std::abs(sa.maxH - sb.maxH) < 0.01f); if (!heightMatch) diffs++; if (jsonOut) { nlohmann::json j; j["a"] = aBase; j["b"] = bBase; for (const auto& r : rows) { j[r.label] = {{"a", r.av}, {"b", r.bv}}; } j["tile"] = {{"a", {a.coord.x, a.coord.y}}, {"b", {b.coord.x, b.coord.y}}}; j["heightRange"] = {{"a", {sa.minH, sa.maxH}}, {"b", {sb.minH, sb.maxH}}}; j["totalDiffs"] = diffs; j["identical"] = (diffs == 0); std::printf("%s\n", j.dump(2).c_str()); return diffs == 0 ? 0 : 1; } std::printf("Diff: %s vs %s\n", aBase.c_str(), bBase.c_str()); std::printf(" a b\n"); std::printf(" %-13s: (%4d,%4d) (%4d,%4d) %s\n", "tile", a.coord.x, a.coord.y, b.coord.x, b.coord.y, tileMatch ? "" : "DIFF"); for (const auto& r : rows) { std::printf(" %-13s: %12lld %12lld %s\n", r.label, r.av, r.bv, r.av == r.bv ? "" : "DIFF"); } std::printf(" %-13s: [%.2f,%.2f] [%.2f,%.2f] %s\n", "heightRange", sa.minH, sa.maxH, sb.minH, sb.maxH, heightMatch ? "" : "DIFF"); if (diffs == 0) { std::printf(" IDENTICAL\n"); return 0; } return 1; } else if (std::strcmp(argv[i], "--diff-woc") == 0 && i + 2 < argc) { // Collision-mesh diff. Confirms a --regen-collision pass // actually changed something (or didn't, when the heightmap // tweak was below the slope threshold). std::string aPath = argv[++i]; std::string bPath = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; for (const auto& p : {aPath, bPath}) { if (!std::filesystem::exists(p)) { std::fprintf(stderr, "diff-woc: WOC not found: %s\n", p.c_str()); return 1; } } auto a = wowee::pipeline::WoweeCollisionBuilder::load(aPath); auto b = wowee::pipeline::WoweeCollisionBuilder::load(bPath); struct Row { const char* label; long long av, bv; }; std::vector<Row> rows = { {"triangles", (long long)a.triangles.size(), (long long)b.triangles.size()}, {"walkable", (long long)a.walkableCount(), (long long)b.walkableCount()}, {"steep", (long long)a.steepCount(), (long long)b.steepCount()}, }; int diffs = 0; for (const auto& r : rows) if (r.av != r.bv) diffs++; bool tileMatch = (a.tileX == b.tileX && a.tileY == b.tileY); if (!tileMatch) diffs++; if (jsonOut) { nlohmann::json j; j["a"] = aPath; j["b"] = bPath; for (const auto& r : rows) { j[r.label] = {{"a", r.av}, {"b", r.bv}}; } j["tile"] = {{"a", {a.tileX, a.tileY}}, {"b", {b.tileX, b.tileY}}}; j["totalDiffs"] = diffs; j["identical"] = (diffs == 0); std::printf("%s\n", j.dump(2).c_str()); return diffs == 0 ? 0 : 1; } std::printf("Diff: %s vs %s\n", aPath.c_str(), bPath.c_str()); std::printf(" a b\n"); std::printf(" %-12s: (%4u,%4u) (%4u,%4u) %s\n", "tile", a.tileX, a.tileY, b.tileX, b.tileY, tileMatch ? "" : "DIFF"); for (const auto& r : rows) { std::printf(" %-12s: %12lld %12lld %s\n", r.label, r.av, r.bv, r.av == r.bv ? "" : "DIFF"); } if (diffs == 0) { std::printf(" IDENTICAL\n"); return 0; } return 1; } else if (std::strcmp(argv[i], "--diff-jsondbc") == 0 && i + 2 < argc) { // JSON DBC structural diff. Catches schema regressions when // a sidecar is regenerated by a different tool version (or // a different DBC layout produces a different field count). std::string aPath = argv[++i]; std::string bPath = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; auto loadDoc = [](const std::string& p, nlohmann::json& doc) { std::ifstream in(p); if (!in) return false; try { in >> doc; } catch (...) { return false; } return true; }; nlohmann::json a, b; if (!loadDoc(aPath, a)) { std::fprintf(stderr, "diff-jsondbc: failed to read %s\n", aPath.c_str()); return 1; } if (!loadDoc(bPath, b)) { std::fprintf(stderr, "diff-jsondbc: failed to read %s\n", bPath.c_str()); return 1; } // Pull comparable fields with safe defaults. std::string aFmt = a.value("format", std::string{}); std::string bFmt = b.value("format", std::string{}); std::string aSrc = a.value("source", std::string{}); std::string bSrc = b.value("source", std::string{}); uint32_t aRC = a.value("recordCount", 0u); uint32_t bRC = b.value("recordCount", 0u); uint32_t aFC = a.value("fieldCount", 0u); uint32_t bFC = b.value("fieldCount", 0u); uint32_t aActual = (a.contains("records") && a["records"].is_array()) ? static_cast<uint32_t>(a["records"].size()) : 0u; uint32_t bActual = (b.contains("records") && b["records"].is_array()) ? static_cast<uint32_t>(b["records"].size()) : 0u; int diffs = 0; if (aFmt != bFmt) diffs++; if (aSrc != bSrc) diffs++; if (aRC != bRC) diffs++; if (aFC != bFC) diffs++; if (aActual != bActual) diffs++; if (jsonOut) { nlohmann::json j; j["a"] = aPath; j["b"] = bPath; j["format"] = {{"a", aFmt}, {"b", bFmt}}; j["source"] = {{"a", aSrc}, {"b", bSrc}}; j["recordCount"] = {{"a", aRC}, {"b", bRC}}; j["fieldCount"] = {{"a", aFC}, {"b", bFC}}; j["actualRecs"] = {{"a", aActual}, {"b", bActual}}; j["totalDiffs"] = diffs; j["identical"] = (diffs == 0); std::printf("%s\n", j.dump(2).c_str()); return diffs == 0 ? 0 : 1; } auto cmpStr = [](const char* lbl, const std::string& a, const std::string& b) { std::printf(" %-12s: %-20s %-20s %s\n", lbl, a.empty() ? "(unset)" : a.c_str(), b.empty() ? "(unset)" : b.c_str(), a == b ? "" : "DIFF"); }; auto cmpNum = [](const char* lbl, uint32_t a, uint32_t b) { std::printf(" %-12s: %20u %20u %s\n", lbl, a, b, a == b ? "" : "DIFF"); }; std::printf("Diff: %s vs %s\n", aPath.c_str(), bPath.c_str()); cmpStr("format", aFmt, bFmt); cmpStr("source", aSrc, bSrc); cmpNum("recordCount", aRC, bRC); cmpNum("fieldCount", aFC, bFC); cmpNum("actualRecs", aActual, bActual); if (diffs == 0) { std::printf(" IDENTICAL\n"); return 0; } return 1; } else if (std::strcmp(argv[i], "--diff-extract") == 0 && i + 2 < argc) { // Compare two extracted asset directories. Useful for diffing // a fresh asset_extract run against a previous baseline (did // the new MPQ add files? did any get dropped?), or comparing // what each WoW expansion contributes. std::string aDir = argv[++i]; std::string bDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; for (const auto& d : {aDir, bDir}) { if (!fs::exists(d) || !fs::is_directory(d)) { std::fprintf(stderr, "diff-extract: %s is not a directory\n", d.c_str()); return 1; } } // Tally per-extension counts + bytes for each side. struct Stats { int count = 0; uint64_t bytes = 0; }; auto walk = [](const std::string& dir) { std::map<std::string, Stats> m; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(dir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); if (ext.empty()) ext = "(no-ext)"; auto& s = m[ext]; s.count++; s.bytes += e.file_size(ec); } return m; }; auto a = walk(aDir); auto b = walk(bDir); // Union of all extensions. std::set<std::string> allExts; for (const auto& [e, _] : a) allExts.insert(e); for (const auto& [e, _] : b) allExts.insert(e); int diffs = 0; for (const auto& e : allExts) { int aC = a.count(e) ? a[e].count : 0; int bC = b.count(e) ? b[e].count : 0; if (aC != bC) diffs++; } int aTotalFiles = 0, bTotalFiles = 0; uint64_t aTotalBytes = 0, bTotalBytes = 0; for (const auto& [_, s] : a) { aTotalFiles += s.count; aTotalBytes += s.bytes; } for (const auto& [_, s] : b) { bTotalFiles += s.count; bTotalBytes += s.bytes; } if (jsonOut) { nlohmann::json j; j["a"] = aDir; j["b"] = bDir; j["totalFiles"] = {{"a", aTotalFiles}, {"b", bTotalFiles}}; j["totalBytes"] = {{"a", aTotalBytes}, {"b", bTotalBytes}}; nlohmann::json byExt = nlohmann::json::array(); for (const auto& e : allExts) { int aC = a.count(e) ? a[e].count : 0; int bC = b.count(e) ? b[e].count : 0; uint64_t aB = a.count(e) ? a[e].bytes : 0; uint64_t bB = b.count(e) ? b[e].bytes : 0; byExt.push_back({{"ext", e}, {"a", {{"count", aC}, {"bytes", aB}}}, {"b", {{"count", bC}, {"bytes", bB}}}}); } j["byExtension"] = byExt; j["totalDiffs"] = diffs; j["identical"] = (diffs == 0); std::printf("%s\n", j.dump(2).c_str()); return diffs == 0 ? 0 : 1; } std::printf("Diff: %s vs %s\n", aDir.c_str(), bDir.c_str()); std::printf(" totals: %d files / %.1f MB vs %d files / %.1f MB\n", aTotalFiles, aTotalBytes / (1024.0 * 1024.0), bTotalFiles, bTotalBytes / (1024.0 * 1024.0)); std::printf("\n Per-extension (count then bytes):\n"); std::printf(" %-12s a count b count a bytes b bytes status\n", "ext"); for (const auto& e : allExts) { int aC = a.count(e) ? a[e].count : 0; int bC = b.count(e) ? b[e].count : 0; uint64_t aB = a.count(e) ? a[e].bytes : 0; uint64_t bB = b.count(e) ? b[e].bytes : 0; const char* status = (aC == bC) ? "" : (aC == 0) ? "+B" : (bC == 0) ? "-A" : "DIFF"; std::printf(" %-12s %9d %9d %10llu %12llu %s\n", e.c_str(), aC, bC, static_cast<unsigned long long>(aB), static_cast<unsigned long long>(bB), status); } if (diffs == 0) { std::printf("\n IDENTICAL (per-extension counts match)\n"); return 0; } std::printf("\n %d extension(s) differ\n", diffs); return 1; } else if (std::strcmp(argv[i], "--diff-checksum") == 0 && i + 2 < argc) { // Compare two SHA256SUMS files (from --export-zone-checksum). // Reports which files are added / removed / changed between // two zone snapshots — much faster than walking the // filesystem to recompute hashes of unchanged content. std::string aPath = argv[++i]; std::string bPath = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; // Parse standard sha256sum format: "<64-hex> <path>" auto load = [](const std::string& p, std::map<std::string, std::string>& out) { std::ifstream in(p); if (!in) return false; std::string line; while (std::getline(in, line)) { if (line.size() < 66) continue; std::string hash = line.substr(0, 64); // Two spaces, then path. size_t sep = line.find(" ", 64); if (sep == std::string::npos) continue; std::string path = line.substr(sep + 2); out[path] = hash; } return true; }; std::map<std::string, std::string> a, b; if (!load(aPath, a)) { std::fprintf(stderr, "diff-checksum: failed to read %s\n", aPath.c_str()); return 1; } if (!load(bPath, b)) { std::fprintf(stderr, "diff-checksum: failed to read %s\n", bPath.c_str()); return 1; } std::vector<std::string> added, removed, changed; for (const auto& [path, hash] : a) { auto it = b.find(path); if (it == b.end()) removed.push_back(path); else if (it->second != hash) changed.push_back(path); } for (const auto& [path, hash] : b) { if (a.count(path) == 0) added.push_back(path); } int diffs = added.size() + removed.size() + changed.size(); if (jsonOut) { nlohmann::json j; j["a"] = aPath; j["b"] = bPath; j["added"] = added; j["removed"] = removed; j["changed"] = changed; j["totalDiffs"] = diffs; j["identical"] = (diffs == 0); std::printf("%s\n", j.dump(2).c_str()); return diffs == 0 ? 0 : 1; } std::printf("Diff: %s vs %s\n", aPath.c_str(), bPath.c_str()); std::printf(" added : %zu\n", added.size()); std::printf(" removed : %zu\n", removed.size()); std::printf(" changed : %zu\n", changed.size()); for (const auto& p : added) std::printf(" + %s\n", p.c_str()); for (const auto& p : removed) std::printf(" - %s\n", p.c_str()); for (const auto& p : changed) std::printf(" ~ %s\n", p.c_str()); if (diffs == 0) { std::printf(" IDENTICAL\n"); return 0; } return 1; } else if (std::strcmp(argv[i], "--list-wcp") == 0 && i + 1 < argc) { // Like --info-wcp but prints every file path. Useful for spotting // missing or unexpected entries before unpacking. std::string path = argv[++i]; wowee::editor::ContentPackInfo info; if (!wowee::editor::ContentPacker::readInfo(path, info)) { std::fprintf(stderr, "Failed to read WCP: %s\n", path.c_str()); return 1; } std::printf("WCP: %s — %zu files\n", path.c_str(), info.files.size()); // Sort by path so identical packs produce identical output (the // packer order depends on the directory_iterator implementation). auto files = info.files; std::sort(files.begin(), files.end(), [](const auto& a, const auto& b) { return a.path < b.path; }); for (const auto& f : files) { std::printf(" %-10s %10llu %s\n", f.category.c_str(), static_cast<unsigned long long>(f.size), f.path.c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-wcp") == 0 && i + 1 < argc) { std::string path = argv[++i]; // Optional --json after the path for machine-readable output. bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::ContentPackInfo info; if (!wowee::editor::ContentPacker::readInfo(path, info)) { std::fprintf(stderr, "Failed to read WCP: %s\n", path.c_str()); return 1; } // Per-category file totals std::unordered_map<std::string, size_t> byCat; uint64_t totalSize = 0; for (const auto& f : info.files) { byCat[f.category]++; totalSize += f.size; } if (jsonOut) { nlohmann::json j; j["wcp"] = path; j["name"] = info.name; j["author"] = info.author; j["description"] = info.description; j["version"] = info.version; j["format"] = info.format; j["mapId"] = info.mapId; j["fileCount"] = info.files.size(); j["totalBytes"] = totalSize; nlohmann::json categories = nlohmann::json::object(); for (const auto& [cat, count] : byCat) categories[cat] = count; j["categories"] = categories; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("WCP: %s\n", path.c_str()); std::printf(" name : %s\n", info.name.c_str()); std::printf(" author : %s\n", info.author.c_str()); std::printf(" description : %s\n", info.description.c_str()); std::printf(" version : %s\n", info.version.c_str()); std::printf(" format : %s\n", info.format.c_str()); std::printf(" mapId : %u\n", info.mapId); std::printf(" files : %zu\n", info.files.size()); for (const auto& [cat, count] : byCat) { std::printf(" %-10s : %zu\n", cat.c_str(), count); } std::printf(" total bytes : %.2f MB\n", totalSize / (1024.0 * 1024.0)); return 0; } else if (std::strcmp(argv[i], "--info-pack-budget") == 0 && i + 1 < argc) { // Per-extension byte breakdown of a WCP archive. --info-wcp // gives counts per category; this gives bytes per extension // so users can spot what's bloating an archive before // shipping. ('Why is my pack 80MB? Oh, the .glb baked // outputs got included.') std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; wowee::editor::ContentPackInfo info; if (!wowee::editor::ContentPacker::readInfo(path, info)) { std::fprintf(stderr, "info-pack-budget: failed to read %s\n", path.c_str()); return 1; } // Sum bytes per extension (lower-cased). std::map<std::string, std::pair<int, uint64_t>> byExt; uint64_t totalBytes = 0; for (const auto& f : info.files) { std::string ext; auto dot = f.path.find_last_of('.'); if (dot != std::string::npos) ext = f.path.substr(dot); else ext = "(no-ext)"; std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); byExt[ext].first++; byExt[ext].second += f.size; totalBytes += f.size; } // Sort by bytes descending. std::vector<std::pair<std::string, std::pair<int, uint64_t>>> sorted( byExt.begin(), byExt.end()); std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.second.second > b.second.second; }); if (jsonOut) { nlohmann::json j; j["wcp"] = path; j["totalFiles"] = info.files.size(); j["totalBytes"] = totalBytes; nlohmann::json arr = nlohmann::json::array(); for (const auto& [ext, cb] : sorted) { arr.push_back({{"ext", ext}, {"count", cb.first}, {"bytes", cb.second}}); } j["byExtension"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("WCP budget: %s\n", path.c_str()); std::printf(" total: %zu file(s), %.2f MB\n", info.files.size(), totalBytes / (1024.0 * 1024.0)); std::printf("\n ext count bytes KB share\n"); for (const auto& [ext, cb] : sorted) { double pct = totalBytes > 0 ? 100.0 * cb.second / totalBytes : 0.0; std::printf(" %-12s %6d %11llu %6.1f %5.1f%%\n", ext.c_str(), cb.first, static_cast<unsigned long long>(cb.second), cb.second / 1024.0, pct); } return 0; } else if (std::strcmp(argv[i], "--info-pack-tree") == 0 && i + 1 < argc) { // Tree view of a WCP's directory layout with per-file byte // sizes. --list-wcp shows the flat sorted file list; // --info-pack-tree gives the hierarchical view that's // easier to read for archives with subdirectories (textures // under data/, models under buildings/, etc.). std::string path = argv[++i]; wowee::editor::ContentPackInfo info; if (!wowee::editor::ContentPacker::readInfo(path, info)) { std::fprintf(stderr, "info-pack-tree: failed to read %s\n", path.c_str()); return 1; } // Build a directory tree from flat file paths. Sub-tree // children are sorted alphabetically with files before dirs // (by-convention filesystem-tree look). struct Node { std::map<std::string, std::shared_ptr<Node>> children; bool isFile = false; uint64_t bytes = 0; }; auto root = std::make_shared<Node>(); auto split = [](const std::string& p) { std::vector<std::string> parts; std::string cur; for (char c : p) { if (c == '/' || c == '\\') { if (!cur.empty()) { parts.push_back(cur); cur.clear(); } } else cur += c; } if (!cur.empty()) parts.push_back(cur); return parts; }; uint64_t totalBytes = 0; for (const auto& f : info.files) { auto parts = split(f.path); if (parts.empty()) continue; Node* cur = root.get(); for (size_t k = 0; k < parts.size(); ++k) { auto& child = cur->children[parts[k]]; if (!child) child = std::make_shared<Node>(); if (k == parts.size() - 1) { child->isFile = true; child->bytes = f.size; } cur = child.get(); } totalBytes += f.size; } // Recursive renderer with box-drawing connectors. Aggregates // child bytes up so directories show their subtotal. std::function<uint64_t(const Node*, const std::string&)> render = [&](const Node* n, const std::string& prefix) -> uint64_t { size_t i = 0; size_t total = n->children.size(); uint64_t subtotal = 0; for (const auto& [name, child] : n->children) { bool last = (++i == total); const char* branch = last ? "└─ " : "├─ "; const char* cont = last ? " " : "│ "; if (child->isFile) { std::printf("%s%s%s (%llu bytes)\n", prefix.c_str(), branch, name.c_str(), static_cast<unsigned long long>(child->bytes)); subtotal += child->bytes; } else { // Directory — recurse, then print header with subtotal. std::printf("%s%s%s/\n", prefix.c_str(), branch, name.c_str()); subtotal += render(child.get(), prefix + cont); } } return subtotal; }; std::printf("%s (%zu files, %.2f KB)\n", path.c_str(), info.files.size(), totalBytes / 1024.0); render(root.get(), ""); 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], "--export-zone-summary-md") == 0 && i + 1 < argc) { // Render a Markdown documentation page for a zone. Useful for // designers tracking changes between versions, generating // GitHub Pages docs, or reviewing zones in PRs without // round-tripping through the GUI. 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-summary-md: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "export-zone-summary-md: failed to parse %s\n", manifestPath.c_str()); return 1; } // Default output: ZONE.md sitting next to zone.json. if (outPath.empty()) outPath = zoneDir + "/ZONE.md"; // Load content sub-files; missing ones contribute 0 entries. 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"); std::ofstream md(outPath); if (!md) { std::fprintf(stderr, "export-zone-summary-md: cannot write %s\n", outPath.c_str()); return 1; } md << "# " << (zm.displayName.empty() ? zm.mapName : zm.displayName) << "\n\n"; md << "*Auto-generated by `wowee_editor --export-zone-summary-md`. " "Do not edit by hand.*\n\n"; md << "## Manifest\n\n"; md << "| Field | Value |\n"; md << "|---|---|\n"; md << "| Map name | `" << zm.mapName << "` |\n"; md << "| Display name | " << zm.displayName << " |\n"; md << "| Map ID | " << zm.mapId << " |\n"; if (!zm.biome.empty()) md << "| Biome | " << zm.biome << " |\n"; md << "| Base height | " << zm.baseHeight << " |\n"; md << "| Tile count | " << zm.tiles.size() << " |\n"; md << "| Allow flying | " << (zm.allowFlying ? "yes" : "no") << " |\n"; md << "| PvP enabled | " << (zm.pvpEnabled ? "yes" : "no") << " |\n"; md << "| Indoor | " << (zm.isIndoor ? "yes" : "no") << " |\n"; md << "| Sanctuary | " << (zm.isSanctuary ? "yes" : "no") << " |\n"; if (!zm.musicTrack.empty()) md << "| Music | `" << zm.musicTrack << "` |\n"; if (!zm.ambienceDay.empty()) md << "| Ambient (day) | `" << zm.ambienceDay << "` |\n"; if (!zm.ambienceNight.empty())md << "| Ambient (night) | `" << zm.ambienceNight << "` |\n"; if (!zm.description.empty()) { md << "\n### Description\n\n" << zm.description << "\n"; } md << "\n## Tiles\n\n"; md << "| tx | ty |\n|---|---|\n"; for (const auto& [tx, ty] : zm.tiles) { md << "| " << tx << " | " << ty << " |\n"; } md << "\n## Creatures (" << sp.spawnCount() << ")\n\n"; if (sp.spawnCount() == 0) { md << "*No creature spawns.*\n"; } else { md << "| # | Name | Lvl | DisplayId | Pos (x, y, z) | Flags |\n"; md << "|---|---|---|---|---|---|\n"; for (size_t k = 0; k < sp.spawnCount(); ++k) { const auto& s = sp.getSpawns()[k]; md << "| " << k << " | " << s.name << " | " << s.level << " | " << s.displayId << " | (" << s.position.x << ", " << s.position.y << ", " << s.position.z << ") |"; if (s.hostile) md << " hostile"; if (s.questgiver) md << " quest"; if (s.vendor) md << " vendor"; if (s.trainer) md << " trainer"; md << " |\n"; } } md << "\n## Objects (" << op.getObjects().size() << ")\n\n"; if (op.getObjects().empty()) { md << "*No object placements.*\n"; } else { md << "| # | Type | Path | Pos | Scale |\n"; md << "|---|---|---|---|---|\n"; for (size_t k = 0; k < op.getObjects().size(); ++k) { const auto& o = op.getObjects()[k]; md << "| " << k << " | " << (o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo") << " | `" << o.path << "` | (" << o.position.x << ", " << o.position.y << ", " << o.position.z << ") | " << o.scale << " |\n"; } } md << "\n## Quests (" << qe.questCount() << ")\n\n"; if (qe.questCount() == 0) { md << "*No quests.*\n"; } else { 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) { const auto& q = qe.getQuests()[k]; md << "### " << k << ". " << q.title << "\n\n"; md << "- Required level: " << q.requiredLevel << "\n"; md << "- Quest giver NPC ID: " << q.questGiverNpcId << "\n"; md << "- Turn-in NPC ID: " << q.turnInNpcId << "\n"; md << "- XP: " << q.reward.xp << "\n"; if (q.reward.gold || q.reward.silver || q.reward.copper) { md << "- Coin: " << q.reward.gold << "g " << q.reward.silver << "s " << q.reward.copper << "c\n"; } if (!q.objectives.empty()) { md << "- Objectives:\n"; for (const auto& obj : q.objectives) { md << " - **" << typeName(obj.type) << "** " << obj.targetName << " ×" << obj.targetCount; if (!obj.description.empty()) { md << " — *" << obj.description << "*"; } md << "\n"; } } if (!q.reward.itemRewards.empty()) { md << "- Item rewards:\n"; for (const auto& it : q.reward.itemRewards) { md << " - `" << it << "`\n"; } } md << "\n"; } } md.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" zone=%s, %zu tiles, %zu creatures, %zu objects, %zu quests\n", zm.mapName.c_str(), zm.tiles.size(), sp.spawnCount(), op.getObjects().size(), qe.questCount()); return 0; } else if (std::strcmp(argv[i], "--export-zone-csv") == 0 && i + 1 < argc) { // Emit creatures.csv / objects.csv / quests.csv for designers // who prefer spreadsheets over JSON. Round-trip back into the // editor isn't supported yet, but for read-only analysis (sort // by XP, group by faction, pivot tables in LibreOffice) CSV is // the lingua franca of design data. std::string zoneDir = argv[++i]; std::string outDir; if (i + 1 < argc && argv[i + 1][0] != '-') outDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(zoneDir + "/zone.json")) { std::fprintf(stderr, "export-zone-csv: %s has no zone.json\n", zoneDir.c_str()); return 1; } if (outDir.empty()) outDir = zoneDir; // CSV-escape: wrap any field containing comma/quote/newline in // double quotes; double up internal quotes per RFC 4180. 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; }; int filesWritten = 0; // Creatures wowee::editor::NpcSpawner sp; if (sp.loadFromFile(zoneDir + "/creatures.json")) { std::string out = outDir + "/creatures.csv"; std::ofstream f(out); if (!f) { std::fprintf(stderr, "cannot write %s\n", out.c_str()); return 1; } f << "index,id,name,displayId,level,health,mana,faction," "x,y,z,orientation,scale,hostile,questgiver,vendor,trainer\n"; for (size_t k = 0; k < sp.spawnCount(); ++k) { const auto& s = sp.getSpawns()[k]; f << k << "," << s.id << "," << csvEsc(s.name) << "," << s.displayId << "," << s.level << "," << s.health << "," << s.mana << "," << s.faction << "," << s.position.x << "," << s.position.y << "," << s.position.z << "," << s.orientation << "," << s.scale << "," << (s.hostile ? 1 : 0) << "," << (s.questgiver ? 1 : 0) << "," << (s.vendor ? 1 : 0) << "," << (s.trainer ? 1 : 0) << "\n"; } std::printf(" wrote %s (%zu rows)\n", out.c_str(), sp.spawnCount()); filesWritten++; } // Objects wowee::editor::ObjectPlacer op; if (op.loadFromFile(zoneDir + "/objects.json")) { std::string out = outDir + "/objects.csv"; std::ofstream f(out); if (!f) return 1; f << "index,type,path,x,y,z,rotX,rotY,rotZ,scale\n"; for (size_t k = 0; k < op.getObjects().size(); ++k) { const auto& o = op.getObjects()[k]; f << k << "," << (o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo") << "," << csvEsc(o.path) << "," << o.position.x << "," << o.position.y << "," << o.position.z << "," << o.rotation.x << "," << o.rotation.y << "," << o.rotation.z << "," << o.scale << "\n"; } std::printf(" wrote %s (%zu rows)\n", out.c_str(), op.getObjects().size()); filesWritten++; } // Quests — flatten to one row per quest. Objectives + items // are joined into a single semicolon-separated cell so the // CSV stays one-row-per-quest (designer-friendly for sorting). wowee::editor::QuestEditor qe; if (qe.loadFromFile(zoneDir + "/quests.json")) { std::string out = outDir + "/quests.csv"; std::ofstream f(out); if (!f) return 1; f << "index,id,title,requiredLevel,giverNpcId,turnInNpcId," "xp,gold,silver,copper,nextQuestId,objectiveCount," "objectives,itemRewards\n"; 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) { const auto& q = qe.getQuests()[k]; std::string objs; for (size_t o = 0; o < q.objectives.size(); ++o) { if (o) objs += "; "; objs += std::string(typeName(q.objectives[o].type)) + ":" + q.objectives[o].targetName + "x" + std::to_string(q.objectives[o].targetCount); } std::string items; for (size_t r = 0; r < q.reward.itemRewards.size(); ++r) { if (r) items += "; "; items += q.reward.itemRewards[r]; } f << k << "," << q.id << "," << csvEsc(q.title) << "," << q.requiredLevel << "," << q.questGiverNpcId << "," << q.turnInNpcId << "," << q.reward.xp << "," << q.reward.gold << "," << q.reward.silver << "," << q.reward.copper << "," << q.nextQuestId << "," << q.objectives.size() << "," << csvEsc(objs) << "," << csvEsc(items) << "\n"; } std::printf(" wrote %s (%zu rows)\n", out.c_str(), qe.questCount()); filesWritten++; } // Items — read items.json inline since the items pipeline // doesn't have a dedicated editor class yet. std::string itemsPath = zoneDir + "/items.json"; if (fs::exists(itemsPath)) { nlohmann::json doc; try { std::ifstream in(itemsPath); in >> doc; } catch (...) {} if (doc.contains("items") && doc["items"].is_array()) { std::string out = outDir + "/items.csv"; std::ofstream f(out); if (f) { f << "index,id,name,quality,itemLevel,displayId,stackable\n"; const auto& arr = doc["items"]; for (size_t k = 0; k < arr.size(); ++k) { const auto& it = arr[k]; f << 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"; } std::printf(" wrote %s (%zu rows)\n", out.c_str(), arr.size()); filesWritten++; } } } if (filesWritten == 0) { std::fprintf(stderr, "export-zone-csv: zone has no creatures/objects/quests/items to emit\n"); return 1; } std::printf("Exported %d CSV file(s) to %s\n", filesWritten, outDir.c_str()); return 0; } else if (std::strcmp(argv[i], "--export-zone-checksum") == 0 && i + 1 < argc) { // SHA-256 manifest of every source file in a zone, in the // standard sha256sum format ('<hex> <relpath>'). Lets users // verify zone integrity after a download or transfer with the // standard system tool: // wowee_editor --export-zone-checksum custom_zones/MyZone // sha256sum -c custom_zones/MyZone/SHA256SUMS 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-checksum: %s has no zone.json\n", zoneDir.c_str()); return 1; } if (outPath.empty()) outPath = zoneDir + "/SHA256SUMS"; // Source files only — derived outputs (.glb/.obj/.stl/.html/ // ZONE.md/DEPS.md/quests.dot/SHA256SUMS itself) are excluded // since they're regeneratable and would invalidate the // checksum on every rebuild. auto isDerived = [](const fs::path& p) { std::string ext = p.extension().string(); std::string name = p.filename().string(); if (ext == ".glb" || ext == ".obj" || ext == ".stl" || ext == ".html" || ext == ".dot" || ext == ".csv") return true; if (name == "ZONE.md" || name == "DEPS.md" || name == "SHA256SUMS" || name == "Makefile") return true; if (ext == ".png") return true; // BLP→PNG renders at root return false; }; std::vector<std::pair<std::string, std::string>> entries; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; if (isDerived(e.path())) continue; std::string hex = wowee_sha256::fileHex(e.path().string()); if (hex.empty()) continue; std::string rel = fs::relative(e.path(), zoneDir, ec).string(); if (ec) rel = e.path().string(); entries.push_back({hex, rel}); } std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) { return a.second < b.second; }); std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-zone-checksum: cannot write %s\n", outPath.c_str()); return 1; } for (const auto& [hash, path] : entries) { // sha256sum format: 64-char hex, two spaces, path. out << hash << " " << path << "\n"; } out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" %zu file(s) hashed (source only, derived excluded)\n", entries.size()); std::printf(" verify with: sha256sum -c %s\n", outPath.c_str()); return 0; } else if (std::strcmp(argv[i], "--export-project-checksum") == 0 && i + 1 < argc) { // Project-wide manifest in the same sha256sum format, with // paths kept relative to <projectDir> (so entries look like // "<hex> <zoneName>/<file>"). Also emits a single SHA-256 // fingerprint over the manifest itself — a one-line // identity for the whole project, handy for CI release // gates and reproducibility checks. // // wowee_editor --export-project-checksum custom_zones // sha256sum -c custom_zones/PROJECT_SHA256SUMS 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-checksum: %s is not a directory\n", projectDir.c_str()); return 1; } if (outPath.empty()) outPath = projectDir + "/PROJECT_SHA256SUMS"; // Same derived-output filter as --export-zone-checksum. auto isDerived = [](const fs::path& p) { std::string ext = p.extension().string(); std::string name = p.filename().string(); if (ext == ".glb" || ext == ".obj" || ext == ".stl" || ext == ".html" || ext == ".dot" || ext == ".csv") return true; if (name == "ZONE.md" || name == "DEPS.md" || name == "SHA256SUMS" || name == "PROJECT_SHA256SUMS" || name == "Makefile") return true; if (ext == ".png") return true; return false; }; 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::vector<std::pair<std::string, std::string>> entries; for (const auto& zoneDir : zones) { std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) { if (!e.is_regular_file()) continue; if (isDerived(e.path())) continue; std::string hex = wowee_sha256::fileHex(e.path().string()); if (hex.empty()) continue; std::string rel = fs::relative(e.path(), projectDir, ec).string(); if (ec) rel = e.path().string(); entries.push_back({hex, rel}); } } std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) { return a.second < b.second; }); std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-project-checksum: cannot write %s\n", outPath.c_str()); return 1; } // Hash the manifest body inline so the project fingerprint // is byte-identical to what `sha256sum PROJECT_SHA256SUMS` // would yield on the written file. std::string body; body.reserve(entries.size() * 80); for (const auto& [hash, path] : entries) { body += hash; body += " "; body += path; body += "\n"; } out << body; out.close(); std::string fingerprint = wowee_sha256::hex( reinterpret_cast<const uint8_t*>(body.data()), body.size()); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" files hashed : %zu\n", entries.size()); std::printf(" fingerprint : %s\n", fingerprint.c_str()); std::printf(" verify with : sha256sum -c %s\n", outPath.c_str()); return 0; } else if (std::strcmp(argv[i], "--validate-project-checksum") == 0 && i + 1 < argc) { // In-tool verification of the manifest produced by // --export-project-checksum. Equivalent to 'sha256sum -c // PROJECT_SHA256SUMS' but cross-platform — Windows and // CI runners without coreutils don't need an external tool. // Exit 1 if any file is missing or its hash drifted. std::string projectDir = argv[++i]; std::string inPath; if (i + 1 < argc && argv[i + 1][0] != '-') inPath = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "validate-project-checksum: %s is not a directory\n", projectDir.c_str()); return 1; } if (inPath.empty()) inPath = projectDir + "/PROJECT_SHA256SUMS"; std::ifstream in(inPath); if (!in) { std::fprintf(stderr, "validate-project-checksum: cannot read %s\n", inPath.c_str()); return 1; } int ok = 0, missing = 0, mismatched = 0; std::vector<std::string> failures; std::string line; while (std::getline(in, line)) { if (line.empty()) continue; // sha256sum format: 64-char hex, two spaces, path. if (line.size() < 66 || line[64] != ' ' || line[65] != ' ') { std::fprintf(stderr, " malformed line (skipped): %s\n", line.c_str()); continue; } std::string expected = line.substr(0, 64); std::string rel = line.substr(66); std::string full = projectDir + "/" + rel; if (!fs::exists(full)) { missing++; failures.push_back(rel + " (missing)"); continue; } std::string actual = wowee_sha256::fileHex(full); if (actual != expected) { mismatched++; failures.push_back(rel + " (hash mismatch)"); continue; } ok++; } std::printf("validate-project-checksum: %s\n", inPath.c_str()); std::printf(" ok : %d\n", ok); std::printf(" missing : %d\n", missing); std::printf(" mismatched : %d\n", mismatched); if (!failures.empty()) { std::printf("\n Failures:\n"); for (const auto& f : failures) std::printf(" - %s\n", f.c_str()); } return (missing == 0 && mismatched == 0) ? 0 : 1; } else if (std::strcmp(argv[i], "--export-zone-html") == 0 && i + 1 < argc) { // Generate a single-file HTML viewer next to the zone .glb. // Anyone with a modern browser can open it — no installs, no // CDN-mining the user's network. Uses model-viewer (Google's // web component) bundled from the unpkg CDN since it's // standards-based and doesn't require a build step. // // Usage flow: // wowee_editor --bake-zone-glb custom_zones/MyZone // wowee_editor --export-zone-html custom_zones/MyZone // open custom_zones/MyZone/MyZone.html # opens in browser 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-html: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "export-zone-html: parse failed\n"); return 1; } std::string glbName = zm.mapName + ".glb"; std::string glbPath = zoneDir + "/" + glbName; if (!fs::exists(glbPath)) { std::fprintf(stderr, "export-zone-html: %s does not exist — run --bake-zone-glb first\n", glbPath.c_str()); return 1; } if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".html"; std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-zone-html: cannot write %s\n", outPath.c_str()); return 1; } // Compute relative path from html file's parent dir to the // .glb so the viewer loads it. Default same-dir → just basename. std::string glbHref = glbName; // If outPath is in a different dir than the .glb, the user is // responsible for moving things; leaving glbHref as the // basename is a sensible default that fails loudly in the // browser console rather than producing a wrong-but-silent // page. std::string title = zm.displayName.empty() ? zm.mapName : zm.displayName; // Single-file template with model-viewer. The version pin // (^4.0.0) keeps the page from breaking when the unpkg // 'latest' silently bumps a major version. out << "<!doctype html>\n" "<html lang=\"en\">\n" "<head>\n" " <meta charset=\"utf-8\">\n" " <title>" << title << " — Wowee Zone Viewer\n" " \n" " \n" "\n" "\n" "
\n" "

" << title << "

\n" "
Map: " << zm.mapName << " · Tiles: " << zm.tiles.size() << " · MapId: " << zm.mapId << "
\n" "
\n" " \n" " \n" "
Generated by wowee_editor --export-zone-html
\n" "\n" "\n"; out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" references %s (must sit next to .html)\n", glbHref.c_str()); std::printf(" open in any modern browser — no install required\n"); return 0; } else if (std::strcmp(argv[i], "--export-project-html") == 0 && i + 1 < argc) { // Project-level index page linking every zone's HTML viewer. // Pairs with --export-zone-html (single zone) and // --bake-zone-glb (terrain bake). Designed for github-pages // style 'all my zones' showcase. 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-html: %s is not a directory\n", projectDir.c_str()); return 1; } if (outPath.empty()) outPath = projectDir + "/index.html"; // Walk for zones (dirs with zone.json). For each, record: // - display name // - relative path to its .html viewer (or null if not generated) // - tile count, content counts struct ZoneEntry { std::string name, dirRel, htmlRel, glbRel; bool htmlExists = false, glbExists = false; int tiles = 0, creatures = 0, objects = 0, quests = 0; }; std::vector entries; 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; ZoneEntry ze; ze.name = zm.displayName.empty() ? zm.mapName : zm.displayName; ze.dirRel = entry.path().filename().string(); ze.htmlRel = ze.dirRel + "/" + zm.mapName + ".html"; ze.glbRel = ze.dirRel + "/" + zm.mapName + ".glb"; ze.htmlExists = fs::exists(entry.path() / (zm.mapName + ".html")); ze.glbExists = fs::exists(entry.path() / (zm.mapName + ".glb")); ze.tiles = static_cast(zm.tiles.size()); wowee::editor::NpcSpawner sp; if (sp.loadFromFile((entry.path() / "creatures.json").string())) { ze.creatures = static_cast(sp.spawnCount()); } wowee::editor::ObjectPlacer op; if (op.loadFromFile((entry.path() / "objects.json").string())) { ze.objects = static_cast(op.getObjects().size()); } wowee::editor::QuestEditor qe; if (qe.loadFromFile((entry.path() / "quests.json").string())) { ze.quests = static_cast(qe.questCount()); } entries.push_back(ze); } std::sort(entries.begin(), entries.end(), [](const ZoneEntry& a, const ZoneEntry& b) { return a.name < b.name; }); std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-project-html: cannot write %s\n", outPath.c_str()); return 1; } out << "\n" "\n" "\n" " \n" " Wowee Project — Zone Index\n" " \n" "\n" "\n" "

Wowee Project — Zone Index

\n" "
" << entries.size() << " zone(s) found in " << projectDir << "
\n" "
\n"; for (const auto& z : entries) { out << "
\n" "

" << z.name << "

\n" "
" << z.tiles << " tile" << (z.tiles == 1 ? "" : "s") << " · " << z.creatures << " creature" << (z.creatures == 1 ? "" : "s") << " · " << z.objects << " object" << (z.objects == 1 ? "" : "s") << " · " << z.quests << " quest" << (z.quests == 1 ? "" : "s") << "
\n"; if (z.htmlExists) { out << " Open viewer →\n"; } else if (z.glbExists) { out << "
No HTML viewer (run --export-zone-html)
\n"; } else { out << "
No .glb (run --bake-zone-glb)
\n"; } out << "
\n"; } out << "
\n" "
Generated by wowee_editor --export-project-html
\n" "\n" "\n"; out.close(); int withViewer = 0; for (const auto& z : entries) if (z.htmlExists) withViewer++; std::printf("Wrote %s\n", outPath.c_str()); std::printf(" %zu zone(s) listed, %d with viewable HTML\n", entries.size(), withViewer); return 0; } else if (std::strcmp(argv[i], "--export-project-md") == 0 && i + 1 < argc) { // Markdown counterpart to --export-project-html. Generates a // README.md indexing every zone with counts + bake/viewer // status. GitHub renders it natively at the project root. // Pairs with --export-zone-summary-md (per-zone) — the project // README links to each zone's per-zone .md. 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-md: %s is not a directory\n", projectDir.c_str()); return 1; } if (outPath.empty()) outPath = projectDir + "/README.md"; // Per-zone collection: name + counts + which artifacts exist. struct Row { std::string name, dirRel, mapName; int tiles = 0, creatures = 0, objects = 0, quests = 0; bool hasGlb = false, hasHtml = false, hasZoneMd = false; }; std::vector rows; 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; Row r; r.name = zm.displayName.empty() ? zm.mapName : zm.displayName; r.dirRel = entry.path().filename().string(); r.mapName = zm.mapName; r.tiles = static_cast(zm.tiles.size()); wowee::editor::NpcSpawner sp; if (sp.loadFromFile((entry.path() / "creatures.json").string())) { r.creatures = static_cast(sp.spawnCount()); } wowee::editor::ObjectPlacer op; if (op.loadFromFile((entry.path() / "objects.json").string())) { r.objects = static_cast(op.getObjects().size()); } wowee::editor::QuestEditor qe; if (qe.loadFromFile((entry.path() / "quests.json").string())) { r.quests = static_cast(qe.questCount()); } r.hasGlb = fs::exists(entry.path() / (zm.mapName + ".glb")); r.hasHtml = fs::exists(entry.path() / (zm.mapName + ".html")); r.hasZoneMd = fs::exists(entry.path() / "ZONE.md"); rows.push_back(std::move(r)); } std::sort(rows.begin(), rows.end(), [](const Row& a, const Row& b) { return a.name < b.name; }); int totalT = 0, totalC = 0, totalO = 0, totalQ = 0; for (const auto& r : rows) { totalT += r.tiles; totalC += r.creatures; totalO += r.objects; totalQ += r.quests; } std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-project-md: cannot write %s\n", outPath.c_str()); return 1; } out << "# Wowee Project — Zone Index\n\n"; out << "*Auto-generated. " << rows.size() << " zone(s) discovered in `" << projectDir << "`.*\n\n"; out << "## Summary\n\n"; out << "| Metric | Total |\n|---|---:|\n"; out << "| Zones | " << rows.size() << " |\n"; out << "| Tiles | " << totalT << " |\n"; out << "| Creatures | " << totalC << " |\n"; out << "| Objects | " << totalO << " |\n"; out << "| Quests | " << totalQ << " |\n\n"; out << "## Zones\n\n"; out << "| Zone | Tiles | Creatures | Objects | Quests | Bake | Viewer | Docs |\n"; out << "|---|---:|---:|---:|---:|:---:|:---:|:---:|\n"; for (const auto& r : rows) { out << "| "; if (r.hasZoneMd) { out << "[" << r.name << "](" << r.dirRel << "/ZONE.md)"; } else { out << r.name; } out << " | " << r.tiles << " | " << r.creatures << " | " << r.objects << " | " << r.quests << " | " << (r.hasGlb ? "✓" : "—") << " | " << (r.hasHtml ? "[view](" + r.dirRel + "/" + r.mapName + ".html)" : "—") << " | " << (r.hasZoneMd ? "[md](" + r.dirRel + "/ZONE.md)" : "—") << " |\n"; } out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" %zu zone(s) indexed (%d tiles, %d creatures, %d objects, %d quests)\n", rows.size(), totalT, totalC, totalO, totalQ); return 0; } else if (std::strcmp(argv[i], "--export-quest-graph") == 0 && i + 1 < argc) { // Render quest chains as a Graphviz DOT graph. Visualizing // quest dependencies in plain text rapidly becomes unreadable // past ~10 quests; piping this through 'dot -Tpng -o q.png' // makes complex chains immediately legible. // // wowee_editor --export-quest-graph custom_zones/MyZone // dot -Tpng custom_zones/MyZone/quests.dot -o quests.png std::string zoneDir = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') { outPath = argv[++i]; } std::string path = zoneDir + "/quests.json"; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "export-quest-graph: %s not found\n", path.c_str()); return 1; } if (outPath.empty()) outPath = zoneDir + "/quests.dot"; wowee::editor::QuestEditor qe; if (!qe.loadFromFile(path)) { std::fprintf(stderr, "export-quest-graph: failed to parse %s\n", path.c_str()); return 1; } std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-quest-graph: cannot write %s\n", outPath.c_str()); return 1; } // DOT-escape strings (just quotes and backslashes) — quest // titles can include arbitrary punctuation that breaks DOT // parsing if not escaped. auto dotEsc = [](const std::string& s) { std::string out; for (char c : s) { if (c == '"' || c == '\\') out += '\\'; out += c; } return out; }; const auto& quests = qe.getQuests(); // Build an index of valid quest IDs so dangling chain // pointers can be styled differently (red, dashed). std::unordered_set validIds; for (const auto& q : quests) validIds.insert(q.id); out << "digraph QuestChains {\n"; out << " // Generated by wowee_editor --export-quest-graph\n"; out << " rankdir=LR;\n"; out << " node [shape=box, style=filled, fontname=\"sans-serif\"];\n"; // Nodes: one per quest, colored by completion-readiness: // green = has objectives + reward + valid NPCs // yellow = missing some non-fatal field (description, etc.) // gray = no objectives (won't actually complete in-game) for (const auto& q : quests) { bool hasObjs = !q.objectives.empty(); bool hasReward = (q.reward.xp > 0 || !q.reward.itemRewards.empty()); std::string color = hasObjs ? (hasReward ? "lightgreen" : "lightyellow") : "lightgray"; std::string label = "[" + std::to_string(q.id) + "] " + dotEsc(q.title); if (q.requiredLevel > 1) { label += "\\nlvl " + std::to_string(q.requiredLevel); } if (q.reward.xp > 0) { label += " " + std::to_string(q.reward.xp) + " XP"; } out << " q" << q.id << " [label=\"" << label << "\", fillcolor=" << color << "];\n"; } // Edges: quest -> nextQuestId. Style chain-pointers to // missing quests differently so they stand out visually. int chainEdges = 0, brokenEdges = 0; for (const auto& q : quests) { if (q.nextQuestId == 0) continue; if (validIds.count(q.nextQuestId) == 0) { out << " q" << q.id << " -> q" << q.nextQuestId << " [color=red, style=dashed, label=\"missing\"];\n"; out << " q" << q.nextQuestId << " [label=\" [" << q.nextQuestId << "]\", fillcolor=mistyrose, style=\"filled,dashed\"];\n"; brokenEdges++; } else { out << " q" << q.id << " -> q" << q.nextQuestId << ";\n"; chainEdges++; } } out << "}\n"; out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" %zu quests, %d chain edges, %d broken (red/dashed)\n", quests.size(), chainEdges, brokenEdges); std::printf(" next: dot -Tpng %s -o quests.png\n", outPath.c_str()); return 0; } else if (std::strcmp(argv[i], "--validate") == 0 && i + 1 < argc) { std::string zoneDir = argv[++i]; // Optional --json after the dir for machine-readable output // (matches --info-extract --json). bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; auto v = wowee::editor::ContentPacker::validateZone(zoneDir); int score = v.openFormatScore(); if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["score"] = score; j["maxScore"] = 7; j["formats"] = v.summary(); auto fmt = [&](const char* name, bool present, int count, bool valid = true, int invalid = 0) { nlohmann::json f; f["present"] = present; f["count"] = count; f["valid"] = valid; if (invalid > 0) f["invalid"] = invalid; j[name] = f; }; fmt("wot", v.hasWot, v.wotCount); fmt("whm", v.hasWhm, v.whmCount, v.whmValid); fmt("wom", v.hasWom, v.womCount, v.womValid, v.womInvalidCount); fmt("wob", v.hasWob, v.wobCount, v.wobValid, v.wobInvalidCount); fmt("woc", v.hasWoc, v.wocCount, v.wocValid, v.wocInvalidCount); fmt("png", v.hasPng, v.pngCount); j["zoneJson"] = v.hasZoneJson; j["creatures"] = v.hasCreatures; j["quests"] = v.hasQuests; j["objects"] = v.hasObjects; std::printf("%s\n", j.dump(2).c_str()); return score == 7 ? 0 : 1; } std::printf("Zone: %s\n", zoneDir.c_str()); std::printf("Open format score: %d/7\n", score); std::printf("Formats: %s\n", v.summary().c_str()); std::printf("Files present:\n"); std::printf(" WOT (terrain meta) : %s (%d)\n", v.hasWot ? "yes" : "no", v.wotCount); std::printf(" WHM (heightmap) : %s (%d)%s\n", v.hasWhm ? "yes" : "no", v.whmCount, v.hasWhm && !v.whmValid ? " (BAD MAGIC)" : ""); std::printf(" WOM (models) : %s (%d)%s\n", v.hasWom ? "yes" : "no", v.womCount, v.womInvalidCount > 0 ? (" (" + std::to_string(v.womInvalidCount) + " invalid)").c_str() : ""); std::printf(" WOB (buildings) : %s (%d)%s\n", v.hasWob ? "yes" : "no", v.wobCount, v.wobInvalidCount > 0 ? (" (" + std::to_string(v.wobInvalidCount) + " invalid)").c_str() : ""); std::printf(" WOC (collision) : %s (%d)%s\n", v.hasWoc ? "yes" : "no", v.wocCount, v.wocInvalidCount > 0 ? (" (" + std::to_string(v.wocInvalidCount) + " invalid)").c_str() : ""); std::printf(" PNG (textures) : %s (%d)\n", v.hasPng ? "yes" : "no", v.pngCount); std::printf(" zone.json : %s\n", v.hasZoneJson ? "yes" : "no"); std::printf(" creatures.json : %s\n", v.hasCreatures ? "yes" : "no"); std::printf(" quests.json : %s\n", v.hasQuests ? "yes" : "no"); std::printf(" objects.json : %s\n", v.hasObjects ? "yes" : "no"); return score == 7 ? 0 : 1; } else if (std::strcmp(argv[i], "--validate-wom") == 0 && i + 1 < argc) { // Deep consistency check on a single WOM. The loader is // deliberately lenient (it accepts older/partial files), so // silent corruption can survive load. This walks every cross- // reference and reports anything out of range. 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, "WOM not found: %s.wom\n", base.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(base); auto errors = validateWomErrors(wom); if (jsonOut) { nlohmann::json j; j["wom"] = base + ".wom"; j["version"] = wom.version; j["errorCount"] = errors.size(); j["errors"] = errors; j["passed"] = errors.empty(); std::printf("%s\n", j.dump(2).c_str()); return errors.empty() ? 0 : 1; } std::printf("WOM: %s.wom (v%u)\n", base.c_str(), wom.version); if (errors.empty()) { std::printf(" PASSED — %zu verts, %zu indices, %zu bones, %zu anims, %zu batches\n", wom.vertices.size(), wom.indices.size(), wom.bones.size(), wom.animations.size(), wom.batches.size()); return 0; } std::printf(" FAILED — %zu error(s):\n", errors.size()); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; } else if (std::strcmp(argv[i], "--validate-wob") == 0 && i + 1 < argc) { // Deep consistency check on a single WOB. Like --validate-wom // but covering buildings: per-group index/material refs, portal // group references, doodad scales, and bounds. 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); auto errors = validateWobErrors(bld); 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["errorCount"] = errors.size(); j["errors"] = errors; j["passed"] = errors.empty(); std::printf("%s\n", j.dump(2).c_str()); return errors.empty() ? 0 : 1; } std::printf("WOB: %s.wob\n", base.c_str()); std::printf(" name : %s\n", bld.name.c_str()); if (errors.empty()) { std::printf(" PASSED — %zu groups, %zu portals, %zu doodads\n", bld.groups.size(), bld.portals.size(), bld.doodads.size()); return 0; } std::printf(" FAILED — %zu error(s):\n", errors.size()); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; } else if (std::strcmp(argv[i], "--validate-woc") == 0 && i + 1 < argc) { // Deep check on a WOC collision mesh — finite vertex coords, // non-degenerate triangles, valid flag bits, sane bounds. // Catches corruption that breaks movement queries silently. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; if (!std::filesystem::exists(path)) { std::fprintf(stderr, "WOC not found: %s\n", path.c_str()); return 1; } auto woc = wowee::pipeline::WoweeCollisionBuilder::load(path); auto errors = validateWocErrors(woc); if (jsonOut) { nlohmann::json j; j["woc"] = path; j["triangles"] = woc.triangles.size(); j["walkable"] = woc.walkableCount(); j["steep"] = woc.steepCount(); j["tile"] = {woc.tileX, woc.tileY}; j["errorCount"] = errors.size(); j["errors"] = errors; j["passed"] = errors.empty(); std::printf("%s\n", j.dump(2).c_str()); return errors.empty() ? 0 : 1; } std::printf("WOC: %s\n", path.c_str()); std::printf(" tile : (%u, %u)\n", woc.tileX, woc.tileY); if (errors.empty()) { std::printf(" PASSED — %zu triangles (%zu walkable, %zu steep)\n", woc.triangles.size(), woc.walkableCount(), woc.steepCount()); return 0; } std::printf(" FAILED — %zu error(s):\n", errors.size()); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; } else if (std::strcmp(argv[i], "--validate-whm") == 0 && i + 1 < argc) { // Deep check on a WHM/WOT terrain pair — finite heights, // chunks present, placements within name-table bounds. std::string base = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) 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; } wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(base, terrain); auto errors = validateWhmErrors(terrain); if (jsonOut) { nlohmann::json j; j["whm"] = base + ".whm"; j["wot"] = base + ".wot"; j["coord"] = {terrain.coord.x, terrain.coord.y}; j["doodadPlacements"] = terrain.doodadPlacements.size(); j["wmoPlacements"] = terrain.wmoPlacements.size(); int loadedChunks = 0; for (const auto& c : terrain.chunks) if (c.heightMap.isLoaded()) loadedChunks++; j["loadedChunks"] = loadedChunks; j["errorCount"] = errors.size(); j["errors"] = errors; j["passed"] = errors.empty(); std::printf("%s\n", j.dump(2).c_str()); return errors.empty() ? 0 : 1; } std::printf("WHM/WOT: %s.{whm,wot}\n", base.c_str()); std::printf(" tile : (%d, %d)\n", terrain.coord.x, terrain.coord.y); if (errors.empty()) { int loaded = 0; for (const auto& c : terrain.chunks) if (c.heightMap.isLoaded()) loaded++; std::printf(" PASSED — %d/256 chunks, %zu doodad + %zu wmo placements\n", loaded, terrain.doodadPlacements.size(), terrain.wmoPlacements.size()); return 0; } std::printf(" FAILED — %zu error(s):\n", errors.size()); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; } else if (std::strcmp(argv[i], "--validate-all") == 0 && i + 1 < argc) { // CI gate: walk a directory, run every per-format validator on // every matching file. Aggregate counts for fast triage; per- // file errors are listed (capped at 20) so the user knows which // file to drill into with --validate-{wom,wob,woc,whm}. std::string root = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(root)) { std::fprintf(stderr, "validate-all: not found: %s\n", root.c_str()); return 1; } int womTotal = 0, womFail = 0, wobTotal = 0, wobFail = 0; int wocTotal = 0, wocFail = 0, whmTotal = 0, whmFail = 0; int totalErrors = 0; std::vector>> failures; auto recordFailure = [&](const std::string& path, const std::vector& errs) { totalErrors += errs.size(); if (failures.size() < 20) failures.push_back({path, errs}); }; for (const auto& entry : fs::recursive_directory_iterator(root)) { if (!entry.is_regular_file()) continue; std::string ext = entry.path().extension().string(); std::string base = entry.path().string(); base = base.substr(0, base.size() - ext.size()); if (ext == ".wom") { womTotal++; auto wom = wowee::pipeline::WoweeModelLoader::load(base); auto errs = validateWomErrors(wom); if (!errs.empty()) { womFail++; recordFailure(entry.path().string(), errs); } } else if (ext == ".wob") { wobTotal++; auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); auto errs = validateWobErrors(bld); if (!errs.empty()) { wobFail++; recordFailure(entry.path().string(), errs); } } else if (ext == ".woc") { wocTotal++; auto woc = wowee::pipeline::WoweeCollisionBuilder::load(entry.path().string()); auto errs = validateWocErrors(woc); if (!errs.empty()) { wocFail++; recordFailure(entry.path().string(), errs); } } else if (ext == ".whm") { // Only validate via the .whm half — .wot is its sidecar // and gets pulled in by load(base). whmTotal++; wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(base, terrain); auto errs = validateWhmErrors(terrain); if (!errs.empty()) { whmFail++; recordFailure(entry.path().string(), errs); } } } int allPassed = (womFail == 0 && wobFail == 0 && wocFail == 0 && whmFail == 0); int totalFiles = womTotal + wobTotal + wocTotal + whmTotal; if (jsonOut) { nlohmann::json j; j["root"] = root; j["wom"] = {{"total", womTotal}, {"failed", womFail}}; j["wob"] = {{"total", wobTotal}, {"failed", wobFail}}; j["woc"] = {{"total", wocTotal}, {"failed", wocFail}}; j["whm"] = {{"total", whmTotal}, {"failed", whmFail}}; j["totalErrors"] = totalErrors; j["passed"] = bool(allPassed); nlohmann::json failArr = nlohmann::json::array(); for (const auto& [path, errs] : failures) { failArr.push_back({{"file", path}, {"errors", errs}}); } j["failures"] = failArr; std::printf("%s\n", j.dump(2).c_str()); return allPassed ? 0 : 1; } std::printf("validate-all: %s\n", root.c_str()); std::printf(" WOM: %d total, %d failed\n", womTotal, womFail); std::printf(" WOB: %d total, %d failed\n", wobTotal, wobFail); std::printf(" WOC: %d total, %d failed\n", wocTotal, wocFail); std::printf(" WHM: %d total, %d failed\n", whmTotal, whmFail); if (allPassed) { std::printf(" PASSED — all %d file(s) clean\n", totalFiles); return 0; } std::printf(" FAILED — %d total error(s) across %zu file(s):\n", totalErrors, failures.size()); for (const auto& [path, errs] : failures) { std::printf(" %s:\n", path.c_str()); for (const auto& e : errs) std::printf(" - %s\n", e.c_str()); } return 1; } else if (std::strcmp(argv[i], "--validate-project") == 0 && i + 1 < argc) { // Project-level validate. Walks every zone in // and runs the per-format validators (same as --validate-all). // Aggregates pass/fail counts; exits 1 if any zone has any // validation errors. 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, "validate-project: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector 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()); // Per-zone pass/fail with file-level breakdown. struct ZoneResult { std::string name; int totalFiles, failedFiles, totalErrors; }; std::vector results; int projectFailedZones = 0; for (const auto& zoneDir : zones) { ZoneResult r{zoneDir, 0, 0, 0}; std::error_code ec; for (const auto& entry : fs::recursive_directory_iterator(zoneDir, ec)) { if (!entry.is_regular_file()) continue; std::string ext = entry.path().extension().string(); std::string base = entry.path().string(); base = base.substr(0, base.size() - ext.size()); std::vector errs; if (ext == ".wom") { r.totalFiles++; auto wom = wowee::pipeline::WoweeModelLoader::load(base); errs = validateWomErrors(wom); } else if (ext == ".wob") { r.totalFiles++; auto wob = wowee::pipeline::WoweeBuildingLoader::load(base); errs = validateWobErrors(wob); } else if (ext == ".woc") { r.totalFiles++; auto woc = wowee::pipeline::WoweeCollisionBuilder::load(entry.path().string()); errs = validateWocErrors(woc); } else if (ext == ".whm") { r.totalFiles++; wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(base, terrain); errs = validateWhmErrors(terrain); } if (!errs.empty()) { r.failedFiles++; r.totalErrors += static_cast(errs.size()); } } if (r.failedFiles > 0) projectFailedZones++; results.push_back(r); } int allPassed = (projectFailedZones == 0); if (jsonOut) { nlohmann::json j; j["projectDir"] = projectDir; j["totalZones"] = zones.size(); j["failedZones"] = projectFailedZones; j["passed"] = bool(allPassed); nlohmann::json zarr = nlohmann::json::array(); for (const auto& r : results) { zarr.push_back({ {"zone", r.name}, {"totalFiles", r.totalFiles}, {"failedFiles", r.failedFiles}, {"totalErrors", r.totalErrors} }); } j["zones"] = zarr; std::printf("%s\n", j.dump(2).c_str()); return allPassed ? 0 : 1; } std::printf("validate-project: %s\n", projectDir.c_str()); std::printf(" zones : %zu (%d failed)\n", zones.size(), projectFailedZones); std::printf("\n zone files failed errors status\n"); for (const auto& r : results) { std::string shortName = fs::path(r.name).filename().string(); std::printf(" %-26s %5d %6d %6d %s\n", shortName.substr(0, 26).c_str(), r.totalFiles, r.failedFiles, r.totalErrors, r.failedFiles == 0 ? "PASS" : "FAIL"); } if (allPassed) { std::printf("\n ALL ZONES PASSED\n"); return 0; } std::printf("\n %d zone(s) failed validation\n", projectFailedZones); return 1; } else if (std::strcmp(argv[i], "--validate-project-open-only") == 0 && i + 1 < argc) { // Release gate. Walks every file in and exits // 1 if any proprietary Blizzard asset is present (.m2, .skin, // .wmo, .blp, .dbc). Designed for CI to enforce a // "no-proprietary-assets" release condition once a project // has fully migrated to the open WOM/WOB/PNG/JSON formats. std::string projectDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "validate-project-open-only: %s is not a directory\n", projectDir.c_str()); return 1; } // Standard set of proprietary extensions. Mirrors the // "(proprietary)" categories used by --info-project-bytes. static const std::set propExt = { ".m2", ".skin", ".wmo", ".blp", ".dbc", }; std::map byExt; std::vector hits; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(projectDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); if (!propExt.count(ext)) continue; byExt[ext]++; std::string rel = fs::relative(e.path(), projectDir, ec).string(); if (ec) rel = e.path().string(); hits.push_back(rel); } std::sort(hits.begin(), hits.end()); std::printf("validate-project-open-only: %s\n", projectDir.c_str()); if (hits.empty()) { std::printf(" PASSED — no proprietary Blizzard assets present\n"); return 0; } std::printf(" FAILED — %zu proprietary file(s) remain\n", hits.size()); std::printf("\n Per-extension:\n"); for (const auto& [ext, count] : byExt) { std::printf(" %-6s : %d\n", ext.c_str(), count); } std::printf("\n Files (sorted):\n"); // Cap the file list at 50 entries so a wholly unmigrated // project doesn't fill the user's terminal. size_t shown = 0; for (const auto& h : hits) { if (shown >= 50) { std::printf(" ... and %zu more\n", hits.size() - shown); break; } std::printf(" - %s\n", h.c_str()); shown++; } return 1; } else if (std::strcmp(argv[i], "--audit-project") == 0 && i + 1 < argc) { // Composite CI gate. Re-invokes the binary to run the four // most important per-project checks back-to-back and rolls // their exit codes into a single PASS/FAIL verdict. Emits // a one-line summary for each sub-check plus the final // overall result. Designed to be the only command CI needs // to run before --pack-wcp. // // Sub-checks (ordered cheapest→most expensive so a fast // failure surfaces before the slow ones run): // 1. validate-project (per-format integrity) // 2. validate-project-open-only (no proprietary leaks) // 3. validate-project-items (items.json schema) // 4. check-project-refs (every model/NPC ref resolves) // 5. check-project-content (sane field values) // 6. audit-project-spawns (spawn Z near terrain) std::string projectDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "audit-project: %s is not a directory\n", projectDir.c_str()); return 1; } // Use the binary's own path so the audit works from any cwd. std::string self = argv[0]; // Quote both to survive paths with spaces; redirect each // sub-check's stdout to a separate temp file so the final // verdict isn't drowned in their output. auto runStep = [&](const std::string& flag) -> int { std::string cmd = "\"" + self + "\" " + flag + " \"" + projectDir + "\""; // Suppress stdout so the audit's own report stays // readable; users can rerun the individual sub-check // for full output if needed. cmd += " >/dev/null 2>&1"; // std::system returns 0 on success across POSIX and // Windows. Anything else is a failure for our purposes; // we just need PASS/FAIL granularity here. return std::system(cmd.c_str()); }; struct Step { const char* name; const char* flag; int rc; }; std::vector steps = { {"format validation ", "--validate-project", 0}, {"open-only release gate ", "--validate-project-open-only", 0}, {"items schema ", "--validate-project-items", 0}, {"reference integrity ", "--check-project-refs", 0}, {"content field sanity ", "--check-project-content", 0}, {"spawn placement ", "--audit-project-spawns", 0}, }; int totalFailed = 0; std::printf("audit-project: %s\n\n", projectDir.c_str()); for (auto& s : steps) { s.rc = runStep(s.flag); bool pass = (s.rc == 0); std::printf(" [%s] %s (%s, rc=%d)\n", pass ? "PASS" : "FAIL", s.name, s.flag, s.rc); if (!pass) totalFailed++; } std::printf("\n"); if (totalFailed == 0) { std::printf("OVERALL: PASS — project is release-ready\n"); return 0; } std::printf("OVERALL: FAIL — %d sub-check(s) failed\n", totalFailed); std::printf(" rerun a failing sub-check directly for detailed output\n"); return 1; } else if (std::strcmp(argv[i], "--bench-audit-project") == 0 && i + 1 < argc) { // Time each --audit-project sub-step end-to-end so users // can see where the slow checks are. Useful for tuning a // CI pipeline: drop the slowest check from a fast-feedback // pre-commit hook, run the full audit on push. std::string projectDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "bench-audit-project: %s is not a directory\n", projectDir.c_str()); return 1; } std::string self = argv[0]; struct Step { const char* name; const char* flag; double ms; int rc; }; std::vector steps = { {"format validation ", "--validate-project", 0, 0}, {"open-only release gate ", "--validate-project-open-only", 0, 0}, {"items schema ", "--validate-project-items", 0, 0}, {"reference integrity ", "--check-project-refs", 0, 0}, {"content field sanity ", "--check-project-content", 0, 0}, {"spawn placement ", "--audit-project-spawns", 0, 0}, }; double totalMs = 0; for (auto& s : steps) { std::string cmd = "\"" + self + "\" " + s.flag + " \"" + projectDir + "\" >/dev/null 2>&1"; auto t0 = std::chrono::steady_clock::now(); s.rc = std::system(cmd.c_str()); auto t1 = std::chrono::steady_clock::now(); s.ms = std::chrono::duration(t1 - t0).count(); totalMs += s.ms; } std::printf("bench-audit-project: %s\n", projectDir.c_str()); std::printf(" total : %.1f ms (%.2f s)\n", totalMs, totalMs / 1000.0); std::printf("\n step wall-clock share status\n"); for (const auto& s : steps) { double share = totalMs > 0 ? 100.0 * s.ms / totalMs : 0.0; std::printf(" %s %9.1f ms %5.1f%% %s (rc=%d)\n", s.name, s.ms, share, s.rc == 0 ? "ok" : "FAIL", s.rc); } return 0; } else if (std::strcmp(argv[i], "--bench-validate-project") == 0 && i + 1 < argc) { // Time --validate-project per zone. Reports avg/min/max // latency so users can spot zones that are unusually slow // to validate (huge WHM/WOC pairs, lots of WOM batches). 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-validate-project: %s is not a directory\n", projectDir.c_str()); return 1; } std::vector 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()); // Per-zone timing pass — same validator walk as // --validate-project but timing each zone separately. struct Timing { std::string name; double ms; int files; }; std::vector timings; double totalMs = 0; for (const auto& zoneDir : zones) { auto t0 = std::chrono::steady_clock::now(); int files = 0; std::error_code ec; for (const auto& entry : fs::recursive_directory_iterator(zoneDir, ec)) { if (!entry.is_regular_file()) continue; std::string ext = entry.path().extension().string(); std::string base = entry.path().string(); base = base.substr(0, base.size() - ext.size()); if (ext == ".wom") { files++; auto wom = wowee::pipeline::WoweeModelLoader::load(base); (void)validateWomErrors(wom); } else if (ext == ".wob") { files++; auto wob = wowee::pipeline::WoweeBuildingLoader::load(base); (void)validateWobErrors(wob); } else if (ext == ".woc") { files++; auto woc = wowee::pipeline::WoweeCollisionBuilder::load(entry.path().string()); (void)validateWocErrors(woc); } else if (ext == ".whm") { files++; wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(base, terrain); (void)validateWhmErrors(terrain); } } auto t1 = std::chrono::steady_clock::now(); double ms = std::chrono::duration(t1 - t0).count(); totalMs += ms; timings.push_back({fs::path(zoneDir).filename().string(), ms, files}); } // Compute aggregate stats. double avgMs = !timings.empty() ? totalMs / timings.size() : 0.0; double minMs = 1e30, maxMs = 0; std::string slowestZone; for (const auto& t : timings) { if (t.ms < minMs) minMs = t.ms; if (t.ms > maxMs) { maxMs = t.ms; slowestZone = 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"] = slowestZone; nlohmann::json arr = nlohmann::json::array(); for (const auto& t : timings) { arr.push_back({{"zone", t.name}, {"ms", t.ms}, {"files", t.files}}); } j["perZone"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Bench validate: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", timings.size()); std::printf(" total : %.2f ms\n", totalMs); std::printf(" per zone : avg=%.2f min=%.2f max=%.2f ms\n", avgMs, minMs, maxMs); if (!slowestZone.empty()) { std::printf(" slowest : %s (%.2f ms)\n", slowestZone.c_str(), maxMs); } std::printf("\n Per-zone timings:\n"); std::printf(" zone ms files ms/file\n"); for (const auto& t : timings) { double mspf = t.files > 0 ? t.ms / t.files : 0.0; std::printf(" %-26s %7.2f %5d %6.3f\n", t.name.substr(0, 26).c_str(), t.ms, t.files, mspf); } 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 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 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(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], "--validate-glb") == 0 || std::strcmp(argv[i], "--info-glb") == 0) && i + 1 < argc) { // Shared handler: --validate-glb errors out on broken structure; // --info-glb prints the same metadata but exits 0 unless the // file is unreadable. Same parser, different verdict policy. bool isValidate = (std::strcmp(argv[i], "--validate-glb") == 0); std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "%s: cannot open %s\n", isValidate ? "validate-glb" : "info-glb", path.c_str()); return 1; } std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); std::vector errors; // 12-byte header: 'glTF' magic, version=2, total length. uint32_t magic = 0, version = 0, totalLen = 0; if (bytes.size() < 12) { errors.push_back("file too short for glTF header (need 12 bytes)"); } else { std::memcpy(&magic, &bytes[0], 4); std::memcpy(&version, &bytes[4], 4); std::memcpy(&totalLen, &bytes[8], 4); if (magic != 0x46546C67) { errors.push_back("magic is not 'glTF' (0x46546C67)"); } if (version != 2) { errors.push_back("version " + std::to_string(version) + " not supported (only glTF 2.0)"); } if (totalLen != bytes.size()) { errors.push_back("totalLength=" + std::to_string(totalLen) + " != file size " + std::to_string(bytes.size())); } } // JSON chunk follows: 4-byte length, 4-byte type ('JSON'), // then payload. Then BIN chunk same shape. uint32_t jsonLen = 0, jsonType = 0; uint32_t binLen = 0, binType = 0; std::string jsonStr; std::vector binData; if (errors.empty()) { if (bytes.size() < 20) { errors.push_back("missing JSON chunk header"); } else { std::memcpy(&jsonLen, &bytes[12], 4); std::memcpy(&jsonType, &bytes[16], 4); if (jsonType != 0x4E4F534A) { errors.push_back("first chunk type is not 'JSON' (0x4E4F534A)"); } if (20 + jsonLen > bytes.size()) { errors.push_back("JSON chunk extends past file end"); } else { jsonStr.assign(bytes.begin() + 20, bytes.begin() + 20 + jsonLen); } } size_t binOff = 20 + jsonLen; if (binOff + 8 <= bytes.size()) { std::memcpy(&binLen, &bytes[binOff], 4); std::memcpy(&binType, &bytes[binOff + 4], 4); if (binType != 0x004E4942) { errors.push_back("second chunk type is not 'BIN\\0' (0x004E4942)"); } if (binOff + 8 + binLen > bytes.size()) { errors.push_back("BIN chunk extends past file end"); } else { binData.assign(bytes.begin() + binOff + 8, bytes.begin() + binOff + 8 + binLen); } } // BIN chunk is optional in spec; only flag missing if // accessors below reference a buffer. } // Parse JSON and validate structure. nlohmann::json gj; int meshCount = 0, primitiveCount = 0, accessorCount = 0, bufferViewCount = 0, bufferCount = 0; std::string assetVersion; if (errors.empty() && !jsonStr.empty()) { try { gj = nlohmann::json::parse(jsonStr); assetVersion = gj.value("/asset/version"_json_pointer, std::string{}); if (assetVersion != "2.0") { errors.push_back("asset.version is '" + assetVersion + "', not '2.0'"); } if (gj.contains("meshes") && gj["meshes"].is_array()) { meshCount = static_cast(gj["meshes"].size()); for (const auto& m : gj["meshes"]) { if (m.contains("primitives") && m["primitives"].is_array()) { primitiveCount += static_cast(m["primitives"].size()); } } } if (gj.contains("accessors") && gj["accessors"].is_array()) { accessorCount = static_cast(gj["accessors"].size()); // Verify each accessor's bufferView exists. for (size_t a = 0; a < gj["accessors"].size(); ++a) { const auto& acc = gj["accessors"][a]; if (acc.contains("bufferView")) { int bv = acc["bufferView"]; if (!gj.contains("bufferViews") || bv >= static_cast(gj["bufferViews"].size())) { errors.push_back("accessor " + std::to_string(a) + " bufferView=" + std::to_string(bv) + " out of range"); } } } } if (gj.contains("bufferViews") && gj["bufferViews"].is_array()) { bufferViewCount = static_cast(gj["bufferViews"].size()); for (size_t b = 0; b < gj["bufferViews"].size(); ++b) { const auto& bv = gj["bufferViews"][b]; uint32_t bo = bv.value("byteOffset", 0u); uint32_t bl = bv.value("byteLength", 0u); uint64_t end = uint64_t(bo) + bl; if (end > binLen) { errors.push_back("bufferView " + std::to_string(b) + " range [" + std::to_string(bo) + ", " + std::to_string(end) + ") past BIN chunk length " + std::to_string(binLen)); } } } if (gj.contains("buffers") && gj["buffers"].is_array()) { bufferCount = static_cast(gj["buffers"].size()); } } catch (const std::exception& e) { errors.push_back(std::string("JSON parse error: ") + e.what()); } } int errorCount = static_cast(errors.size()); if (jsonOut) { nlohmann::json j; j["glb"] = path; j["fileSize"] = bytes.size(); j["version"] = version; j["assetVersion"] = assetVersion; j["totalLength"] = totalLen; j["jsonLength"] = jsonLen; j["binLength"] = binLen; j["meshes"] = meshCount; j["primitives"] = primitiveCount; j["accessors"] = accessorCount; j["bufferViews"] = bufferViewCount; j["buffers"] = bufferCount; j["errorCount"] = errorCount; j["errors"] = errors; j["passed"] = errors.empty(); std::printf("%s\n", j.dump(2).c_str()); return (isValidate && errorCount > 0) ? 1 : 0; } std::printf("GLB: %s\n", path.c_str()); std::printf(" file bytes : %zu\n", bytes.size()); std::printf(" glTF version: %u (asset.version=%s)\n", version, assetVersion.empty() ? "?" : assetVersion.c_str()); std::printf(" totalLength : %u\n", totalLen); std::printf(" JSON chunk : %u bytes\n", jsonLen); std::printf(" BIN chunk : %u bytes\n", binLen); std::printf(" meshes : %d (%d primitives)\n", meshCount, primitiveCount); std::printf(" accessors : %d bufferViews: %d buffers: %d\n", accessorCount, bufferViewCount, bufferCount); if (errors.empty()) { std::printf(" PASSED\n"); return 0; } std::printf(" FAILED — %d error(s):\n", errorCount); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return isValidate ? 1 : 0; } else if (std::strcmp(argv[i], "--info-glb-tree") == 0 && i + 1 < argc) { // Pretty `tree`-style view of glTF structure. --info-glb gives // counts; this shows the actual scene→node→mesh→primitive // hierarchy with names. Useful when debugging 'why is this // imported model showing up empty in three.js?' (often // because the scene's nodes[] array references the wrong node). std::string path = argv[++i]; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "info-glb-tree: cannot open %s\n", path.c_str()); return 1; } std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); if (bytes.size() < 28) { std::fprintf(stderr, "info-glb-tree: file too short\n"); return 1; } uint32_t magic, version; std::memcpy(&magic, &bytes[0], 4); std::memcpy(&version, &bytes[4], 4); if (magic != 0x46546C67 || version != 2) { std::fprintf(stderr, "info-glb-tree: not glTF 2.0\n"); return 1; } uint32_t jsonLen; std::memcpy(&jsonLen, &bytes[12], 4); std::string jsonStr(bytes.begin() + 20, bytes.begin() + 20 + jsonLen); nlohmann::json gj; try { gj = nlohmann::json::parse(jsonStr); } catch (const std::exception& e) { std::fprintf(stderr, "info-glb-tree: JSON parse failed: %s\n", e.what()); return 1; } // Tree drawing auto branch = [](bool last) { return last ? "└─ " : "├─ "; }; auto cont = [](bool last) { return last ? " " : "│ "; }; std::printf("%s\n", path.c_str()); // Asset section std::string genName = gj.value("/asset/version"_json_pointer, std::string{}); std::string gen = gj.value("/asset/generator"_json_pointer, std::string{}); std::printf("├─ asset (v%s, %s)\n", genName.c_str(), gen.empty() ? "no generator" : gen.c_str()); // Buffers int nBuf = (gj.contains("buffers") && gj["buffers"].is_array()) ? static_cast(gj["buffers"].size()) : 0; std::printf("├─ buffers (%d)\n", nBuf); if (nBuf > 0) { for (int b = 0; b < nBuf; ++b) { bool last = (b == nBuf - 1); uint64_t bl = gj["buffers"][b].value("byteLength", 0u); std::printf("│ %s[%d] %llu bytes\n", branch(last), b, static_cast(bl)); } } // BufferViews int nBV = (gj.contains("bufferViews") && gj["bufferViews"].is_array()) ? static_cast(gj["bufferViews"].size()) : 0; std::printf("├─ bufferViews (%d)\n", nBV); for (int v = 0; v < nBV; ++v) { bool last = (v == nBV - 1); const auto& bv = gj["bufferViews"][v]; uint32_t bo = bv.value("byteOffset", 0u); uint32_t bl = bv.value("byteLength", 0u); int target = bv.value("target", 0); std::printf("│ %s[%d] off=%u len=%u%s\n", branch(last), v, bo, bl, target == 34962 ? " (vertex)" : target == 34963 ? " (index)" : ""); } // Accessors int nAcc = (gj.contains("accessors") && gj["accessors"].is_array()) ? static_cast(gj["accessors"].size()) : 0; std::printf("├─ accessors (%d)\n", nAcc); for (int a = 0; a < nAcc; ++a) { bool last = (a == nAcc - 1); const auto& acc = gj["accessors"][a]; int ct = acc.value("componentType", 0); std::string type = acc.value("type", std::string{}); uint32_t count = acc.value("count", 0u); int bv = acc.value("bufferView", -1); const char* ctName = ct == 5120 ? "i8" : ct == 5121 ? "u8" : ct == 5122 ? "i16" : ct == 5123 ? "u16" : ct == 5125 ? "u32" : ct == 5126 ? "f32" : "?"; std::printf("│ %s[%d] %s %s ×%u (bv=%d)\n", branch(last), a, ctName, type.c_str(), count, bv); } // Meshes (with primitives nested) int nMesh = (gj.contains("meshes") && gj["meshes"].is_array()) ? static_cast(gj["meshes"].size()) : 0; std::printf("├─ meshes (%d)\n", nMesh); for (int m = 0; m < nMesh; ++m) { bool lastM = (m == nMesh - 1); const auto& mesh = gj["meshes"][m]; std::string name = mesh.value("name", std::string{}); int nPrim = (mesh.contains("primitives") && mesh["primitives"].is_array()) ? static_cast(mesh["primitives"].size()) : 0; std::printf("│ %s[%d]%s%s (%d primitives)\n", branch(lastM), m, name.empty() ? "" : " ", name.c_str(), nPrim); for (int p = 0; p < nPrim; ++p) { bool lastP = (p == nPrim - 1); const auto& prim = mesh["primitives"][p]; int idxAcc = prim.value("indices", -1); int mode = prim.value("mode", 4); const char* modeName = mode == 0 ? "POINTS" : mode == 1 ? "LINES" : mode == 4 ? "TRIANGLES" : "?"; std::printf("│ %s%s[%d] %s indices=acc#%d\n", cont(lastM), branch(lastP), p, modeName, idxAcc); } } // Nodes (flat list — could be tree but glTF nodes are a graph) int nNode = (gj.contains("nodes") && gj["nodes"].is_array()) ? static_cast(gj["nodes"].size()) : 0; std::printf("├─ nodes (%d)\n", nNode); for (int n = 0; n < nNode; ++n) { bool last = (n == nNode - 1); const auto& node = gj["nodes"][n]; std::string name = node.value("name", std::string{}); int meshIdx = node.value("mesh", -1); std::printf("│ %s[%d]%s%s%s\n", branch(last), n, name.empty() ? "" : " ", name.c_str(), meshIdx >= 0 ? (" -> mesh#" + std::to_string(meshIdx)).c_str() : ""); } // Scenes (last branch) int nScene = (gj.contains("scenes") && gj["scenes"].is_array()) ? static_cast(gj["scenes"].size()) : 0; std::printf("└─ scenes (%d, default=%d)\n", nScene, gj.value("scene", 0)); for (int s = 0; s < nScene; ++s) { bool lastS = (s == nScene - 1); const auto& scene = gj["scenes"][s]; int nodeRefs = (scene.contains("nodes") && scene["nodes"].is_array()) ? static_cast(scene["nodes"].size()) : 0; std::printf(" %s[%d] nodes=[", branch(lastS), s); if (scene.contains("nodes") && scene["nodes"].is_array()) { for (size_t k = 0; k < scene["nodes"].size(); ++k) { std::printf("%s%d", k ? "," : "", scene["nodes"][k].get()); } } std::printf("] (%d nodes)\n", nodeRefs); } return 0; } else if (std::strcmp(argv[i], "--info-glb-bytes") == 0 && i + 1 < argc) { // Per-section + per-bufferView byte breakdown of a .glb. Useful // for understanding what's bloating a baked .glb (vertex attrs // vs indices, position vs uv vs normal data, mesh-level // payloads). Pairs with --info-glb (counts) and --info-glb-tree // (structure). std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "info-glb-bytes: cannot open %s\n", path.c_str()); return 1; } std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); if (bytes.size() < 28) { std::fprintf(stderr, "info-glb-bytes: file too short\n"); return 1; } uint32_t magic, version; std::memcpy(&magic, &bytes[0], 4); std::memcpy(&version, &bytes[4], 4); if (magic != 0x46546C67 || version != 2) { std::fprintf(stderr, "info-glb-bytes: not glTF 2.0\n"); return 1; } uint32_t jsonLen, binLen = 0; std::memcpy(&jsonLen, &bytes[12], 4); std::string jsonStr(bytes.begin() + 20, bytes.begin() + 20 + jsonLen); size_t binOff = 20 + jsonLen; if (binOff + 8 <= bytes.size()) { std::memcpy(&binLen, &bytes[binOff], 4); } uint32_t headerBytes = 12; // magic+version+totalLength uint32_t jsonHdrBytes = 8; // jsonLen + jsonType uint32_t binHdrBytes = (binLen > 0) ? 8 : 0; nlohmann::json gj; try { gj = nlohmann::json::parse(jsonStr); } catch (const std::exception& e) { std::fprintf(stderr, "info-glb-bytes: JSON parse failed: %s\n", e.what()); return 1; } // Per-bufferView size table. struct BV { int idx; uint32_t off, len; std::string label; }; std::vector bufferViews; if (gj.contains("bufferViews") && gj["bufferViews"].is_array()) { for (size_t k = 0; k < gj["bufferViews"].size(); ++k) { const auto& bv = gj["bufferViews"][k]; BV b; b.idx = static_cast(k); b.off = bv.value("byteOffset", 0u); b.len = bv.value("byteLength", 0u); int target = bv.value("target", 0); b.label = (target == 34962) ? "vertex" : (target == 34963) ? "index" : "other"; bufferViews.push_back(b); } } // Bucket bufferViews by purpose using accessor types. // Walk accessors: each references a bufferView, with type // (VEC3/VEC2/SCALAR) hinting at content (position/uv/etc.) std::map bytesByPurpose; if (gj.contains("accessors") && gj["accessors"].is_array() && gj.contains("meshes") && gj["meshes"].is_array()) { std::set seenAccessors; for (const auto& m : gj["meshes"]) { if (!m.contains("primitives") || !m["primitives"].is_array()) continue; for (const auto& p : m["primitives"]) { if (!p.contains("attributes")) continue; for (auto it = p["attributes"].begin(); it != p["attributes"].end(); ++it) { int ai = it.value().get(); if (seenAccessors.count(ai)) continue; seenAccessors.insert(ai); if (ai < 0 || ai >= static_cast(gj["accessors"].size())) continue; const auto& acc = gj["accessors"][ai]; int bv = acc.value("bufferView", -1); if (bv < 0 || bv >= static_cast(bufferViews.size())) continue; std::string typeStr = acc.value("type", std::string{}); int comp = acc.value("componentType", 0); uint32_t cnt = acc.value("count", 0u); uint32_t byteStride = typeStr == "VEC3" ? 12 : typeStr == "VEC2" ? 8 : typeStr == "VEC4" ? 16 : typeStr == "SCALAR" ? (comp == 5126 ? 4 : comp == 5125 ? 4 : comp == 5123 ? 2 : comp == 5121 ? 1 : 4) : 4; uint64_t b = uint64_t(cnt) * byteStride; bytesByPurpose[it.key()] += b; } // Indices accessor. if (p.contains("indices")) { int ai = p["indices"].get(); if (seenAccessors.count(ai)) continue; seenAccessors.insert(ai); if (ai < 0 || ai >= static_cast(gj["accessors"].size())) continue; const auto& acc = gj["accessors"][ai]; uint32_t cnt = acc.value("count", 0u); int comp = acc.value("componentType", 0); uint32_t s = (comp == 5125 ? 4 : comp == 5123 ? 2 : 4); bytesByPurpose["INDICES"] += uint64_t(cnt) * s; } } } } uint64_t totalBytes = bytes.size(); if (jsonOut) { nlohmann::json j; j["glb"] = path; j["totalBytes"] = totalBytes; j["sections"] = { {"header", headerBytes}, {"jsonHeader", jsonHdrBytes}, {"json", jsonLen}, {"binHeader", binHdrBytes}, {"bin", binLen} }; nlohmann::json bvArr = nlohmann::json::array(); for (const auto& bv : bufferViews) { bvArr.push_back({{"index", bv.idx}, {"target", bv.label}, {"bytes", bv.len}}); } j["bufferViews"] = bvArr; nlohmann::json byPurp = nlohmann::json::object(); for (const auto& [p, b] : bytesByPurpose) byPurp[p] = b; j["byPurpose"] = byPurp; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("GLB bytes: %s\n", path.c_str()); std::printf(" total: %llu bytes (%.2f MB)\n", static_cast(totalBytes), totalBytes / (1024.0 * 1024.0)); std::printf("\n Sections:\n"); auto pct = [&](uint64_t v) { return totalBytes ? 100.0 * v / totalBytes : 0.0; }; std::printf(" header : %5u bytes %5.2f%%\n", headerBytes, pct(headerBytes)); std::printf(" JSON hdr : %5u bytes %5.2f%%\n", jsonHdrBytes, pct(jsonHdrBytes)); std::printf(" JSON : %5u bytes %5.2f%%\n", jsonLen, pct(jsonLen)); std::printf(" BIN hdr : %5u bytes %5.2f%%\n", binHdrBytes, pct(binHdrBytes)); std::printf(" BIN : %5u bytes %5.2f%%\n", binLen, pct(binLen)); if (!bufferViews.empty()) { std::printf("\n BufferViews:\n"); std::printf(" idx target bytes MB share-of-bin\n"); for (const auto& bv : bufferViews) { double bvPct = binLen ? 100.0 * bv.len / binLen : 0.0; std::printf(" %3d %-7s %8u %6.2f %5.2f%%\n", bv.idx, bv.label.c_str(), bv.len, bv.len / (1024.0 * 1024.0), bvPct); } } if (!bytesByPurpose.empty()) { std::printf("\n By attribute:\n"); for (const auto& [p, b] : bytesByPurpose) { double bPct = binLen ? 100.0 * b / binLen : 0.0; std::printf(" %-12s %8llu bytes (%.2f%% of BIN)\n", p.c_str(), static_cast(b), bPct); } } return 0; } else if (std::strcmp(argv[i], "--check-glb-bounds") == 0 && i + 1 < argc) { // Cross-checks every position accessor's claimed min/max // against the actual data in the BIN chunk. glTF viewers use // these for camera framing and frustum culling — stale // values (e.g. from a tool that edited geometry without // recomputing) cause models to vanish at certain angles or // get framed wrong on load. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "check-glb-bounds: cannot open %s\n", path.c_str()); return 1; } std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); // Parse glb structure (re-implements --validate-glb's parser // since we need access to the BIN chunk bytes here). if (bytes.size() < 28) { std::fprintf(stderr, "check-glb-bounds: file too short to be a .glb\n"); return 1; } uint32_t magic, version; std::memcpy(&magic, &bytes[0], 4); std::memcpy(&version, &bytes[4], 4); if (magic != 0x46546C67 || version != 2) { std::fprintf(stderr, "check-glb-bounds: not a valid glTF 2.0 binary\n"); return 1; } uint32_t jsonLen, jsonType; std::memcpy(&jsonLen, &bytes[12], 4); std::memcpy(&jsonType, &bytes[16], 4); std::string jsonStr(bytes.begin() + 20, bytes.begin() + 20 + jsonLen); size_t binOff = 20 + jsonLen; std::memcpy(&magic, &bytes[binOff + 4], 4); // chunkType const uint8_t* binData = &bytes[binOff + 8]; uint32_t binLen; std::memcpy(&binLen, &bytes[binOff], 4); (void)binLen; // not range-checked here; --validate-glb does that nlohmann::json gj; try { gj = nlohmann::json::parse(jsonStr); } catch (const std::exception& e) { std::fprintf(stderr, "check-glb-bounds: JSON parse failed: %s\n", e.what()); return 1; } std::vector errors; int posAccessors = 0, mismatched = 0; // Walk all primitives, collect their POSITION accessor index, // dedupe (multiple primitives can share an accessor — only // recompute once per unique). std::set posAccIndices; if (gj.contains("meshes") && gj["meshes"].is_array()) { for (const auto& m : gj["meshes"]) { if (!m.contains("primitives") || !m["primitives"].is_array()) continue; for (const auto& p : m["primitives"]) { if (p.contains("attributes") && p["attributes"].contains("POSITION")) { posAccIndices.insert(p["attributes"]["POSITION"].get()); } } } } const auto& accessors = gj["accessors"]; const auto& bufferViews = gj["bufferViews"]; for (int ai : posAccIndices) { if (ai < 0 || ai >= static_cast(accessors.size())) { errors.push_back("position accessor " + std::to_string(ai) + " out of range"); continue; } const auto& acc = accessors[ai]; if (acc.value("type", std::string{}) != "VEC3" || acc.value("componentType", 0) != 5126) { errors.push_back("accessor " + std::to_string(ai) + " is not VEC3 FLOAT"); continue; } posAccessors++; int bvIdx = acc.value("bufferView", -1); if (bvIdx < 0 || bvIdx >= static_cast(bufferViews.size())) { errors.push_back("accessor " + std::to_string(ai) + " bufferView " + std::to_string(bvIdx) + " out of range"); continue; } const auto& bv = bufferViews[bvIdx]; uint32_t bvOff = bv.value("byteOffset", 0u); uint32_t accOff = acc.value("byteOffset", 0u); uint32_t count = acc.value("count", 0u); const uint8_t* p = binData + bvOff + accOff; glm::vec3 actualMin{1e30f}, actualMax{-1e30f}; for (uint32_t v = 0; v < count; ++v) { glm::vec3 pos; std::memcpy(&pos.x, p + v * 12 + 0, 4); std::memcpy(&pos.y, p + v * 12 + 4, 4); std::memcpy(&pos.z, p + v * 12 + 8, 4); actualMin = glm::min(actualMin, pos); actualMax = glm::max(actualMax, pos); } // Compare against claimed min/max (within float epsilon). glm::vec3 claimedMin{0}, claimedMax{0}; bool hasClaimed = (acc.contains("min") && acc.contains("max")); if (hasClaimed) { claimedMin.x = acc["min"][0]; claimedMin.y = acc["min"][1]; claimedMin.z = acc["min"][2]; claimedMax.x = acc["max"][0]; claimedMax.y = acc["max"][1]; claimedMax.z = acc["max"][2]; auto close = [](float a, float b) { return std::abs(a - b) < 1e-3f; }; bool ok = close(claimedMin.x, actualMin.x) && close(claimedMin.y, actualMin.y) && close(claimedMin.z, actualMin.z) && close(claimedMax.x, actualMax.x) && close(claimedMax.y, actualMax.y) && close(claimedMax.z, actualMax.z); if (!ok) { mismatched++; char buf[256]; std::snprintf(buf, sizeof(buf), "accessor %d bounds mismatch: claimed [%g,%g,%g]-[%g,%g,%g] vs actual [%g,%g,%g]-[%g,%g,%g]", ai, claimedMin.x, claimedMin.y, claimedMin.z, claimedMax.x, claimedMax.y, claimedMax.z, actualMin.x, actualMin.y, actualMin.z, actualMax.x, actualMax.y, actualMax.z); errors.push_back(buf); } } else { // glTF spec requires position accessors to declare min/max. errors.push_back("accessor " + std::to_string(ai) + " missing required min/max for POSITION attribute"); mismatched++; } } if (jsonOut) { nlohmann::json j; j["glb"] = path; j["positionAccessors"] = posAccessors; j["mismatched"] = mismatched; j["errors"] = errors; j["passed"] = errors.empty(); std::printf("%s\n", j.dump(2).c_str()); return errors.empty() ? 0 : 1; } std::printf("GLB bounds: %s\n", path.c_str()); std::printf(" position accessors checked : %d\n", posAccessors); std::printf(" mismatched : %d\n", mismatched); if (errors.empty()) { std::printf(" PASSED\n"); return 0; } std::printf(" FAILED — %zu error(s):\n", errors.size()); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; } else if (std::strcmp(argv[i], "--validate-stl") == 0 && i + 1 < argc) { // Structural validator for ASCII STL — pairs with --export-stl // and --import-stl (and --bake-zone-stl). Catches truncation, // missing solid framing, mismatched facet/vertex counts, and // non-finite vertex coords that would crash a slicer's mesh // analyzer. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path); if (!in) { std::fprintf(stderr, "validate-stl: cannot open %s\n", path.c_str()); return 1; } std::vector errors; std::string solidName; int facetCount = 0, vertCount = 0, nonFinite = 0; int facetsOpen = 0; // facet-without-endfacet leak detector bool sawSolid = false, sawEndsolid = false; int currentFacetVerts = 0; std::string line; int lineNum = 0; while (std::getline(in, line)) { lineNum++; while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back(); if (line.empty()) continue; std::istringstream ss(line); std::string tok; ss >> tok; if (tok == "solid") { if (sawSolid) { errors.push_back("line " + std::to_string(lineNum) + ": multiple 'solid' headers"); } sawSolid = true; ss >> solidName; } else if (tok == "facet") { facetCount++; facetsOpen++; currentFacetVerts = 0; std::string nrmTok; ss >> nrmTok; if (nrmTok != "normal") { errors.push_back("line " + std::to_string(lineNum) + ": 'facet' missing 'normal' subtoken"); } else { float nx, ny, nz; if (!(ss >> nx >> ny >> nz)) { errors.push_back("line " + std::to_string(lineNum) + ": 'facet normal' missing 3 floats"); } else if (!std::isfinite(nx) || !std::isfinite(ny) || !std::isfinite(nz)) { errors.push_back("line " + std::to_string(lineNum) + ": non-finite facet normal"); nonFinite++; } } } else if (tok == "vertex") { vertCount++; currentFacetVerts++; float x, y, z; if (!(ss >> x >> y >> z)) { errors.push_back("line " + std::to_string(lineNum) + ": 'vertex' missing 3 floats"); } else if (!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(z)) { nonFinite++; if (errors.size() < 30) { errors.push_back("line " + std::to_string(lineNum) + ": non-finite vertex coord"); } } } else if (tok == "endfacet") { facetsOpen--; if (currentFacetVerts != 3) { errors.push_back("line " + std::to_string(lineNum) + ": facet has " + std::to_string(currentFacetVerts) + " vertices, expected exactly 3"); } } else if (tok == "endsolid") { sawEndsolid = true; } // outer loop / endloop are required by spec but ignored // here; their absence doesn't break parsing as long as // the vertex count per facet is correct. } if (!sawSolid) errors.push_back("missing 'solid' header"); if (!sawEndsolid) errors.push_back("missing 'endsolid' footer"); if (facetsOpen != 0) { errors.push_back(std::to_string(facetsOpen) + " unclosed 'facet' (missing 'endfacet')"); } if (vertCount != facetCount * 3) { errors.push_back("vertex count " + std::to_string(vertCount) + " != 3 * facet count " + std::to_string(facetCount)); } if (jsonOut) { nlohmann::json j; j["stl"] = path; j["solidName"] = solidName; j["facetCount"] = facetCount; j["vertexCount"] = vertCount; j["nonFiniteCount"] = nonFinite; j["errorCount"] = errors.size(); j["errors"] = errors; j["passed"] = errors.empty(); std::printf("%s\n", j.dump(2).c_str()); return errors.empty() ? 0 : 1; } std::printf("STL: %s\n", path.c_str()); std::printf(" solid name : %s\n", solidName.empty() ? "(unset)" : solidName.c_str()); std::printf(" facets : %d\n", facetCount); std::printf(" vertices : %d\n", vertCount); if (nonFinite > 0) { std::printf(" non-finite : %d\n", nonFinite); } if (errors.empty()) { std::printf(" PASSED\n"); return 0; } std::printf(" FAILED — %zu error(s):\n", errors.size()); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; } else if (std::strcmp(argv[i], "--validate-png") == 0 && i + 1 < argc) { // Full PNG structural validator — beyond --info-png's // header-only sniff. Walks every chunk, verifies CRC, // ensures IHDR/IDAT/IEND are present and ordered correctly. // Catches the kind of corruption (truncation mid-IDAT, // bit-flip in CRC) that browsers/decoders silently skip. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "validate-png: cannot open %s\n", path.c_str()); return 1; } std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); std::vector errors; // PNG signature: 89 50 4E 47 0D 0A 1A 0A static const uint8_t kSig[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; if (bytes.size() < 8 || std::memcmp(bytes.data(), kSig, 8) != 0) { errors.push_back("missing PNG signature"); } // CRC32 table per PNG spec (matches the standard polynomial // 0xEDB88320; building once via constexpr-eligible logic). uint32_t crcTable[256]; for (uint32_t n = 0; n < 256; ++n) { uint32_t c = n; for (int k = 0; k < 8; ++k) { c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); } crcTable[n] = c; } auto crc32 = [&](const uint8_t* data, size_t len) { uint32_t c = 0xFFFFFFFFu; for (size_t k = 0; k < len; ++k) { c = crcTable[(c ^ data[k]) & 0xFF] ^ (c >> 8); } return c ^ 0xFFFFFFFFu; }; auto be32 = [](const uint8_t* p) { return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | uint32_t(p[3]); }; int chunkCount = 0; int badCrcs = 0; bool sawIHDR = false, sawIDAT = false, sawIEND = false; bool ihdrFirst = false; std::string firstChunkType; uint32_t width = 0, height = 0; uint8_t bitDepth = 0, colorType = 0; // Walk chunks: each is length(4) + type(4) + data(length) + crc(4). size_t off = 8; while (errors.empty() && off + 12 <= bytes.size()) { uint32_t len = be32(&bytes[off]); if (off + 8 + len + 4 > bytes.size()) { errors.push_back("chunk at offset " + std::to_string(off) + " extends past file end"); break; } std::string type(reinterpret_cast(&bytes[off + 4]), 4); if (chunkCount == 0) { firstChunkType = type; ihdrFirst = (type == "IHDR"); } chunkCount++; if (type == "IHDR") { sawIHDR = true; if (len >= 13) { width = be32(&bytes[off + 8]); height = be32(&bytes[off + 12]); bitDepth = bytes[off + 16]; colorType = bytes[off + 17]; } } else if (type == "IDAT") { sawIDAT = true; } else if (type == "IEND") { sawIEND = true; } // Verify CRC (computed over type + data, not length). uint32_t storedCrc = be32(&bytes[off + 8 + len]); uint32_t actualCrc = crc32(&bytes[off + 4], 4 + len); if (storedCrc != actualCrc) { badCrcs++; if (errors.size() < 10) { char buf[128]; std::snprintf(buf, sizeof(buf), "chunk '%s' at offset %zu: CRC mismatch (stored=0x%08X actual=0x%08X)", type.c_str(), off, storedCrc, actualCrc); errors.push_back(buf); } } off += 8 + len + 4; } if (!ihdrFirst) { errors.push_back("first chunk is '" + firstChunkType + "', expected 'IHDR'"); } if (!sawIHDR) errors.push_back("missing required IHDR chunk"); if (!sawIDAT) errors.push_back("missing required IDAT chunk"); if (!sawIEND) errors.push_back("missing required IEND chunk"); if (off < bytes.size()) { errors.push_back(std::to_string(bytes.size() - off) + " trailing bytes after IEND chunk"); } if (jsonOut) { nlohmann::json j; j["png"] = path; j["width"] = width; j["height"] = height; j["bitDepth"] = bitDepth; j["colorType"] = colorType; j["chunkCount"] = chunkCount; j["badCrcs"] = badCrcs; j["fileSize"] = bytes.size(); j["errors"] = errors; j["passed"] = errors.empty(); std::printf("%s\n", j.dump(2).c_str()); return errors.empty() ? 0 : 1; } std::printf("PNG: %s\n", path.c_str()); std::printf(" size : %u x %u\n", width, height); std::printf(" bit depth : %u (color type %u)\n", bitDepth, colorType); std::printf(" chunks : %d (%d CRC mismatches)\n", chunkCount, badCrcs); std::printf(" file bytes : %zu\n", bytes.size()); if (errors.empty()) { std::printf(" PASSED\n"); return 0; } std::printf(" FAILED — %zu error(s):\n", errors.size()); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; } else if (std::strcmp(argv[i], "--validate-blp") == 0 && i + 1 < argc) { // BLP structural validator. --info-blp shows header fields // (full decode); this checks structural invariants without // decoding pixels — useful for spot-checking thousands of // BLPs in an extract dir without paying the DXT decompress // cost on each. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path, std::ios::binary); if (!in) { std::fprintf(stderr, "validate-blp: cannot open %s\n", path.c_str()); return 1; } std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); std::vector errors; uint32_t width = 0, height = 0; std::string magic; int validMips = 0; // BLP1 and BLP2 share magic 'BLP1' / 'BLP2' at byte 0; both // have 16 mipOffset slots + 16 mipSize slots after the // initial header (offsets vary by version). if (bytes.size() < 8) { errors.push_back("file too short to be a BLP"); } else { magic.assign(bytes.begin(), bytes.begin() + 4); if (magic != "BLP1" && magic != "BLP2") { errors.push_back("magic is '" + magic + "', expected 'BLP1' or 'BLP2'"); } } // BLP1 layout (post-magic): // compression(4) + alphaBits(4) + width(4) + height(4) + // extra(4) + hasMips(4) + mipOffsets[16](64) + mipSizes[16](64) + // palette[256](1024) [palette only present if compression==1] // BLP2 layout (post-magic): // version(4) + compression(1) + alphaDepth(1) + // alphaEncoding(1) + hasMips(1) + width(4) + height(4) + // mipOffsets[16](64) + mipSizes[16](64) + palette[256](1024) uint32_t mipOffPos = 0, mipSzPos = 0; if (errors.empty()) { auto le32 = [&](size_t off) { uint32_t v = 0; if (off + 4 <= bytes.size()) std::memcpy(&v, &bytes[off], 4); return v; }; if (magic == "BLP1") { width = le32(4 + 8); // skip magic + comp + alphaBits height = le32(4 + 12); mipOffPos = 4 + 24; // after extra + hasMips mipSzPos = 4 + 24 + 64; } else { width = le32(4 + 8); // BLP2: skip magic + version + 4 bytes height = le32(4 + 12); mipOffPos = 4 + 16; mipSzPos = 4 + 16 + 64; } if (width == 0 || height == 0) { errors.push_back("zero width or height in header"); } if (width > 8192 || height > 8192) { errors.push_back("dimensions " + std::to_string(width) + "x" + std::to_string(height) + " exceed 8192 (rejected by texture exporter)"); } // Walk the mipOffset/mipSize tables and verify each // mip's data range is within the file. Stops at the // first zero offset (BLP convention for unused slots). if (mipSzPos + 64 <= bytes.size()) { for (int m = 0; m < 16; ++m) { uint32_t off = le32(mipOffPos + m * 4); uint32_t sz = le32(mipSzPos + m * 4); if (off == 0 && sz == 0) break; // unused slot if (off == 0 || sz == 0) { errors.push_back("mip " + std::to_string(m) + " has off=0 but size=" + std::to_string(sz) + " (or vice versa)"); continue; } if (uint64_t(off) + sz > bytes.size()) { errors.push_back("mip " + std::to_string(m) + " range [" + std::to_string(off) + ", " + std::to_string(off + sz) + ") past file end " + std::to_string(bytes.size())); } else { validMips++; } } } } if (jsonOut) { nlohmann::json j; j["blp"] = path; j["magic"] = magic; j["width"] = width; j["height"] = height; j["validMips"] = validMips; j["fileSize"] = bytes.size(); j["errors"] = errors; j["passed"] = errors.empty(); std::printf("%s\n", j.dump(2).c_str()); return errors.empty() ? 0 : 1; } std::printf("BLP: %s\n", path.c_str()); std::printf(" magic : %s\n", magic.empty() ? "(none)" : magic.c_str()); std::printf(" size : %u x %u\n", width, height); std::printf(" valid mips : %d\n", validMips); std::printf(" file bytes : %zu\n", bytes.size()); if (errors.empty()) { std::printf(" PASSED\n"); return 0; } std::printf(" FAILED — %zu error(s):\n", errors.size()); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; } else if (std::strcmp(argv[i], "--validate-jsondbc") == 0 && i + 1 < argc) { // Strict schema validator for JSON DBC sidecars. --info-jsondbc // checks that header recordCount matches the actual records[] // length; this goes deeper: // - format tag is the wowee 1.0 string // - source field present (so re-import knows which DBC slot) // - recordCount + fieldCount are non-negative integers // - records is an array // - each record is an array exactly fieldCount long // - each cell is string|number|bool|null (no objects/arrays) // Catches the kind of corruption that load() might silently // tolerate (missing fields default to 0/empty), letting the // editor's runtime DBC loader downstream-fail in confusing // ways. std::string path = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; std::ifstream in(path); if (!in) { std::fprintf(stderr, "validate-jsondbc: cannot open %s\n", path.c_str()); return 1; } nlohmann::json doc; std::vector errors; try { in >> doc; } catch (const std::exception& e) { errors.push_back(std::string("JSON parse error: ") + e.what()); } std::string format, source; uint32_t recordCount = 0, fieldCount = 0; uint32_t actualRecs = 0; int badRowWidths = 0, badCellTypes = 0; if (errors.empty()) { if (!doc.is_object()) { errors.push_back("top-level value is not a JSON object"); } else { if (!doc.contains("format")) { errors.push_back("missing 'format' field"); } else if (!doc["format"].is_string()) { errors.push_back("'format' field is not a string"); } else { format = doc["format"].get(); if (format != "wowee-dbc-json-1.0") { errors.push_back("'format' is '" + format + "', expected 'wowee-dbc-json-1.0'"); } } if (!doc.contains("source")) { errors.push_back("missing 'source' field (re-import needs it)"); } else { source = doc.value("source", std::string{}); } if (!doc.contains("recordCount") || !doc["recordCount"].is_number_integer()) { errors.push_back("'recordCount' missing or not an integer"); } else { recordCount = doc["recordCount"].get(); } if (!doc.contains("fieldCount") || !doc["fieldCount"].is_number_integer()) { errors.push_back("'fieldCount' missing or not an integer"); } else { fieldCount = doc["fieldCount"].get(); } if (!doc.contains("records") || !doc["records"].is_array()) { errors.push_back("'records' missing or not an array"); } else { const auto& records = doc["records"]; actualRecs = static_cast(records.size()); if (actualRecs != recordCount) { errors.push_back("recordCount " + std::to_string(recordCount) + " != actual records " + std::to_string(actualRecs)); } for (size_t r = 0; r < records.size(); ++r) { const auto& row = records[r]; if (!row.is_array()) { errors.push_back("record[" + std::to_string(r) + "] is not an array"); continue; } if (row.size() != fieldCount) { badRowWidths++; if (badRowWidths <= 3) { errors.push_back("record[" + std::to_string(r) + "] has " + std::to_string(row.size()) + " cells, expected " + std::to_string(fieldCount)); } } for (size_t c = 0; c < row.size(); ++c) { const auto& cell = row[c]; bool ok = cell.is_string() || cell.is_number() || cell.is_boolean() || cell.is_null(); if (!ok) { badCellTypes++; if (badCellTypes <= 3) { errors.push_back("record[" + std::to_string(r) + "][" + std::to_string(c) + "] has invalid type (objects/arrays not allowed)"); } } } } if (badRowWidths > 3) { errors.push_back("... and " + std::to_string(badRowWidths - 3) + " more rows with wrong cell count"); } if (badCellTypes > 3) { errors.push_back("... and " + std::to_string(badCellTypes - 3) + " more cells with invalid types"); } } } } int errorCount = static_cast(errors.size()); if (jsonOut) { nlohmann::json j; j["jsondbc"] = path; j["format"] = format; j["source"] = source; j["recordCount"] = recordCount; j["fieldCount"] = fieldCount; j["actualRecords"] = actualRecs; j["errorCount"] = errorCount; j["errors"] = errors; j["passed"] = errors.empty(); std::printf("%s\n", j.dump(2).c_str()); return errors.empty() ? 0 : 1; } std::printf("JSON DBC: %s\n", path.c_str()); std::printf(" format : %s\n", format.empty() ? "?" : format.c_str()); std::printf(" source : %s\n", source.empty() ? "?" : source.c_str()); std::printf(" records : %u (header) / %u (actual)\n", recordCount, actualRecs); std::printf(" fields : %u\n", fieldCount); if (errors.empty()) { std::printf(" PASSED\n"); return 0; } std::printf(" FAILED — %d error(s):\n", errorCount); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; } else if (std::strcmp(argv[i], "--export-obj") == 0 && i + 1 < argc) { // Convert WOM (our open M2 replacement) to Wavefront OBJ — a // universally supported text format that opens directly in // Blender, MeshLab, ZBrush, Maya, and basically every other 3D // tool ever made. Makes the open-format ecosystem actually // useful for content authors who don't want to write a custom // WOM importer for their DCC of choice. 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) == ".wom") base = base.substr(0, base.size() - 4); if (!wowee::pipeline::WoweeModelLoader::exists(base)) { std::fprintf(stderr, "WOM not found: %s.wom\n", base.c_str()); return 1; } if (outPath.empty()) outPath = base + ".obj"; auto wom = wowee::pipeline::WoweeModelLoader::load(base); if (!wom.isValid()) { std::fprintf(stderr, "WOM has no geometry to export: %s.wom\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; } // Header — preserves provenance so a designer reopening the OBJ // weeks later knows where it came from. The MTL line is a // courtesy: we don't currently emit a .mtl, but downstream // tools won't error without one either. obj << "# Wavefront OBJ generated by wowee_editor --export-obj\n"; obj << "# Source: " << base << ".wom (v" << wom.version << ")\n"; obj << "# Verts: " << wom.vertices.size() << " Tris: " << wom.indices.size() / 3 << " Textures: " << wom.texturePaths.size() << "\n\n"; obj << "o " << (wom.name.empty() ? "WoweeModel" : wom.name) << "\n"; // Positions (v), texcoords (vt), normals (vn) — OBJ flips V so // that the same UVs that look right in our Vulkan renderer // also look right in Blender's bottom-left UV convention. for (const auto& v : wom.vertices) { obj << "v " << v.position.x << " " << v.position.y << " " << v.position.z << "\n"; } for (const auto& v : wom.vertices) { obj << "vt " << v.texCoord.x << " " << (1.0f - v.texCoord.y) << "\n"; } for (const auto& v : wom.vertices) { obj << "vn " << v.normal.x << " " << v.normal.y << " " << v.normal.z << "\n"; } // Faces — split per-batch so each material/texture range becomes // its own group. Falls back to a single group when the WOM // wasn't authored with batches (WOM1/WOM2). OBJ indices are // 1-based, hence the +1. auto emitFaces = [&](const char* groupName, uint32_t start, uint32_t count) { obj << "g " << groupName << "\n"; for (uint32_t k = 0; k < count; k += 3) { uint32_t i0 = wom.indices[start + k] + 1; uint32_t i1 = wom.indices[start + k + 1] + 1; uint32_t i2 = wom.indices[start + k + 2] + 1; obj << "f " << i0 << "/" << i0 << "/" << i0 << " " << i1 << "/" << i1 << "/" << i1 << " " << i2 << "/" << i2 << "/" << i2 << "\n"; } }; if (wom.batches.empty()) { emitFaces("mesh", 0, static_cast(wom.indices.size())); } else { for (size_t b = 0; b < wom.batches.size(); ++b) { const auto& batch = wom.batches[b]; std::string groupName = "batch_" + std::to_string(b); if (batch.textureIndex < wom.texturePaths.size()) { // Strip directory + extension for a readable group // name; full path is preserved in the file header // comment so nothing is lost. std::string tex = wom.texturePaths[batch.textureIndex]; auto slash = tex.find_last_of("/\\"); if (slash != std::string::npos) tex = tex.substr(slash + 1); auto dot = tex.find_last_of('.'); if (dot != std::string::npos) tex = tex.substr(0, dot); if (!tex.empty()) groupName += "_" + tex; } emitFaces(groupName.c_str(), batch.indexStart, batch.indexCount); } } obj.close(); std::printf("Exported %s.wom -> %s\n", base.c_str(), outPath.c_str()); std::printf(" %zu verts, %zu tris, %zu groups\n", wom.vertices.size(), wom.indices.size() / 3, wom.batches.empty() ? size_t(1) : wom.batches.size()); return 0; } else if (std::strcmp(argv[i], "--export-glb") == 0 && i + 1 < argc) { // glTF 2.0 binary (.glb) export — modern industry standard // that, unlike OBJ, supports skinning + animations + PBR // materials natively. v1 here writes positions/normals/UVs/ // indices as a single mesh (or one primitive per WOM3 batch); // bones/anims are deliberately not yet emitted because glTF's // joint matrix layout differs from WOM's bone tree and needs // a careful re-mapping pass. // // Why this matters: glTF is what Sketchfab, Three.js, Babylon.js, // and Unity/Unreal-via-import all consume. Shipping WOM through // .glb makes our open binary format viewable in any modern // browser-based 3D viewer with zero conversion friction. 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) == ".wom") base = base.substr(0, base.size() - 4); if (!wowee::pipeline::WoweeModelLoader::exists(base)) { std::fprintf(stderr, "WOM not found: %s.wom\n", base.c_str()); return 1; } if (outPath.empty()) outPath = base + ".glb"; auto wom = wowee::pipeline::WoweeModelLoader::load(base); if (!wom.isValid()) { std::fprintf(stderr, "WOM has no geometry: %s.wom\n", base.c_str()); return 1; } // BIN chunk layout — sections ordered so each accessor's // byteOffset is naturally aligned for its component type: // positions (vec3 float) : 12 bytes/vert, offset 0 // normals (vec3 float) : 12 bytes/vert // uvs (vec2 float) : 8 bytes/vert // indices (uint32) : 4 bytes each // After 32 bytes per vertex, indices start at a 4-byte aligned // offset for free. const uint32_t vCount = static_cast(wom.vertices.size()); const uint32_t iCount = static_cast(wom.indices.size()); const uint32_t posOff = 0; const uint32_t nrmOff = posOff + vCount * 12; const uint32_t uvOff = nrmOff + vCount * 12; const uint32_t idxOff = uvOff + vCount * 8; const uint32_t binSize = idxOff + iCount * 4; std::vector bin(binSize); // Pack positions for (uint32_t v = 0; v < vCount; ++v) { const auto& vert = wom.vertices[v]; std::memcpy(&bin[posOff + v * 12 + 0], &vert.position.x, 4); std::memcpy(&bin[posOff + v * 12 + 4], &vert.position.y, 4); std::memcpy(&bin[posOff + v * 12 + 8], &vert.position.z, 4); std::memcpy(&bin[nrmOff + v * 12 + 0], &vert.normal.x, 4); std::memcpy(&bin[nrmOff + v * 12 + 4], &vert.normal.y, 4); std::memcpy(&bin[nrmOff + v * 12 + 8], &vert.normal.z, 4); std::memcpy(&bin[uvOff + v * 8 + 0], &vert.texCoord.x, 4); std::memcpy(&bin[uvOff + v * 8 + 4], &vert.texCoord.y, 4); } std::memcpy(&bin[idxOff], wom.indices.data(), iCount * 4); // Compute bounds for the position accessor's min/max — glTF // viewers rely on these for camera framing and culling. glm::vec3 bMin{1e30f}, bMax{-1e30f}; for (const auto& v : wom.vertices) { bMin = glm::min(bMin, v.position); bMax = glm::max(bMax, v.position); } // Build the JSON structure. nlohmann::json keeps insertion // order in dump(), but glTF readers are key-based so order // doesn't matter functionally. nlohmann::json gj; gj["asset"] = {{"version", "2.0"}, {"generator", "wowee_editor --export-glb"}}; gj["scene"] = 0; gj["scenes"] = nlohmann::json::array({nlohmann::json{{"nodes", {0}}}}); gj["nodes"] = nlohmann::json::array({nlohmann::json{ {"name", wom.name.empty() ? "WoweeModel" : wom.name}, {"mesh", 0} }}); gj["buffers"] = nlohmann::json::array({nlohmann::json{ {"byteLength", binSize} }}); // BufferViews: one per attribute + one per index range. // Per WOM3 batch we slice the index bufferView with separate // accessors so each batch becomes its own primitive. nlohmann::json bufferViews = nlohmann::json::array(); // 0: positions, 1: normals, 2: uvs, 3: indices (whole range) bufferViews.push_back({{"buffer", 0}, {"byteOffset", posOff}, {"byteLength", vCount * 12}, {"target", 34962}}); // ARRAY_BUFFER bufferViews.push_back({{"buffer", 0}, {"byteOffset", nrmOff}, {"byteLength", vCount * 12}, {"target", 34962}}); bufferViews.push_back({{"buffer", 0}, {"byteOffset", uvOff}, {"byteLength", vCount * 8}, {"target", 34962}}); bufferViews.push_back({{"buffer", 0}, {"byteOffset", idxOff}, {"byteLength", iCount * 4}, {"target", 34963}}); // ELEMENT_ARRAY_BUFFER gj["bufferViews"] = bufferViews; // Accessors: 0=position, 1=normal, 2=uv, 3..N=indices (one // per primitive, sliced from bufferView 3). nlohmann::json accessors = nlohmann::json::array(); accessors.push_back({ {"bufferView", 0}, {"componentType", 5126}, // FLOAT {"count", vCount}, {"type", "VEC3"}, {"min", {bMin.x, bMin.y, bMin.z}}, {"max", {bMax.x, bMax.y, bMax.z}} }); accessors.push_back({ {"bufferView", 1}, {"componentType", 5126}, {"count", vCount}, {"type", "VEC3"} }); accessors.push_back({ {"bufferView", 2}, {"componentType", 5126}, {"count", vCount}, {"type", "VEC2"} }); // Build primitives — one per WOM3 batch, or one over the // whole index range if no batches. nlohmann::json primitives = nlohmann::json::array(); auto addPrimitive = [&](uint32_t idxStart, uint32_t idxCount) { uint32_t accessorIdx = static_cast(accessors.size()); accessors.push_back({ {"bufferView", 3}, {"byteOffset", idxStart * 4}, {"componentType", 5125}, // UNSIGNED_INT {"count", idxCount}, {"type", "SCALAR"} }); primitives.push_back({ {"attributes", {{"POSITION", 0}, {"NORMAL", 1}, {"TEXCOORD_0", 2}}}, {"indices", accessorIdx}, {"mode", 4} // TRIANGLES }); }; if (wom.batches.empty()) { addPrimitive(0, iCount); } else { for (const auto& b : wom.batches) { addPrimitive(b.indexStart, b.indexCount); } } gj["accessors"] = accessors; gj["meshes"] = nlohmann::json::array({nlohmann::json{ {"primitives", primitives} }}); // Serialize JSON to bytes; pad to 4-byte boundary with spaces // (glTF spec requires JSON chunk padded with 0x20). std::string jsonStr = gj.dump(); while (jsonStr.size() % 4 != 0) jsonStr += ' '; // BIN chunk pads to 4-byte boundary with zeros (already // satisfied since binSize = idxOff + iCount*4 and idxOff is // 4-byte aligned). uint32_t jsonLen = static_cast(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; } // Header: magic, version, total length (all little-endian uint32) uint32_t magic = 0x46546C67; // 'glTF' uint32_t version = 2; out.write(reinterpret_cast(&magic), 4); out.write(reinterpret_cast(&version), 4); out.write(reinterpret_cast(&totalLen), 4); // JSON chunk header + payload uint32_t jsonChunkType = 0x4E4F534A; // 'JSON' out.write(reinterpret_cast(&jsonLen), 4); out.write(reinterpret_cast(&jsonChunkType), 4); out.write(jsonStr.data(), jsonLen); // BIN chunk header + payload uint32_t binChunkType = 0x004E4942; // 'BIN\0' out.write(reinterpret_cast(&binLen), 4); out.write(reinterpret_cast(&binChunkType), 4); out.write(reinterpret_cast(bin.data()), binLen); out.close(); std::printf("Exported %s.wom -> %s\n", base.c_str(), outPath.c_str()); std::printf(" %u verts, %u tris, %zu primitive(s), %u-byte binary chunk\n", vCount, iCount / 3, primitives.size(), binLen); return 0; } else if (std::strcmp(argv[i], "--export-stl") == 0 && i + 1 < argc) { // ASCII STL export — single most universal 3D-printer format. // Cura, PrusaSlicer, Bambu Studio, Slic3r, OctoPrint, MakerBot // — every slicer made in the last 25 years opens STL natively. // Lets WOM models drive physical prints with no conversion // friction beyond this one command. 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) == ".wom") base = base.substr(0, base.size() - 4); if (!wowee::pipeline::WoweeModelLoader::exists(base)) { std::fprintf(stderr, "WOM not found: %s.wom\n", base.c_str()); return 1; } if (outPath.empty()) outPath = base + ".stl"; auto wom = wowee::pipeline::WoweeModelLoader::load(base); if (!wom.isValid()) { std::fprintf(stderr, "WOM has no geometry: %s.wom\n", base.c_str()); return 1; } std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str()); return 1; } // STL solid name must be alphanumeric + underscores per loose // convention; sanitize whatever the WOM name contains. Empty // -> 'wowee_model'. std::string solidName = wom.name.empty() ? "wowee_model" : wom.name; for (auto& c : solidName) { if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')) c = '_'; } out << "solid " << solidName << "\n"; // Per-triangle facet — STL has no shared vertex pool, every // triangle stands alone. Compute face normal from cross product // (STL spec requires unit-length face normal; viewers fall // back to per-vertex if zero, but most slicers want the real // value for orientation hints). uint32_t triCount = 0; for (size_t k = 0; k + 2 < wom.indices.size(); k += 3) { uint32_t i0 = wom.indices[k]; uint32_t i1 = wom.indices[k + 1]; uint32_t i2 = wom.indices[k + 2]; if (i0 >= wom.vertices.size() || i1 >= wom.vertices.size() || i2 >= wom.vertices.size()) continue; const auto& v0 = wom.vertices[i0].position; const auto& v1 = wom.vertices[i1].position; const auto& v2 = wom.vertices[i2].position; glm::vec3 e1 = v1 - v0; glm::vec3 e2 = v2 - v0; glm::vec3 n = glm::cross(e1, e2); float len = glm::length(n); if (len > 1e-12f) n /= len; else n = {0, 0, 1}; // degenerate — STL spec allows any unit normal out << " facet normal " << n.x << " " << n.y << " " << n.z << "\n" << " outer loop\n" << " vertex " << v0.x << " " << v0.y << " " << v0.z << "\n" << " vertex " << v1.x << " " << v1.y << " " << v1.z << "\n" << " vertex " << v2.x << " " << v2.y << " " << v2.z << "\n" << " endloop\n" << " endfacet\n"; triCount++; } out << "endsolid " << solidName << "\n"; out.close(); std::printf("Exported %s.wom -> %s\n", base.c_str(), outPath.c_str()); std::printf(" solid '%s', %u facets\n", solidName.c_str(), triCount); return 0; } else if (std::strcmp(argv[i], "--import-stl") == 0 && i + 1 < argc) { // ASCII STL -> WOM. Closes the STL round trip so designers can // edit prints in TinkerCAD/Meshmixer/SolidWorks and bring them // back to the engine. Dedupes vertices on (pos, normal) so the // resulting WOM vertex buffer stays compact. std::string stlPath = argv[++i]; std::string womBase; if (i + 1 < argc && argv[i + 1][0] != '-') womBase = argv[++i]; if (!std::filesystem::exists(stlPath)) { std::fprintf(stderr, "STL not found: %s\n", stlPath.c_str()); return 1; } if (womBase.empty()) { womBase = stlPath; if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".stl") { womBase = womBase.substr(0, womBase.size() - 4); } } std::ifstream in(stlPath); if (!in) { std::fprintf(stderr, "Failed to open STL: %s\n", stlPath.c_str()); return 1; } wowee::pipeline::WoweeModel wom; wom.version = 1; // Dedupe key: 6 floats (pos + normal) packed as a string. Loose // matching, but exact for round-trips since we write the same // floats back. Real-world STLs from CAD tools rarely benefit // from looser tolerance — they already share verts at the // exporter level. std::unordered_map dedupe; auto interVert = [&](const glm::vec3& pos, const glm::vec3& nrm) { char key[128]; std::snprintf(key, sizeof(key), "%.6f|%.6f|%.6f|%.6f|%.6f|%.6f", pos.x, pos.y, pos.z, nrm.x, nrm.y, nrm.z); auto it = dedupe.find(key); if (it != dedupe.end()) return it->second; wowee::pipeline::WoweeModel::Vertex v; v.position = pos; v.normal = nrm; v.texCoord = {0, 0}; uint32_t idx = static_cast(wom.vertices.size()); wom.vertices.push_back(v); dedupe[key] = idx; return idx; }; std::string line; std::string solidName; // Per-facet state: parsed normal + accumulating vertex queue. glm::vec3 currentNormal{0, 0, 1}; std::vector facetVerts; int facetCount = 0; while (std::getline(in, line)) { while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back(); std::istringstream ss(line); std::string tok; ss >> tok; if (tok == "solid" && solidName.empty()) { ss >> solidName; } else if (tok == "facet") { std::string normalKw; ss >> normalKw; if (normalKw == "normal") { ss >> currentNormal.x >> currentNormal.y >> currentNormal.z; } facetVerts.clear(); } else if (tok == "vertex") { glm::vec3 v; ss >> v.x >> v.y >> v.z; facetVerts.push_back(v); } else if (tok == "endfacet") { if (facetVerts.size() == 3) { // Use the facet normal for all 3 verts since STL // doesn't carry per-vertex normals. Glue-points to // adjacent facets will get distinct verts (which is // correct for faceted-shading STL geometry). for (const auto& v : facetVerts) { wom.indices.push_back(interVert(v, currentNormal)); } facetCount++; } facetVerts.clear(); } // 'outer loop', 'endloop', 'endsolid' ignored — we infer // from the vertex count per facet. } if (wom.vertices.empty() || wom.indices.empty()) { std::fprintf(stderr, "import-stl: no geometry parsed from %s\n", stlPath.c_str()); return 1; } wom.name = solidName.empty() ? std::filesystem::path(stlPath).stem().string() : solidName; // Compute bounds — renderer culls by these so wrong values // make models 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-stl: failed to write %s.wom\n", womBase.c_str()); return 1; } std::printf("Imported %s -> %s.wom\n", stlPath.c_str(), womBase.c_str()); std::printf(" %d facets, %zu verts (deduped), bounds [%.2f, %.2f, %.2f] - [%.2f, %.2f, %.2f]\n", facetCount, wom.vertices.size(), wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); 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 groupVertOff(bld.groups.size(), 0); std::vector groupIdxOff(bld.groups.size(), 0); for (size_t g = 0; g < bld.groups.size(); ++g) { groupVertOff[g] = totalV; groupIdxOff[g] = totalI; totalV += static_cast(bld.groups[g].vertices.size()); totalI += static_cast(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 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(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(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(&magic), 4); out.write(reinterpret_cast(&version), 4); out.write(reinterpret_cast(&totalLen), 4); uint32_t jsonChunkType = 0x4E4F534A; out.write(reinterpret_cast(&jsonLen), 4); out.write(reinterpret_cast(&jsonChunkType), 4); out.write(jsonStr.data(), jsonLen); uint32_t binChunkType = 0x004E4942; out.write(reinterpret_cast(&binLen), 4); out.write(reinterpret_cast(&binChunkType), 4); out.write(reinterpret_cast(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 chunkMeshes; std::vector positions; // packed sequentially std::vector 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(positions.size()); cm.idxOff = static_cast(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(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(positions.size()); const uint32_t totalI = static_cast(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 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(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(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(&magic), 4); out.write(reinterpret_cast(&version), 4); out.write(reinterpret_cast(&totalLen), 4); uint32_t jsonChunkType = 0x4E4F534A; out.write(reinterpret_cast(&jsonLen), 4); out.write(reinterpret_cast(&jsonChunkType), 4); out.write(jsonStr.data(), jsonLen); uint32_t binChunkType = 0x004E4942; out.write(reinterpret_cast(&binLen), 4); out.write(reinterpret_cast(&binChunkType), 4); out.write(reinterpret_cast(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], "--bake-zone-glb") == 0 && i + 1 < argc) { // Bake every WHM tile in a zone into ONE .glb so the whole // multi-tile zone opens in three.js / model-viewer with one // file. Each tile becomes its own mesh+node so they can be // toggled independently. v1: terrain only — object/WOB // instances are a follow-up that needs careful per-mesh // bufferView slicing. 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, "bake-zone-glb: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "bake-zone-glb: failed to parse zone.json\n"); return 1; } if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".glb"; if (zm.tiles.empty()) { std::fprintf(stderr, "bake-zone-glb: zone has no tiles\n"); return 1; } constexpr float kTileSize = 533.33333f; constexpr float kChunkSize = kTileSize / 16.0f; constexpr float kVertSpacing = kChunkSize / 8.0f; // Per-tile mesh metadata so we can create one node per tile // and slice its index range from the shared bufferView. struct TileMesh { int tx, ty; uint32_t vertOff, vertCount; uint32_t idxOff, idxCount; }; std::vector tileMeshes; std::vector positions; std::vector indices; int loadedTiles = 0; glm::vec3 bMin{1e30f}, bMax{-1e30f}; 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)) { std::fprintf(stderr, "bake-zone-glb: tile (%d,%d) WHM/WOT missing — skipping\n", tx, ty); continue; } wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); TileMesh tm{tx, ty, 0, 0, 0, 0}; tm.vertOff = static_cast(positions.size()); tm.idxOff = static_cast(indices.size()); // Same per-chunk outer-grid layout as --export-whm-glb, // but accumulated across all tiles so they share one // global vertex+index pool. 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; float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; uint32_t chunkVertOff = static_cast(positions.size()); 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); } } bool isHoleChunk = (chunk.holes != 0); 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; } auto idx = [&](int r, int c) { return chunkVertOff + r * 9 + c; }; 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)); } } } } tm.vertCount = static_cast(positions.size()) - tm.vertOff; tm.idxCount = static_cast(indices.size()) - tm.idxOff; if (tm.vertCount > 0 && tm.idxCount > 0) { tileMeshes.push_back(tm); loadedTiles++; } } if (loadedTiles == 0) { std::fprintf(stderr, "bake-zone-glb: no tiles loaded\n"); return 1; } // Pack BIN chunk same way as --export-whm-glb (positions + // synthetic +Z normals + indices). Per-tile accessors slice // their index region via byteOffset. const uint32_t totalV = static_cast(positions.size()); const uint32_t totalI = static_cast(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 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. One mesh + one node per tile so they can // be toggled in viewers. nlohmann::json gj; gj["asset"] = {{"version", "2.0"}, {"generator", "wowee_editor --bake-zone-glb"}}; gj["scene"] = 0; gj["buffers"] = nlohmann::json::array({nlohmann::json{ {"byteLength", binSize} }}); // Three shared bufferViews — pos, nrm, idx — sliced into // per-tile primitives via byteOffset on the index accessor. 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; // Shared position+normal accessors (covering the full pool; // primitives reference them, the index accessor does the // per-tile slicing). 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-tile mesh + node + indices accessor. nlohmann::json meshes = nlohmann::json::array(); nlohmann::json nodes = nlohmann::json::array(); nlohmann::json sceneNodes = nlohmann::json::array(); for (const auto& tm : tileMeshes) { uint32_t accIdx = static_cast(accessors.size()); accessors.push_back({ {"bufferView", 2}, {"byteOffset", tm.idxOff * 4}, {"componentType", 5125}, {"count", tm.idxCount}, {"type", "SCALAR"} }); uint32_t meshIdx = static_cast(meshes.size()); meshes.push_back({ {"primitives", nlohmann::json::array({nlohmann::json{ {"attributes", {{"POSITION", 0}, {"NORMAL", 1}}}, {"indices", accIdx}, {"mode", 4} }})} }); std::string nodeName = "tile_" + std::to_string(tm.tx) + "_" + std::to_string(tm.ty); uint32_t nodeIdx = static_cast(nodes.size()); nodes.push_back({{"name", nodeName}, {"mesh", meshIdx}}); sceneNodes.push_back(nodeIdx); } gj["accessors"] = accessors; gj["meshes"] = meshes; gj["nodes"] = nodes; gj["scenes"] = nlohmann::json::array({nlohmann::json{ {"nodes", sceneNodes} }}); std::string jsonStr = gj.dump(); while (jsonStr.size() % 4 != 0) jsonStr += ' '; uint32_t jsonLen = static_cast(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(&magic), 4); out.write(reinterpret_cast(&version), 4); out.write(reinterpret_cast(&totalLen), 4); uint32_t jsonChunkType = 0x4E4F534A; out.write(reinterpret_cast(&jsonLen), 4); out.write(reinterpret_cast(&jsonChunkType), 4); out.write(jsonStr.data(), jsonLen); uint32_t binChunkType = 0x004E4942; out.write(reinterpret_cast(&binLen), 4); out.write(reinterpret_cast(&binChunkType), 4); out.write(reinterpret_cast(bin.data()), binLen); out.close(); std::printf("Baked %s -> %s\n", zoneDir.c_str(), outPath.c_str()); std::printf(" %d tile(s), %u verts, %u tris, %zu meshes, %u-byte BIN\n", loadedTiles, totalV, totalI / 3, meshes.size(), binLen); return 0; } else if (std::strcmp(argv[i], "--bake-zone-stl") == 0 && i + 1 < argc) { // STL counterpart to --bake-zone-glb. Designers can 3D-print a // miniature of an entire multi-tile zone in one slicer load — // useful for tabletop RPG props or a physical reference of a // playtest area. 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, "bake-zone-stl: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "bake-zone-stl: failed to parse zone.json\n"); return 1; } if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".stl"; if (zm.tiles.empty()) { std::fprintf(stderr, "bake-zone-stl: zone has no tiles\n"); return 1; } std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "bake-zone-stl: cannot write %s\n", outPath.c_str()); return 1; } constexpr float kTileSize = 533.33333f; constexpr float kChunkSize = kTileSize / 16.0f; constexpr float kVertSpacing = kChunkSize / 8.0f; // Solid name sanitized to alphanum + underscore. std::string solidName = zm.mapName; for (auto& c : solidName) { if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')) c = '_'; } if (solidName.empty()) solidName = "wowee_zone"; out << "solid " << solidName << "\n"; int loadedTiles = 0, holesSkipped = 0; uint64_t triCount = 0; // For each tile, generate the same 9x9 outer-grid mesh and // emit per-triangle facets directly (STL has no shared // vertex pool — each triangle stands alone). Compute face // normal from cross product (slicers use it for orientation). 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)) { std::fprintf(stderr, "bake-zone-stl: tile (%d, %d) WHM/WOT missing — skipping\n", tx, ty); continue; } wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); loadedTiles++; 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; float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; // Pre-compute the 9x9 vertex grid for this chunk. glm::vec3 V[9][9]; for (int row = 0; row < 9; ++row) { for (int col = 0; col < 9; ++col) { V[row][col] = { chunkBaseX - row * kVertSpacing, chunkBaseY - col * kVertSpacing, chunk.position[2] + chunk.heightMap.heights[row * 17 + col] }; } } bool isHoleChunk = (chunk.holes != 0); auto emitTri = [&](const glm::vec3& a, const glm::vec3& b, const glm::vec3& c) { glm::vec3 e1 = b - a, e2 = c - a; glm::vec3 n = glm::cross(e1, e2); float len = glm::length(n); if (len > 1e-12f) n /= len; else n = {0, 0, 1}; out << " facet normal " << n.x << " " << n.y << " " << n.z << "\n" << " outer loop\n" << " vertex " << a.x << " " << a.y << " " << a.z << "\n" << " vertex " << b.x << " " << b.y << " " << b.z << "\n" << " vertex " << c.x << " " << c.y << " " << c.z << "\n" << " endloop\n" << " endfacet\n"; triCount++; }; 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))) { holesSkipped++; continue; } } emitTri(V[row][col], V[row][col + 1], V[row + 1][col + 1]); emitTri(V[row][col], V[row + 1][col + 1], V[row + 1][col]); } } } } } out << "endsolid " << solidName << "\n"; out.close(); if (loadedTiles == 0) { std::fprintf(stderr, "bake-zone-stl: no tiles loaded\n"); std::filesystem::remove(outPath); return 1; } std::printf("Baked %s -> %s\n", zoneDir.c_str(), outPath.c_str()); std::printf(" %d tile(s), %llu facets, %d hole quads skipped\n", loadedTiles, static_cast(triCount), holesSkipped); return 0; } else if (std::strcmp(argv[i], "--bake-zone-obj") == 0 && i + 1 < argc) { // OBJ companion to --bake-zone-glb / --bake-zone-stl. Same // multi-tile WHM aggregation, but as Wavefront OBJ — opens // directly in Blender / MeshLab / 3DS Max for hand-editing. // Each tile becomes its own 'g' block so designers can hide // tiles independently. 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, "bake-zone-obj: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "bake-zone-obj: parse failed\n"); return 1; } if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".obj"; if (zm.tiles.empty()) { std::fprintf(stderr, "bake-zone-obj: zone has no tiles\n"); return 1; } std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "bake-zone-obj: cannot write %s\n", outPath.c_str()); return 1; } constexpr float kTileSize = 533.33333f; constexpr float kChunkSize = kTileSize / 16.0f; constexpr float kVertSpacing = kChunkSize / 8.0f; out << "# Wavefront OBJ generated by wowee_editor --bake-zone-obj\n"; out << "# Zone: " << zm.mapName << " (" << zm.tiles.size() << " tiles)\n"; out << "o " << zm.mapName << "\n"; // OBJ uses a single global vertex pool with per-tile g-blocks // and per-tile face index offsetting. We accumulate per-tile // vertex blocks first (so face indices know their offsets), // then per-tile face blocks at the end. // Layout: emit ALL verts first (organized by tile, in order), // then emit ALL face blocks. OBJ requires verts before faces // that reference them. int loadedTiles = 0; int totalVerts = 0; // Per-tile bookkeeping: vertex base index (1-based for OBJ) // and which faces reference it. struct TileMeta { int tx, ty; uint32_t vertBase; // 1-based OBJ index of first vert uint32_t vertCount; std::vector faceI0, faceI1, faceI2; // local indices }; std::vector tiles; 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)) { std::fprintf(stderr, "bake-zone-obj: tile (%d, %d) WHM/WOT missing — skipping\n", tx, ty); continue; } wowee::pipeline::ADTTerrain terrain; wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); TileMeta tm{tx, ty, static_cast(totalVerts + 1), 0, {}, {}, {}}; // Walk chunks; emit verts to file as we go (so we don't // hold a giant vector in memory). Track local indices for // face emission afterwards. uint32_t tileLocalIdx = 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; float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; uint32_t chunkBaseLocal = tileLocalIdx; 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]; out << "v " << x << " " << y << " " << z << "\n"; tileLocalIdx++; } } bool isHoleChunk = (chunk.holes != 0); 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; } auto idx = [&](int r, int c) { return chunkBaseLocal + r * 9 + c; }; tm.faceI0.push_back(idx(row, col)); tm.faceI1.push_back(idx(row, col + 1)); tm.faceI2.push_back(idx(row + 1, col + 1)); tm.faceI0.push_back(idx(row, col)); tm.faceI1.push_back(idx(row + 1, col + 1)); tm.faceI2.push_back(idx(row + 1, col)); } } } } tm.vertCount = tileLocalIdx; totalVerts += tm.vertCount; if (tm.vertCount > 0) { tiles.push_back(std::move(tm)); loadedTiles++; } } // Now emit per-tile face groups (after all verts are written). uint64_t totalFaces = 0; for (const auto& tm : tiles) { out << "g tile_" << tm.tx << "_" << tm.ty << "\n"; for (size_t k = 0; k < tm.faceI0.size(); ++k) { uint32_t a = tm.faceI0[k] + tm.vertBase; uint32_t b = tm.faceI1[k] + tm.vertBase; uint32_t c = tm.faceI2[k] + tm.vertBase; out << "f " << a << " " << b << " " << c << "\n"; totalFaces++; } } out.close(); if (loadedTiles == 0) { std::fprintf(stderr, "bake-zone-obj: no tiles loaded\n"); std::filesystem::remove(outPath); return 1; } std::printf("Baked %s -> %s\n", zoneDir.c_str(), outPath.c_str()); std::printf(" %d tile(s), %d verts, %llu tris\n", loadedTiles, totalVerts, static_cast(totalFaces)); return 0; } else if (std::strcmp(argv[i], "--bake-project-obj") == 0 && i + 1 < argc) { // Project-level OBJ bake: every zone in gets // emitted into one giant OBJ with one 'g zone_NAME' block // per zone. Useful for previewing an entire project's terrain // in MeshLab/Blender at once, or for printing the whole map. 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, "bake-project-obj: %s is not a directory\n", projectDir.c_str()); return 1; } if (outPath.empty()) outPath = projectDir + "/project.obj"; std::vector zoneDirs; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zoneDirs.push_back(entry.path().string()); } std::sort(zoneDirs.begin(), zoneDirs.end()); if (zoneDirs.empty()) { std::fprintf(stderr, "bake-project-obj: no zones found in %s\n", projectDir.c_str()); return 1; } std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "bake-project-obj: cannot write %s\n", outPath.c_str()); return 1; } constexpr float kTileSize = 533.33333f; constexpr float kChunkSize = kTileSize / 16.0f; constexpr float kVertSpacing = kChunkSize / 8.0f; out << "# Wavefront OBJ generated by wowee_editor --bake-project-obj\n"; out << "# Project: " << projectDir << " (" << zoneDirs.size() << " zones)\n"; // Single global vertex pool. Per-zone we accumulate verts then // emit faces; same shape as --bake-zone-obj. int totalZones = 0, totalTiles = 0; int totalVerts = 0; uint64_t totalFaces = 0; struct Pending { std::string zoneName; uint32_t vertBase; // 1-based OBJ index std::vector faceI0, faceI1, faceI2; }; std::vector queues; for (const auto& zoneDir : zoneDirs) { wowee::editor::ZoneManifest zm; if (!zm.load(zoneDir + "/zone.json")) continue; Pending pq; pq.zoneName = zm.mapName; pq.vertBase = static_cast(totalVerts + 1); int zoneTiles = 0; uint32_t zoneLocalIdx = 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); zoneTiles++; 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; float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; uint32_t chunkBaseLocal = zoneLocalIdx; 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]; out << "v " << x << " " << y << " " << z << "\n"; zoneLocalIdx++; } } bool isHoleChunk = (chunk.holes != 0); 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; } auto idx = [&](int r, int c) { return chunkBaseLocal + r * 9 + c; }; pq.faceI0.push_back(idx(row, col)); pq.faceI1.push_back(idx(row, col + 1)); pq.faceI2.push_back(idx(row + 1, col + 1)); pq.faceI0.push_back(idx(row, col)); pq.faceI1.push_back(idx(row + 1, col + 1)); pq.faceI2.push_back(idx(row + 1, col)); } } } } } if (zoneLocalIdx == 0) continue; totalVerts += zoneLocalIdx; totalTiles += zoneTiles; totalZones++; queues.push_back(std::move(pq)); } // After all verts written, emit faces grouped by zone. for (const auto& pq : queues) { out << "g zone_" << pq.zoneName << "\n"; for (size_t k = 0; k < pq.faceI0.size(); ++k) { out << "f " << (pq.faceI0[k] + pq.vertBase) << " " << (pq.faceI1[k] + pq.vertBase) << " " << (pq.faceI2[k] + pq.vertBase) << "\n"; totalFaces++; } } out.close(); std::printf("Baked %s -> %s\n", projectDir.c_str(), outPath.c_str()); std::printf(" %d zone(s), %d tiles, %d verts, %llu tris\n", totalZones, totalTiles, totalVerts, static_cast(totalFaces)); return 0; } else if ((std::strcmp(argv[i], "--bake-project-stl") == 0 || std::strcmp(argv[i], "--bake-project-glb") == 0) && i + 1 < argc) { // STL + glTF project bakes share the per-zone walking logic // with --bake-project-obj. Only the output emission differs: // STL → per-triangle 'facet normal'+'outer loop'+vertex×3 // GLB → packed BIN chunk + JSON describing per-zone meshes // Coords match across all three exporters so an .obj/.stl/ // .glb of the same source line up spatially when overlaid. bool isStl = (std::strcmp(argv[i], "--bake-project-stl") == 0); const char* cmdName = isStl ? "bake-project-stl" : "bake-project-glb"; 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, "%s: %s is not a directory\n", cmdName, projectDir.c_str()); return 1; } if (outPath.empty()) { outPath = projectDir + "/project." + (isStl ? "stl" : "glb"); } std::vector zoneDirs; for (const auto& entry : fs::directory_iterator(projectDir)) { if (!entry.is_directory()) continue; if (!fs::exists(entry.path() / "zone.json")) continue; zoneDirs.push_back(entry.path().string()); } std::sort(zoneDirs.begin(), zoneDirs.end()); if (zoneDirs.empty()) { std::fprintf(stderr, "%s: no zones found\n", cmdName); return 1; } constexpr float kTileSize = 533.33333f; constexpr float kChunkSize = kTileSize / 16.0f; constexpr float kVertSpacing = kChunkSize / 8.0f; // Common pass: collect per-zone vertex+index pools. STL emits // per-triangle facets directly; GLB packs everything into BIN. struct ZonePool { std::string name; std::vector verts; std::vector indices; }; std::vector zones; int totalZones = 0, totalTiles = 0; glm::vec3 bMin{1e30f}, bMax{-1e30f}; for (const auto& zoneDir : zoneDirs) { wowee::editor::ZoneManifest zm; if (!zm.load(zoneDir + "/zone.json")) continue; ZonePool zp; zp.name = zm.mapName; int zoneTiles = 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); zoneTiles++; 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; float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; uint32_t chunkBase = static_cast(zp.verts.size()); 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] }; zp.verts.push_back(p); bMin = glm::min(bMin, p); bMax = glm::max(bMax, p); } } bool isHoleChunk = (chunk.holes != 0); 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; } auto idx = [&](int r, int c) { return chunkBase + r * 9 + c; }; zp.indices.push_back(idx(row, col)); zp.indices.push_back(idx(row, col + 1)); zp.indices.push_back(idx(row + 1, col + 1)); zp.indices.push_back(idx(row, col)); zp.indices.push_back(idx(row + 1, col + 1)); zp.indices.push_back(idx(row + 1, col)); } } } } } if (zp.verts.empty()) continue; totalTiles += zoneTiles; totalZones++; zones.push_back(std::move(zp)); } if (zones.empty()) { std::fprintf(stderr, "%s: no loadable terrain found\n", cmdName); return 1; } if (isStl) { std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "%s: cannot write %s\n", cmdName, outPath.c_str()); return 1; } out << "solid wowee_project\n"; uint64_t triCount = 0; for (const auto& zp : zones) { for (size_t k = 0; k + 2 < zp.indices.size(); k += 3) { const auto& v0 = zp.verts[zp.indices[k]]; const auto& v1 = zp.verts[zp.indices[k + 1]]; const auto& v2 = zp.verts[zp.indices[k + 2]]; glm::vec3 n = glm::cross(v1 - v0, v2 - v0); float len = glm::length(n); if (len > 1e-12f) n /= len; else n = {0, 0, 1}; out << " facet normal " << n.x << " " << n.y << " " << n.z << "\n" << " outer loop\n" << " vertex " << v0.x << " " << v0.y << " " << v0.z << "\n" << " vertex " << v1.x << " " << v1.y << " " << v1.z << "\n" << " vertex " << v2.x << " " << v2.y << " " << v2.z << "\n" << " endloop\n" << " endfacet\n"; triCount++; } } out << "endsolid wowee_project\n"; out.close(); std::printf("Baked %s -> %s\n", projectDir.c_str(), outPath.c_str()); std::printf(" %d zone(s), %d tiles, %llu facets\n", totalZones, totalTiles, static_cast(triCount)); return 0; } // GLB path: pack positions+normals+indices into one BIN chunk, // one mesh+node per zone with sliced index accessor. uint32_t totalV = 0, totalI = 0; for (const auto& zp : zones) { totalV += static_cast(zp.verts.size()); totalI += static_cast(zp.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 bin(binSize); uint32_t vCursor = 0, iCursor = 0; // Per-zone bookkeeping for accessor slicing. struct ZoneSlice { std::string name; uint32_t vOff, vCnt, iOff, iCnt; }; std::vector slices; for (const auto& zp : zones) { ZoneSlice s{zp.name, vCursor, static_cast(zp.verts.size()), iCursor, static_cast(zp.indices.size())}; for (const auto& v : zp.verts) { std::memcpy(&bin[posOff + vCursor * 12 + 0], &v.x, 4); std::memcpy(&bin[posOff + vCursor * 12 + 4], &v.y, 4); std::memcpy(&bin[posOff + vCursor * 12 + 8], &v.z, 4); float nx = 0, ny = 0, nz = 1; std::memcpy(&bin[nrmOff + vCursor * 12 + 0], &nx, 4); std::memcpy(&bin[nrmOff + vCursor * 12 + 4], &ny, 4); std::memcpy(&bin[nrmOff + vCursor * 12 + 8], &nz, 4); vCursor++; } // Offset zone indices by the global vertBase so they // resolve into the merged pool. for (uint32_t idx : zp.indices) { uint32_t global = idx + s.vOff; std::memcpy(&bin[idxOff + iCursor * 4], &global, 4); iCursor++; } slices.push_back(s); } nlohmann::json gj; gj["asset"] = {{"version", "2.0"}, {"generator", "wowee_editor --bake-project-glb"}}; gj["scene"] = 0; gj["buffers"] = nlohmann::json::array({{{"byteLength", binSize}}}); nlohmann::json bvs = nlohmann::json::array(); bvs.push_back({{"buffer", 0}, {"byteOffset", posOff}, {"byteLength", totalV * 12}, {"target", 34962}}); bvs.push_back({{"buffer", 0}, {"byteOffset", nrmOff}, {"byteLength", totalV * 12}, {"target", 34962}}); bvs.push_back({{"buffer", 0}, {"byteOffset", idxOff}, {"byteLength", totalI * 4}, {"target", 34963}}); gj["bufferViews"] = bvs; 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"}}); nlohmann::json meshes = nlohmann::json::array(); nlohmann::json nodes = nlohmann::json::array(); nlohmann::json sceneNodes = nlohmann::json::array(); for (const auto& s : slices) { uint32_t accIdx = static_cast(accessors.size()); accessors.push_back({{"bufferView", 2}, {"byteOffset", s.iOff * 4}, {"componentType", 5125}, {"count", s.iCnt}, {"type", "SCALAR"}}); uint32_t meshIdx = static_cast(meshes.size()); meshes.push_back({{"primitives", nlohmann::json::array({nlohmann::json{ {"attributes", {{"POSITION", 0}, {"NORMAL", 1}}}, {"indices", accIdx}, {"mode", 4}}})}}); uint32_t nodeIdx = static_cast(nodes.size()); nodes.push_back({{"name", "zone_" + s.name}, {"mesh", meshIdx}}); sceneNodes.push_back(nodeIdx); } gj["accessors"] = accessors; gj["meshes"] = meshes; gj["nodes"] = nodes; gj["scenes"] = nlohmann::json::array({{{"nodes", sceneNodes}}}); std::string jsonStr = gj.dump(); while (jsonStr.size() % 4 != 0) jsonStr += ' '; uint32_t jsonLen = static_cast(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, "%s: cannot write %s\n", cmdName, outPath.c_str()); return 1; } uint32_t magic = 0x46546C67, version = 2; out.write(reinterpret_cast(&magic), 4); out.write(reinterpret_cast(&version), 4); out.write(reinterpret_cast(&totalLen), 4); uint32_t jt = 0x4E4F534A; out.write(reinterpret_cast(&jsonLen), 4); out.write(reinterpret_cast(&jt), 4); out.write(jsonStr.data(), jsonLen); uint32_t bt = 0x004E4942; out.write(reinterpret_cast(&binLen), 4); out.write(reinterpret_cast(&bt), 4); out.write(reinterpret_cast(bin.data()), binLen); out.close(); std::printf("Baked %s -> %s\n", projectDir.c_str(), outPath.c_str()); std::printf(" %d zone(s), %d tiles, %u verts, %u tris, %u-byte BIN\n", totalZones, totalTiles, totalV, totalI / 3, 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(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 positions; std::vector texcoords; std::vector 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 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(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(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(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(it->second); wowee::pipeline::WoweeBuilding::Vertex vert; vert.position = positions[vi]; if (ti >= 0 && ti < static_cast(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(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(grp.vertices.size()); grp.vertices.push_back(vert); groupDedupe[key] = newIdx; return static_cast(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 corners; std::string c; while (ss >> c) corners.push_back(c); if (corners.size() < 3) { badFaces++; continue; } std::vector 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(resolved[0])); grp.indices.push_back(static_cast(resolved[k])); grp.indices.push_back(static_cast(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> 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(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 positions; std::vector texcoords; std::vector normals; wowee::pipeline::WoweeModel wom; wom.version = 1; std::unordered_map 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(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(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(it->second); wowee::pipeline::WoweeModel::Vertex vert; vert.position = positions[vi]; if (ti >= 0 && ti < static_cast(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(normals.size())) { vert.normal = normals[ni]; } else { vert.normal = {0, 0, 1}; } uint32_t newIdx = static_cast(wom.vertices.size()); wom.vertices.push_back(vert); dedupe[key] = newIdx; return static_cast(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 corners; std::string c; while (ss >> c) corners.push_back(c); if (corners.size() < 3) { badFaces++; continue; } std::vector 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(resolved[0])); wom.indices.push_back(static_cast(resolved[k])); wom.indices.push_back(static_cast(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: [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], "--info-zone-summary") == 0 && i + 1 < argc) { // One-glance health digest for a zone. Combines the per- // category counts/bytes from the inventory commands with // a quick pass/fail signal from validate-zone-pack. Lets // a user see at a glance whether a zone is bootstrapped, // empty, or partially populated. 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, "info-zone-summary: %s has no zone.json\n", zoneDir.c_str()); return 1; } // Load zone.json for the friendly map name. std::string mapName = "?"; try { std::ifstream zf(zoneDir + "/zone.json"); if (zf) { nlohmann::json zj; zf >> zj; if (zj.contains("mapName") && zj["mapName"].is_string()) { mapName = zj["mapName"].get<std::string>(); } } } catch (...) { /* tolerated — leave as ? */ } // Per-category quick scan: count + bytes only. auto scan = [&](const std::string& sub, const std::string& ext) -> std::pair<int, uint64_t> { int n = 0; uint64_t b = 0; fs::path p = fs::path(zoneDir) / sub; if (!fs::exists(p)) return {0, 0}; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(p, ec)) { if (!e.is_regular_file()) continue; if (e.path().extension() != ext) continue; n++; b += e.file_size(); } return {n, b}; }; auto [texN, texB] = scan("textures", ".png"); auto [mshN, mshB] = scan("meshes", ".wom"); auto [audN, audB] = scan("audio", ".wav"); // Pack health: bootstrap pass if we have all three // categories with at least 1 file each. "Partial" if // some but not all. "Empty" if none. std::string status; if (texN > 0 && mshN > 0 && audN > 0) status = "BOOTSTRAPPED"; else if (texN + mshN + audN > 0) status = "PARTIAL"; else status = "EMPTY"; uint64_t totalBytes = texB + mshB + audB; int totalAssets = texN + mshN + audN; if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["mapName"] = mapName; j["status"] = status; j["totalAssets"] = totalAssets; j["totalBytes"] = totalBytes; j["textures"] = {{"count", texN}, {"bytes", texB}}; j["meshes"] = {{"count", mshN}, {"bytes", mshB}}; j["audio"] = {{"count", audN}, {"bytes", audB}}; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone: %s (%s)\n", mapName.c_str(), zoneDir.c_str()); std::printf(" status : %s\n", status.c_str()); std::printf(" textures : %d (%llu bytes)\n", texN, static_cast<unsigned long long>(texB)); std::printf(" meshes : %d (%llu bytes)\n", mshN, static_cast<unsigned long long>(mshB)); std::printf(" audio : %d (%llu bytes)\n", audN, static_cast<unsigned long long>(audB)); std::printf(" TOTAL : %d assets, %llu bytes\n", totalAssets, static_cast<unsigned long long>(totalBytes)); return 0; } else if (std::strcmp(argv[i], "--info-project-summary") == 0 && i + 1 < argc) { // Project-wide companion to --info-zone-summary. Walks // every zone in <projectDir> and reports a per-zone // status row + per-category counts, plus a project // total at the bottom. Status: BOOTSTRAPPED (all 3 // categories non-empty), PARTIAL (some), EMPTY (none). 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-summary: %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-category scan logic as info-zone-summary — // duplicated rather than abstracted because the two // commands are read in different contexts and a shared // helper would entrench an internal API for one caller. auto scan = [](const std::string& base, const std::string& sub, const std::string& ext) -> std::pair<int, uint64_t> { int n = 0; uint64_t b = 0; fs::path p = fs::path(base) / sub; if (!fs::exists(p)) return {0, 0}; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(p, ec)) { if (!e.is_regular_file()) continue; if (e.path().extension() != ext) continue; n++; b += e.file_size(); } return {n, b}; }; struct ZRow { std::string name; std::string status; int texN = 0, mshN = 0, audN = 0; uint64_t bytes = 0; }; std::vector<ZRow> rows; int bootstrapped = 0, partial = 0, empty = 0; uint64_t totalBytes = 0; int totalAssets = 0; for (const auto& z : zones) { ZRow r; r.name = fs::path(z).filename().string(); auto [tn, tb] = scan(z, "textures", ".png"); auto [mn, mb] = scan(z, "meshes", ".wom"); auto [an, ab] = scan(z, "audio", ".wav"); r.texN = tn; r.mshN = mn; r.audN = an; r.bytes = tb + mb + ab; if (tn > 0 && mn > 0 && an > 0) { r.status = "BOOTSTRAPPED"; ++bootstrapped; } else if (tn + mn + an > 0) { r.status = "PARTIAL"; ++partial; } else { r.status = "EMPTY"; ++empty; } totalBytes += r.bytes; totalAssets += tn + mn + an; rows.push_back(std::move(r)); } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = rows.size(); j["bootstrapped"] = bootstrapped; j["partial"] = partial; j["empty"] = empty; j["totalAssets"] = totalAssets; j["totalBytes"] = totalBytes; nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({ {"zone", r.name}, {"status", r.status}, {"textures", r.texN}, {"meshes", r.mshN}, {"audio", r.audN}, {"bytes", r.bytes}, }); } j["zones"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", rows.size()); std::printf(" bootstrapped : %d\n", bootstrapped); std::printf(" partial : %d\n", partial); std::printf(" empty : %d\n", empty); std::printf(" total assets : %d\n", totalAssets); std::printf(" total bytes : %llu\n", static_cast<unsigned long long>(totalBytes)); if (rows.empty()) { std::printf(" *no zones found*\n"); return 0; } std::printf("\n %-14s %4s %4s %4s %10s %s\n", "status", "tex", "msh", "aud", "bytes", "zone"); for (const auto& r : rows) { std::printf(" %-14s %4d %4d %4d %10llu %s\n", r.status.c_str(), r.texN, r.mshN, r.audN, static_cast<unsigned long long>(r.bytes), r.name.c_str()); } 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], "--snap-zone-to-ground") == 0 && i + 1 < argc) { // Walk every creature + object in a zone and snap their Z // to the actual terrain height. Useful after terrain edits // or after --random-populate-zone if the spawn baseZ // doesn't match the carved terrain. // // Height lookup walks the loaded WHM tiles and finds the // chunk containing each spawn's (x, y), then uses the // chunk's average heightmap height + base. std::string zoneDir = argv[++i]; namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "snap-zone-to-ground: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "snap-zone-to-ground: failed to parse %s\n", manifestPath.c_str()); return 1; } // Load all tiles into a flat map keyed by (tx, ty). struct LoadedTile { wowee::pipeline::ADTTerrain terrain; int tx, ty; }; std::vector<LoadedTile> tiles; 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; LoadedTile lt; lt.tx = tx; lt.ty = ty; if (wowee::pipeline::WoweeTerrainLoader::load(base, lt.terrain)) { tiles.push_back(std::move(lt)); } } if (tiles.empty()) { std::fprintf(stderr, "snap-zone-to-ground: no .whm tiles loaded\n"); return 1; } // Compute terrain height at world (x, y) by finding the // chunk that contains it and averaging its heightmap. Each // chunk is 33.33y across; chunk position[1]=wowX origin, // [0]=wowY origin. constexpr float kChunkSize = 33.33333f; auto sampleHeight = [&](float wx, float wy) -> float { for (const auto& lt : tiles) { for (const auto& chunk : lt.terrain.chunks) { if (!chunk.heightMap.isLoaded()) continue; float cx0 = chunk.position[1]; float cy0 = chunk.position[0]; if (wx < cx0 || wx >= cx0 + kChunkSize) continue; if (wy < cy0 || wy >= cy0 + kChunkSize) continue; // Use average heightmap height to dodge the // need for full bilinear sampling. Good enough // for spawn placement; finer interpolation is // a future optimization. float sum = 0; int n = 0; for (float h : chunk.heightMap.heights) { if (std::isfinite(h)) { sum += h; n++; } } if (n == 0) return chunk.position[2]; return chunk.position[2] + sum / n; } } return zm.baseHeight; // outside any loaded chunk }; int snappedC = 0, snappedO = 0; // Creatures. wowee::editor::NpcSpawner spawner; std::string cpath = zoneDir + "/creatures.json"; if (fs::exists(cpath) && spawner.loadFromFile(cpath)) { auto& spawns = spawner.getSpawns(); for (auto& s : spawns) { s.position.z = sampleHeight(s.position.x, s.position.y); snappedC++; } if (snappedC > 0) spawner.saveToFile(cpath); } // Objects. wowee::editor::ObjectPlacer placer; std::string opath = zoneDir + "/objects.json"; if (fs::exists(opath) && placer.loadFromFile(opath)) { auto& objs = placer.getObjects(); for (auto& o : objs) { o.position.z = sampleHeight(o.position.x, o.position.y); snappedO++; } if (snappedO > 0) placer.saveToFile(opath); } std::printf("snap-zone-to-ground: %s\n", zoneDir.c_str()); std::printf(" tiles loaded : %zu\n", tiles.size()); std::printf(" creatures : %d snapped\n", snappedC); std::printf(" objects : %d snapped\n", snappedO); return 0; } else if (std::strcmp(argv[i], "--audit-zone-spawns") == 0 && i + 1 < argc) { // Non-destructive companion to --snap-zone-to-ground. // Loads the zone's terrain, walks every creature + object, // and flags any whose Z is more than <threshold> yards // off from the sampled terrain height. Useful for // surveying placement issues before deciding whether to // run --snap-zone-to-ground (which would silently rewrite // every spawn). std::string zoneDir = argv[++i]; float threshold = 5.0f; if (i + 2 < argc && std::strcmp(argv[i + 1], "--threshold") == 0) { try { threshold = std::stof(argv[i + 2]); i += 2; } catch (...) {} } namespace fs = std::filesystem; std::string manifestPath = zoneDir + "/zone.json"; if (!fs::exists(manifestPath)) { std::fprintf(stderr, "audit-zone-spawns: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::ZoneManifest zm; if (!zm.load(manifestPath)) { std::fprintf(stderr, "audit-zone-spawns: failed to parse %s\n", manifestPath.c_str()); return 1; } // Same chunk-average sampler as --snap-zone-to-ground. // Returning baseHeight when no chunk hits = "no terrain // data here", so flag those too via the threshold check. struct LoadedTile { wowee::pipeline::ADTTerrain terrain; }; std::vector<LoadedTile> tiles; 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; LoadedTile lt; if (wowee::pipeline::WoweeTerrainLoader::load(base, lt.terrain)) { tiles.push_back(std::move(lt)); } } constexpr float kChunkSize = 33.33333f; auto sampleHeight = [&](float wx, float wy) -> float { for (const auto& lt : tiles) { for (const auto& chunk : lt.terrain.chunks) { if (!chunk.heightMap.isLoaded()) continue; float cx0 = chunk.position[1]; float cy0 = chunk.position[0]; if (wx < cx0 || wx >= cx0 + kChunkSize) continue; if (wy < cy0 || wy >= cy0 + kChunkSize) continue; float sum = 0; int n = 0; for (float h : chunk.heightMap.heights) { if (std::isfinite(h)) { sum += h; n++; } } if (n == 0) return chunk.position[2]; return chunk.position[2] + sum / n; } } return zm.baseHeight; }; struct Issue { std::string kind; std::string name; float spawnZ, terrainZ; }; std::vector<Issue> issues; wowee::editor::NpcSpawner spawner; if (fs::exists(zoneDir + "/creatures.json") && spawner.loadFromFile(zoneDir + "/creatures.json")) { for (const auto& s : spawner.getSpawns()) { float th = sampleHeight(s.position.x, s.position.y); if (std::fabs(s.position.z - th) > threshold) { issues.push_back({"creature", s.name, s.position.z, th}); } } } wowee::editor::ObjectPlacer placer; if (fs::exists(zoneDir + "/objects.json") && placer.loadFromFile(zoneDir + "/objects.json")) { for (const auto& o : placer.getObjects()) { float th = sampleHeight(o.position.x, o.position.y); if (std::fabs(o.position.z - th) > threshold) { issues.push_back({"object", o.path, o.position.z, th}); } } } std::printf("audit-zone-spawns: %s\n", zoneDir.c_str()); std::printf(" threshold : %.1f yards\n", threshold); std::printf(" creatures : %zu\n", spawner.spawnCount()); std::printf(" objects : %zu\n", placer.getObjects().size()); std::printf(" issues : %zu\n", issues.size()); if (issues.empty()) { std::printf("\n PASSED — every spawn is within %.1f y of the terrain\n", threshold); return 0; } std::printf("\n Flagged spawns (delta = spawnZ - terrainZ):\n"); std::printf(" kind delta spawnZ terrainZ name\n"); for (const auto& iss : issues) { float delta = iss.spawnZ - iss.terrainZ; std::printf(" %-8s %+6.1f %7.1f %7.1f %s\n", iss.kind.c_str(), delta, iss.spawnZ, iss.terrainZ, iss.name.substr(0, 40).c_str()); } std::printf("\n Run --snap-zone-to-ground to fix in bulk.\n"); return 1; } else if (std::strcmp(argv[i], "--list-zone-spawns") == 0 && i + 1 < argc) { // Combined creature + object listing. Useful for a quick // "what's in this zone" survey without running both // --info-creatures and --info-objects separately. 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-spawns: %s has no zone.json\n", zoneDir.c_str()); return 1; } wowee::editor::NpcSpawner spawner; wowee::editor::ObjectPlacer placer; spawner.loadFromFile(zoneDir + "/creatures.json"); placer.loadFromFile(zoneDir + "/objects.json"); const auto& spawns = spawner.getSpawns(); const auto& objs = placer.getObjects(); if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["creatureCount"] = spawns.size(); j["objectCount"] = objs.size(); nlohmann::json carr = nlohmann::json::array(); for (const auto& s : spawns) { carr.push_back({{"name", s.name}, {"level", s.level}, {"x", s.position.x}, {"y", s.position.y}, {"z", s.position.z}, {"hostile", s.hostile}}); } j["creatures"] = carr; nlohmann::json oarr = nlohmann::json::array(); for (const auto& o : objs) { oarr.push_back({{"path", o.path}, {"type", o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"}, {"x", o.position.x}, {"y", o.position.y}, {"z", o.position.z}, {"scale", o.scale}}); } j["objects"] = oarr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Zone spawns: %s\n", zoneDir.c_str()); std::printf(" creatures : %zu\n", spawns.size()); std::printf(" objects : %zu\n", objs.size()); if (!spawns.empty()) { std::printf("\n Creatures:\n"); std::printf(" idx lvl hostile x y z name\n"); for (size_t k = 0; k < spawns.size(); ++k) { const auto& s = spawns[k]; std::printf(" %3zu %3u %-7s %8.1f %8.1f %8.1f %s\n", k, s.level, s.hostile ? "yes" : "no", s.position.x, s.position.y, s.position.z, s.name.c_str()); } } if (!objs.empty()) { std::printf("\n Objects:\n"); std::printf(" idx type scale x y z path\n"); for (size_t k = 0; k < objs.size(); ++k) { const auto& o = objs[k]; std::printf(" %3zu %-4s %5.2f %8.1f %8.1f %8.1f %s\n", k, o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo", o.scale, o.position.x, o.position.y, o.position.z, o.path.c_str()); } } return 0; } else if (std::strcmp(argv[i], "--diff-zone-spawns") == 0 && i + 2 < argc) { // Compare two zones' creatures + objects. Matches by // (kind, name) — paired entries with mismatched positions // are reported as "moved" with the delta. Entries that // exist in only one zone are added/removed. // // Useful for "what did the new branch change vs main" // before merging, or for confirming a copy-zone-items // produced what was expected. std::string aDir = argv[++i]; std::string bDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(aDir + "/zone.json")) { std::fprintf(stderr, "diff-zone-spawns: %s has no zone.json\n", aDir.c_str()); return 1; } if (!fs::exists(bDir + "/zone.json")) { std::fprintf(stderr, "diff-zone-spawns: %s has no zone.json\n", bDir.c_str()); return 1; } // Multiset key: kind/name. Position comes along so we can // report "moved" deltas when a name appears in both with // different XYZ. struct Entry { std::string kind, name; glm::vec3 pos; }; auto load = [&](const std::string& dir) { std::vector<Entry> out; wowee::editor::NpcSpawner spawner; if (spawner.loadFromFile(dir + "/creatures.json")) { for (const auto& s : spawner.getSpawns()) { out.push_back({"creature", s.name, s.position}); } } wowee::editor::ObjectPlacer placer; if (placer.loadFromFile(dir + "/objects.json")) { for (const auto& o : placer.getObjects()) { out.push_back({"object", o.path, o.position}); } } return out; }; auto av = load(aDir); auto bv = load(bDir); // Sort each side for stable key matching. auto cmp = [](const Entry& x, const Entry& y) { if (x.kind != y.kind) return x.kind < y.kind; return x.name < y.name; }; std::sort(av.begin(), av.end(), cmp); std::sort(bv.begin(), bv.end(), cmp); int added = 0, removed = 0, moved = 0, same = 0; std::vector<std::string> diffs; // Two-pointer walk: equal keys → check position; A-only → // removed; B-only → added. size_t i_a = 0, i_b = 0; while (i_a < av.size() || i_b < bv.size()) { if (i_a < av.size() && i_b < bv.size() && av[i_a].kind == bv[i_b].kind && av[i_a].name == bv[i_b].name) { glm::vec3 d = bv[i_b].pos - av[i_a].pos; float dlen = glm::length(d); if (dlen > 0.5f) { char buf[256]; std::snprintf(buf, sizeof(buf), " moved %-9s %-30s by (%+.1f, %+.1f, %+.1f)", av[i_a].kind.c_str(), av[i_a].name.substr(0, 30).c_str(), d.x, d.y, d.z); diffs.push_back(buf); moved++; } else { same++; } i_a++; i_b++; } else if (i_b == bv.size() || (i_a < av.size() && cmp(av[i_a], bv[i_b]))) { char buf[256]; std::snprintf(buf, sizeof(buf), " removed %-9s %s", av[i_a].kind.c_str(), av[i_a].name.substr(0, 60).c_str()); diffs.push_back(buf); removed++; i_a++; } else { char buf[256]; std::snprintf(buf, sizeof(buf), " added %-9s %s", bv[i_b].kind.c_str(), bv[i_b].name.substr(0, 60).c_str()); diffs.push_back(buf); added++; i_b++; } } std::printf("diff-zone-spawns: %s -> %s\n", aDir.c_str(), bDir.c_str()); std::printf(" added : %d\n", added); std::printf(" removed : %d\n", removed); std::printf(" moved : %d (>0.5y)\n", moved); std::printf(" same : %d\n", same); if (!diffs.empty()) { std::printf("\n"); for (const auto& d : diffs) std::printf("%s\n", d.c_str()); } return (added + removed + moved) == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--info-spawn") == 0 && i + 3 < argc) { // Detailed view of one creature or object by index. The // list-zone-spawns table only shows headline fields; this // dumps every field including AI behavior, faction, // patrol path waypoints, etc. std::string zoneDir = argv[++i]; std::string kind = argv[++i]; int idx = -1; try { idx = std::stoi(argv[++i]); } catch (...) { std::fprintf(stderr, "info-spawn: <index> must be an integer\n"); return 1; } bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; std::transform(kind.begin(), kind.end(), kind.begin(), [](unsigned char c) { return std::tolower(c); }); if (kind == "creature") { wowee::editor::NpcSpawner spawner; if (!spawner.loadFromFile(zoneDir + "/creatures.json")) { std::fprintf(stderr, "info-spawn: %s has no creatures.json\n", zoneDir.c_str()); return 1; } const auto& spawns = spawner.getSpawns(); if (idx < 0 || static_cast<size_t>(idx) >= spawns.size()) { std::fprintf(stderr, "info-spawn: index %d out of range (have %zu)\n", idx, spawns.size()); return 1; } const auto& s = spawns[idx]; static const char* behaviors[] = { "Stationary", "Patrol", "Wander", "Scripted" }; int bIdx = static_cast<int>(s.behavior); if (bIdx < 0 || bIdx > 3) bIdx = 0; if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["kind"] = "creature"; j["index"] = idx; j["id"] = s.id; j["name"] = s.name; j["modelPath"] = s.modelPath; j["displayId"] = s.displayId; j["position"] = {s.position.x, s.position.y, s.position.z}; j["orientation"] = s.orientation; j["level"] = s.level; j["health"] = s.health; j["mana"] = s.mana; j["faction"] = s.faction; j["scale"] = s.scale; j["behavior"] = behaviors[bIdx]; j["wanderRadius"] = s.wanderRadius; j["aggroRadius"] = s.aggroRadius; j["leashRadius"] = s.leashRadius; j["respawnTimeMs"] = s.respawnTimeMs; j["hostile"] = s.hostile; j["questgiver"] = s.questgiver; j["vendor"] = s.vendor; j["trainer"] = s.trainer; j["patrolPathSize"] = s.patrolPath.size(); std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Creature spawn %d in %s\n", idx, zoneDir.c_str()); std::printf(" id : %u\n", s.id); std::printf(" name : %s\n", s.name.c_str()); std::printf(" modelPath : %s\n", s.modelPath.empty() ? "(template)" : s.modelPath.c_str()); std::printf(" displayId : %u\n", s.displayId); std::printf(" position : (%.2f, %.2f, %.2f)\n", s.position.x, s.position.y, s.position.z); std::printf(" orientation : %.1f°\n", s.orientation); std::printf(" level : %u\n", s.level); std::printf(" health/mana : %u / %u\n", s.health, s.mana); std::printf(" faction : %u\n", s.faction); std::printf(" scale : %.2f\n", s.scale); std::printf(" behavior : %s\n", behaviors[bIdx]); std::printf(" wander/aggro : %.1f / %.1f y\n", s.wanderRadius, s.aggroRadius); std::printf(" leash : %.1f y\n", s.leashRadius); std::printf(" respawn : %.0f s\n", s.respawnTimeMs / 1000.0f); std::printf(" flags : %s%s%s%s\n", s.hostile ? "hostile " : "", s.questgiver ? "questgiver " : "", s.vendor ? "vendor " : "", s.trainer ? "trainer " : ""); std::printf(" patrol path : %zu waypoint(s)\n", s.patrolPath.size()); return 0; } else if (kind == "object") { wowee::editor::ObjectPlacer placer; if (!placer.loadFromFile(zoneDir + "/objects.json")) { std::fprintf(stderr, "info-spawn: %s has no objects.json\n", zoneDir.c_str()); return 1; } const auto& objs = placer.getObjects(); if (idx < 0 || static_cast<size_t>(idx) >= objs.size()) { std::fprintf(stderr, "info-spawn: index %d out of range (have %zu)\n", idx, objs.size()); return 1; } const auto& o = objs[idx]; if (jsonOut) { nlohmann::json j; j["zone"] = zoneDir; j["kind"] = "object"; j["index"] = idx; j["uniqueId"] = o.uniqueId; j["path"] = o.path; j["type"] = o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"; j["position"] = {o.position.x, o.position.y, o.position.z}; j["rotation"] = {o.rotation.x, o.rotation.y, o.rotation.z}; j["scale"] = o.scale; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Object spawn %d in %s\n", idx, zoneDir.c_str()); std::printf(" uniqueId : %u\n", o.uniqueId); std::printf(" path : %s\n", o.path.c_str()); std::printf(" type : %s\n", o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo"); std::printf(" position : (%.2f, %.2f, %.2f)\n", o.position.x, o.position.y, o.position.z); std::printf(" rotation : (%.2f, %.2f, %.2f) rad\n", o.rotation.x, o.rotation.y, o.rotation.z); std::printf(" scale : %.2f\n", o.scale); return 0; } std::fprintf(stderr, "info-spawn: kind must be 'creature' or 'object' (got '%s')\n", kind.c_str()); return 1; } else if (std::strcmp(argv[i], "--list-project-spawns") == 0 && i + 1 < argc) { // Project-wide companion to --list-zone-spawns. Combines // creatures + objects across every zone into one big // listing keyed by (zone, kind, name). Useful for project- // wide review and for piping into spreadsheets via --json. 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-spawns: %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()); int totalCreat = 0, totalObj = 0; struct Row { std::string zone, kind, name; float x, y, z; std::string extra; }; std::vector<Row> rows; for (const auto& zoneDir : zones) { std::string zname = fs::path(zoneDir).filename().string(); wowee::editor::NpcSpawner spawner; if (spawner.loadFromFile(zoneDir + "/creatures.json")) { for (const auto& s : spawner.getSpawns()) { Row r; r.zone = zname; r.kind = "creature"; r.name = s.name; r.x = s.position.x; r.y = s.position.y; r.z = s.position.z; r.extra = "lvl " + std::to_string(s.level); rows.push_back(r); totalCreat++; } } wowee::editor::ObjectPlacer placer; if (placer.loadFromFile(zoneDir + "/objects.json")) { for (const auto& o : placer.getObjects()) { Row r; r.zone = zname; r.kind = "object"; r.name = o.path; r.x = o.position.x; r.y = o.position.y; r.z = o.position.z; char buf[32]; std::snprintf(buf, sizeof(buf), "scale %.2f", o.scale); r.extra = buf; rows.push_back(r); totalObj++; } } } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = zones.size(); j["creatureCount"] = totalCreat; j["objectCount"] = totalObj; nlohmann::json arr = nlohmann::json::array(); for (const auto& r : rows) { arr.push_back({{"zone", r.zone}, {"kind", r.kind}, {"name", r.name}, {"x", r.x}, {"y", r.y}, {"z", r.z}, {"extra", r.extra}}); } j["spawns"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project spawns: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" creatures : %d\n", totalCreat); std::printf(" objects : %d\n", totalObj); if (rows.empty()) { std::printf("\n *no spawns in any zone*\n"); return 0; } std::printf("\n zone kind x y z info name\n"); for (const auto& r : rows) { std::printf(" %-20s %-8s %8.1f %8.1f %8.1f %-10s %s\n", r.zone.substr(0, 20).c_str(), r.kind.c_str(), r.x, r.y, r.z, r.extra.c_str(), r.name.substr(0, 60).c_str()); } return 0; } else if (std::strcmp(argv[i], "--audit-project-spawns") == 0 && i + 1 < argc) { // Project-wide wrapper around --audit-zone-spawns. Spawns // the binary per-zone (only those with creatures.json or // objects.json), aggregates how many issues each zone has, // and exits 1 if any zone reports problems. CI-friendly // pre-release placement check. std::string projectDir = argv[++i]; std::string thresholdArg; if (i + 2 < argc && std::strcmp(argv[i + 1], "--threshold") == 0) { thresholdArg = argv[i + 2]; i += 2; } namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "audit-project-spawns: %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; bool hasContent = fs::exists(entry.path() / "creatures.json") || fs::exists(entry.path() / "objects.json"); if (!hasContent) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); if (zones.empty()) { std::printf("audit-project-spawns: %s\n", projectDir.c_str()); std::printf(" no zones with creatures.json or objects.json\n"); return 0; } std::string self = argv[0]; int passed = 0, failed = 0; std::printf("audit-project-spawns: %s\n", projectDir.c_str()); std::printf(" zones to audit : %zu\n", zones.size()); if (!thresholdArg.empty()) { std::printf(" threshold : %s yards\n", thresholdArg.c_str()); } std::printf("\n"); for (const auto& zoneDir : zones) { std::printf("--- %s ---\n", fs::path(zoneDir).filename().string().c_str()); std::fflush(stdout); std::string cmd = "\"" + self + "\" --audit-zone-spawns \"" + zoneDir + "\""; if (!thresholdArg.empty()) { cmd += " --threshold " + thresholdArg; } int rc = std::system(cmd.c_str()); if (rc == 0) passed++; else failed++; } std::printf("\n--- summary ---\n"); std::printf(" passed : %d\n", passed); std::printf(" failed : %d\n", failed); if (failed == 0) { std::printf("\n ALL ZONES PASSED\n"); return 0; } std::printf("\n Run --snap-project-to-ground to fix in bulk.\n"); return 1; } else if (std::strcmp(argv[i], "--snap-project-to-ground") == 0 && i + 1 < argc) { // Orchestrator wrapper around --snap-zone-to-ground. Spawns // the binary per-zone (only zones with at least one of // creatures.json or objects.json since pure-terrain zones // have nothing to snap), aggregates a final summary. std::string projectDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "snap-project-to-ground: %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; bool hasContent = fs::exists(entry.path() / "creatures.json") || fs::exists(entry.path() / "objects.json"); if (!hasContent) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); if (zones.empty()) { std::printf("snap-project-to-ground: %s\n", projectDir.c_str()); std::printf(" no zones with creatures.json or objects.json\n"); return 0; } std::string self = argv[0]; int passed = 0, failed = 0; std::printf("snap-project-to-ground: %s\n", projectDir.c_str()); std::printf(" zones to snap : %zu\n\n", zones.size()); for (const auto& zoneDir : zones) { std::printf("--- %s ---\n", fs::path(zoneDir).filename().string().c_str()); std::fflush(stdout); std::string cmd = "\"" + self + "\" --snap-zone-to-ground \"" + zoneDir + "\""; int rc = std::system(cmd.c_str()); if (rc == 0) passed++; else failed++; } std::printf("\n--- summary ---\n"); std::printf(" zones snapped : %d\n", passed); std::printf(" failed : %d\n", failed); return failed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--list-items") == 0 && i + 1 < argc) { // Inspect <zoneDir>/items.json. Pretty-prints id / quality // / item level / display id / name as a table; also // supports --json for machine-readable output. 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 path = zoneDir + "/items.json"; if (!fs::exists(path)) { std::fprintf(stderr, "list-items: %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, "list-items: %s is not valid JSON\n", path.c_str()); return 1; } if (!doc.contains("items") || !doc["items"].is_array()) { std::fprintf(stderr, "list-items: %s has no 'items' array\n", path.c_str()); return 1; } const auto& items = doc["items"]; if (jsonOut) { std::printf("%s\n", items.dump(2).c_str()); return 0; } static const char* qualityNames[] = { "poor", "common", "uncommon", "rare", "epic", "legendary", "artifact" }; std::printf("Zone items: %s\n", path.c_str()); std::printf(" count : %zu\n\n", items.size()); if (items.empty()) { std::printf(" *no items*\n"); return 0; } std::printf(" idx id ilvl stack quality displayId name\n"); for (size_t k = 0; k < items.size(); ++k) { const auto& it = items[k]; uint32_t id = it.value("id", 0u); uint32_t quality = it.value("quality", 1u); uint32_t ilvl = it.value("itemLevel", 1u); uint32_t displayId = it.value("displayId", 0u); uint32_t stack = it.value("stackable", 1u); std::string name = it.value("name", std::string()); if (quality > 6) quality = 0; std::printf(" %3zu %5u %4u %5u %-10s %9u %s\n", k, id, ilvl, stack, qualityNames[quality], displayId, name.c_str()); } return 0; } else if (std::strcmp(argv[i], "--info-item") == 0 && i + 2 < argc) { // Single-item detail view. Lookup is by id by default; // prefix the argument with '#' (e.g., "#3") to look up by // 0-based array index instead. Useful for inspecting all // fields of a single record without sifting through the // full --list-items table. std::string zoneDir = argv[++i]; std::string lookup = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; std::string path = zoneDir + "/items.json"; if (!fs::exists(path)) { std::fprintf(stderr, "info-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, "info-item: %s is not valid JSON\n", path.c_str()); return 1; } if (!doc.contains("items") || !doc["items"].is_array()) { std::fprintf(stderr, "info-item: %s has no 'items' array\n", path.c_str()); return 1; } const 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, "info-item: lookup '%s' is not a number " "(use '#N' for index lookup)\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, "info-item: no match for '%s' in %s\n", lookup.c_str(), path.c_str()); return 1; } const auto& it = items[foundIdx]; if (jsonOut) { std::printf("%s\n", it.dump(2).c_str()); return 0; } static const char* qualityNames[] = { "poor", "common", "uncommon", "rare", "epic", "legendary", "artifact" }; uint32_t quality = it.value("quality", 1u); if (quality > 6) quality = 0; std::printf("Item %d in %s\n", foundIdx, path.c_str()); std::printf(" id : %u\n", it.value("id", 0u)); std::printf(" name : %s\n", it.value("name", std::string("(unnamed)")).c_str()); std::printf(" quality : %u (%s)\n", quality, qualityNames[quality]); std::printf(" itemLevel : %u\n", it.value("itemLevel", 1u)); std::printf(" displayId : %u\n", it.value("displayId", 0u)); std::printf(" stackable : %u\n", it.value("stackable", 1u)); // Surface any extra fields the user added by hand so // info-item stays useful as the schema evolves. std::vector<std::string> extras; for (auto& [k, v] : it.items()) { if (k == "id" || k == "name" || k == "quality" || k == "itemLevel" || k == "displayId" || k == "stackable") continue; extras.push_back(k); } if (!extras.empty()) { std::printf("\n Extra fields:\n"); for (const auto& k : extras) { std::printf(" %s = %s\n", k.c_str(), it[k].dump().c_str()); } } 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], "--validate-items") == 0 && i + 1 < argc) { // Schema validator for items.json. Catches what // --add-item / --clone-item only enforce on insertion // (e.g., duplicate ids if the file was hand-edited), // plus general field-range issues. Exit 1 if any error. std::string zoneDir = argv[++i]; namespace fs = std::filesystem; std::string path = zoneDir + "/items.json"; if (!fs::exists(path)) { std::fprintf(stderr, "validate-items: %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, "validate-items: %s is not valid JSON\n", path.c_str()); return 1; } if (!doc.contains("items") || !doc["items"].is_array()) { std::fprintf(stderr, "validate-items: %s has no 'items' array\n", path.c_str()); return 1; } const auto& items = doc["items"]; std::vector<std::string> errors; std::map<uint32_t, std::vector<size_t>> idIndices; // id -> [item indices] for (size_t k = 0; k < items.size(); ++k) { const auto& it = items[k]; if (!it.is_object()) { errors.push_back("item " + std::to_string(k) + ": not a JSON object"); continue; } if (!it.contains("id") || !it["id"].is_number_unsigned() || it["id"].get<uint32_t>() == 0) { errors.push_back("item " + std::to_string(k) + ": missing/invalid 'id' (must be positive uint)"); } else { idIndices[it["id"].get<uint32_t>()].push_back(k); } if (!it.contains("name") || !it["name"].is_string() || it["name"].get<std::string>().empty()) { errors.push_back("item " + std::to_string(k) + ": missing/empty 'name'"); } if (it.contains("quality") && it["quality"].is_number_unsigned()) { uint32_t q = it["quality"].get<uint32_t>(); if (q > 6) { errors.push_back("item " + std::to_string(k) + ": quality " + std::to_string(q) + " out of range (must be 0..6)"); } } // itemLevel / stackable should be reasonable; flag // pathological values that almost certainly indicate // a typo (e.g., million-level item). if (it.contains("itemLevel") && it["itemLevel"].is_number_unsigned()) { uint32_t lvl = it["itemLevel"].get<uint32_t>(); if (lvl > 1000) { errors.push_back("item " + std::to_string(k) + ": itemLevel " + std::to_string(lvl) + " is suspiciously high (>1000)"); } } if (it.contains("stackable") && it["stackable"].is_number_unsigned()) { uint32_t s = it["stackable"].get<uint32_t>(); if (s == 0 || s > 1000) { errors.push_back("item " + std::to_string(k) + ": stackable " + std::to_string(s) + " out of range (must be 1..1000)"); } } } for (const auto& [id, indices] : idIndices) { if (indices.size() > 1) { std::string idxList; for (size_t v : indices) { if (!idxList.empty()) idxList += ", "; idxList += std::to_string(v); } errors.push_back("duplicate id " + std::to_string(id) + " at item indices [" + idxList + "]"); } } std::printf("validate-items: %s\n", path.c_str()); std::printf(" items checked : %zu\n", items.size()); std::printf(" errors : %zu\n", errors.size()); if (errors.empty()) { std::printf("\n PASSED\n"); return 0; } std::printf("\n Errors:\n"); for (const auto& e : errors) { std::printf(" - %s\n", e.c_str()); } return 1; } else if (std::strcmp(argv[i], "--validate-project-items") == 0 && i + 1 < argc) { // Project-wide wrapper around --validate-items. Spawns // the binary per-zone (only zones that have items.json) // so each zone's full error report streams through, then // aggregates a final tally. Exit 1 if any zone fails. // // Skips zones without items.json — those have nothing to // validate and shouldn't count as failures. std::string projectDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "validate-project-items: %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; if (!fs::exists(entry.path() / "items.json")) continue; zones.push_back(entry.path().string()); } std::sort(zones.begin(), zones.end()); if (zones.empty()) { std::printf("validate-project-items: %s\n", projectDir.c_str()); std::printf(" no zones with items.json — nothing to validate\n"); return 0; } std::string self = argv[0]; int passed = 0, failed = 0; std::printf("validate-project-items: %s\n", projectDir.c_str()); std::printf(" zones with items : %zu\n\n", zones.size()); for (const auto& zoneDir : zones) { std::printf("--- %s ---\n", fs::path(zoneDir).filename().string().c_str()); std::fflush(stdout); std::string cmd = "\"" + self + "\" --validate-items \"" + zoneDir + "\""; int rc = std::system(cmd.c_str()); if (rc == 0) passed++; else failed++; } std::printf("\n--- summary ---\n"); std::printf(" passed : %d\n", passed); std::printf(" failed : %d\n", failed); if (failed == 0) { std::printf("\n ALL ZONES PASSED\n"); return 0; } return 1; } else if (std::strcmp(argv[i], "--info-project-items") == 0 && i + 1 < argc) { // Project-wide rollup of items.json across zones. Reports // per-zone item counts plus project-wide totals and a // quality histogram. Useful for "do my zones have enough // loot variety?" capacity checks. 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-items: %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()); static const char* qualityNames[] = { "poor", "common", "uncommon", "rare", "epic", "legendary", "artifact" }; struct ZRow { std::string name; int count = 0; int qHist[7] = {}; }; std::vector<ZRow> rows; int totalItems = 0; int globalQHist[7] = {}; for (const auto& zoneDir : zones) { ZRow r; r.name = fs::path(zoneDir).filename().string(); std::string path = zoneDir + "/items.json"; if (fs::exists(path)) { nlohmann::json doc; try { std::ifstream in(path); in >> doc; } catch (...) {} if (doc.contains("items") && doc["items"].is_array()) { r.count = static_cast<int>(doc["items"].size()); for (const auto& it : doc["items"]) { uint32_t q = it.value("quality", 1u); if (q > 6) q = 0; r.qHist[q]++; globalQHist[q]++; } } } totalItems += r.count; rows.push_back(r); } if (jsonOut) { nlohmann::json j; j["project"] = projectDir; j["zoneCount"] = zones.size(); j["totalItems"] = totalItems; nlohmann::json qual; for (int q = 0; q <= 6; ++q) qual[qualityNames[q]] = globalQHist[q]; j["quality"] = qual; nlohmann::json zarr = nlohmann::json::array(); for (const auto& r : rows) { nlohmann::json zq; for (int q = 0; q <= 6; ++q) zq[qualityNames[q]] = r.qHist[q]; zarr.push_back({{"name", r.name}, {"count", r.count}, {"quality", zq}}); } j["zones"] = zarr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("Project items: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" total items : %d\n\n", totalItems); std::printf(" Quality histogram (project-wide):\n"); for (int q = 0; q <= 6; ++q) { if (globalQHist[q] == 0) continue; std::printf(" %-10s : %d\n", qualityNames[q], globalQHist[q]); } std::printf("\n zone items poor common uncommon rare epic legend art\n"); for (const auto& r : rows) { std::printf(" %-20s %5d %5d %6d %8d %4d %4d %6d %3d\n", r.name.substr(0, 20).c_str(), r.count, r.qHist[0], r.qHist[1], r.qHist[2], r.qHist[3], r.qHist[4], r.qHist[5], r.qHist[6]); } 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], "--convert-m2-batch") == 0 && i + 1 < argc) { // Bulk M2→WOM conversion. Walks <srcDir> recursively for // every .m2 file and re-invokes --convert-m2 per file via // a child process so the existing single-file logic (with // its AssetManager + skin-resolution bookkeeping) is reused // verbatim. Reports per-file pass/fail and an aggregate // summary. // // Designed to migrate an entire creature/world model dump // in one go. Pair with --convert-blp-batch and --convert- // wmo-batch to migrate a complete extracted Data tree. std::string srcDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "convert-m2-batch: %s is not a directory\n", srcDir.c_str()); return 1; } std::vector<std::string> m2Files; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); if (ext != ".m2") continue; m2Files.push_back(e.path().string()); } std::sort(m2Files.begin(), m2Files.end()); std::printf("convert-m2-batch: %s\n", srcDir.c_str()); std::printf(" candidates : %zu .m2 file(s)\n", m2Files.size()); std::string self = argv[0]; int ok = 0, failed = 0; for (const auto& m2 : m2Files) { std::fflush(stdout); std::string cmd = "\"" + self + "\" --convert-m2 \"" + m2 + "\""; cmd += " >/dev/null 2>&1"; int rc = std::system(cmd.c_str()); if (rc == 0) { ok++; std::printf(" [ok] %s\n", m2.c_str()); } else { failed++; std::printf(" [FAIL] %s (rc=%d)\n", m2.c_str(), rc); } } std::printf("\n summary : %d ok, %d failed (out of %zu)\n", ok, failed, m2Files.size()); return failed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--convert-wmo-batch") == 0 && i + 1 < argc) { // Bulk WMO→WOB conversion. Same orchestrator pattern as // --convert-m2-batch: walks <srcDir> recursively, runs the // existing single-file --convert-wmo per file. // // Skips group files (e.g. Stormwind_001.wmo) since the // root WMO converter already pulls those in transitively. // A WMO is a "group file" iff its stem ends in _NNN where // NNN is a 3-digit integer. std::string srcDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "convert-wmo-batch: %s is not a directory\n", srcDir.c_str()); return 1; } auto isGroupFile = [](const std::string& stem) { if (stem.size() < 5) return false; if (stem[stem.size() - 4] != '_') return false; for (int k = 1; k <= 3; ++k) { if (!std::isdigit(static_cast<unsigned char>( stem[stem.size() - k]))) return false; } return true; }; std::vector<std::string> wmoFiles; int skippedGroups = 0; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); if (ext != ".wmo") continue; std::string stem = e.path().stem().string(); if (isGroupFile(stem)) { skippedGroups++; continue; } wmoFiles.push_back(e.path().string()); } std::sort(wmoFiles.begin(), wmoFiles.end()); std::printf("convert-wmo-batch: %s\n", srcDir.c_str()); std::printf(" candidates : %zu root .wmo file(s) (skipped %d group file(s))\n", wmoFiles.size(), skippedGroups); std::string self = argv[0]; int ok = 0, failed = 0; for (const auto& wmo : wmoFiles) { std::fflush(stdout); std::string cmd = "\"" + self + "\" --convert-wmo \"" + wmo + "\""; cmd += " >/dev/null 2>&1"; int rc = std::system(cmd.c_str()); if (rc == 0) { ok++; std::printf(" [ok] %s\n", wmo.c_str()); } else { failed++; std::printf(" [FAIL] %s (rc=%d)\n", wmo.c_str(), rc); } } std::printf("\n summary : %d ok, %d failed (out of %zu)\n", ok, failed, wmoFiles.size()); return failed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--convert-blp-batch") == 0 && i + 1 < argc) { // Bulk BLP→PNG conversion. Walks <srcDir> recursively for // every .blp file and re-invokes --convert-blp-png per // file via a child process. The single-file converter // writes the .png as a sidecar next to the source by // default, so a batched run mirrors the standard "PNG // sidecar everywhere" layout. std::string srcDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "convert-blp-batch: %s is not a directory\n", srcDir.c_str()); return 1; } std::vector<std::string> blpFiles; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); if (ext != ".blp") continue; blpFiles.push_back(e.path().string()); } std::sort(blpFiles.begin(), blpFiles.end()); std::printf("convert-blp-batch: %s\n", srcDir.c_str()); std::printf(" candidates : %zu .blp file(s)\n", blpFiles.size()); std::string self = argv[0]; int ok = 0, failed = 0; for (const auto& blp : blpFiles) { std::fflush(stdout); std::string cmd = "\"" + self + "\" --convert-blp-png \"" + blp + "\""; cmd += " >/dev/null 2>&1"; int rc = std::system(cmd.c_str()); if (rc == 0) { ok++; std::printf(" [ok] %s\n", blp.c_str()); } else { failed++; std::printf(" [FAIL] %s (rc=%d)\n", blp.c_str(), rc); } } std::printf("\n summary : %d ok, %d failed (out of %zu)\n", ok, failed, blpFiles.size()); return failed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--convert-dbc-batch") == 0 && i + 1 < argc) { // Bulk DBC→JSON conversion. Walks <srcDir> recursively for // every .dbc file and re-invokes --convert-dbc-json per // file. Each .json sidecar is written next to the source. // Final commit in the four-format batch-converter set: // m2/wmo/blp/dbc → wom/wob/png/json. Run all four to // migrate an extracted Data tree end-to-end. std::string srcDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "convert-dbc-batch: %s is not a directory\n", srcDir.c_str()); return 1; } std::vector<std::string> dbcFiles; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); if (ext != ".dbc") continue; dbcFiles.push_back(e.path().string()); } std::sort(dbcFiles.begin(), dbcFiles.end()); std::printf("convert-dbc-batch: %s\n", srcDir.c_str()); std::printf(" candidates : %zu .dbc file(s)\n", dbcFiles.size()); std::string self = argv[0]; int ok = 0, failed = 0; for (const auto& dbc : dbcFiles) { std::fflush(stdout); std::string cmd = "\"" + self + "\" --convert-dbc-json \"" + dbc + "\""; cmd += " >/dev/null 2>&1"; int rc = std::system(cmd.c_str()); if (rc == 0) { ok++; std::printf(" [ok] %s\n", dbc.c_str()); } else { failed++; std::printf(" [FAIL] %s (rc=%d)\n", dbc.c_str(), rc); } } std::printf("\n summary : %d ok, %d failed (out of %zu)\n", ok, failed, dbcFiles.size()); return failed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--migrate-data-tree") == 0 && i + 1 < argc) { // End-to-end open-format migration. Runs all four bulk // converters (m2/wmo/blp/dbc → wom/wob/png/json) in order // on a single extracted Data tree. Each step's full // output streams through; aggregate exit code is failure // if any sub-converter fails. // // Idempotent: re-running on a partially-converted tree // re-attempts the originals (which still produce the // same sidecar) without removing any prior outputs. std::string srcDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "migrate-data-tree: %s is not a directory\n", srcDir.c_str()); return 1; } std::string self = argv[0]; struct Step { const char* name; const char* flag; int rc; }; std::vector<Step> steps = { {"M2 → WOM ", "--convert-m2-batch", 0}, {"WMO → WOB ", "--convert-wmo-batch", 0}, {"BLP → PNG ", "--convert-blp-batch", 0}, {"DBC → JSON", "--convert-dbc-batch", 0}, }; int totalFailed = 0; std::printf("migrate-data-tree: %s\n", srcDir.c_str()); for (auto& s : steps) { std::printf("\n=== %s (%s) ===\n", s.name, s.flag); std::fflush(stdout); std::string cmd = "\"" + self + "\" " + s.flag + " \"" + srcDir + "\""; s.rc = std::system(cmd.c_str()); if (s.rc != 0) totalFailed++; } std::printf("\n=== migrate-data-tree summary ===\n"); for (const auto& s : steps) { std::printf(" [%s] %s (rc=%d)\n", s.rc == 0 ? "PASS" : "FAIL", s.name, s.rc); } if (totalFailed == 0) { std::printf("\n ALL FOUR PASSED — open-format migration complete\n"); return 0; } std::printf("\n %d step(s) reported failures (re-run individually for detail)\n", totalFailed); return 1; } else if (std::strcmp(argv[i], "--bench-migrate-data-tree") == 0 && i + 1 < argc) { // Time each --migrate-data-tree step end-to-end. Useful // for capacity planning ("how long will the full extracted // Data tree take?") and regression detection (a recent // change shouldn't make M2 conversion 2x slower). // // Sub-batches are dispatched the same way --migrate-data- // tree dispatches them — so the timings here are exactly // what the user will experience running the migration. std::string srcDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "bench-migrate-data-tree: %s is not a directory\n", srcDir.c_str()); return 1; } std::string self = argv[0]; struct Step { const char* name; const char* flag; double ms = 0; int rc = 0; }; std::vector<Step> steps = { {"M2 → WOM ", "--convert-m2-batch", 0, 0}, {"WMO → WOB ", "--convert-wmo-batch", 0, 0}, {"BLP → PNG ", "--convert-blp-batch", 0, 0}, {"DBC → JSON", "--convert-dbc-batch", 0, 0}, }; double totalMs = 0; for (auto& s : steps) { std::string cmd = "\"" + self + "\" " + s.flag + " \"" + srcDir + "\""; cmd += " >/dev/null 2>&1"; auto t0 = std::chrono::steady_clock::now(); s.rc = std::system(cmd.c_str()); auto t1 = std::chrono::steady_clock::now(); s.ms = std::chrono::duration<double, std::milli>(t1 - t0).count(); totalMs += s.ms; } if (jsonOut) { nlohmann::json j; j["srcDir"] = srcDir; j["totalMs"] = totalMs; nlohmann::json arr = nlohmann::json::array(); for (const auto& s : steps) { double share = totalMs > 0 ? 100.0 * s.ms / totalMs : 0.0; arr.push_back({{"name", s.name}, {"flag", s.flag}, {"ms", s.ms}, {"share", share}, {"rc", s.rc}}); } j["steps"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("bench-migrate-data-tree: %s\n", srcDir.c_str()); std::printf(" total : %.1f ms (%.2f s)\n", totalMs, totalMs / 1000.0); std::printf("\n step wall-clock share status\n"); for (const auto& s : steps) { double share = totalMs > 0 ? 100.0 * s.ms / totalMs : 0.0; std::printf(" %-15s %8.1f ms %5.1f%% %s (rc=%d)\n", s.name, s.ms, share, s.rc == 0 ? "ok" : "FAIL", s.rc); } return 0; } else if (std::strcmp(argv[i], "--list-data-tree-largest") == 0 && i + 1 < argc) { // Top-N largest proprietary files (.m2/.wmo/.blp/.dbc). // Helps prioritize migration: convert the biggest files // first to free the most disk space sooner. Annotates // each file with whether an open sidecar already exists, // so users can see at a glance which heavy hitters are // already migrated vs still pending. // // Default N = 20. Sized for a terminal page; use --json // (or pass a larger N) for full lists. std::string srcDir = argv[++i]; int N = 20; if (i + 1 < argc && argv[i + 1][0] != '-') { try { N = std::stoi(argv[++i]); } catch (...) {} if (N < 1) N = 20; } namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "list-data-tree-largest: %s is not a directory\n", srcDir.c_str()); return 1; } static const std::vector<std::pair<std::string, std::string>> kPairs = { {".m2", ".wom"}, {".wmo", ".wob"}, {".blp", ".png"}, {".dbc", ".json"}, }; // Open sidecar set for the migration-status annotation. std::map<std::string, std::set<std::pair<std::string, std::string>>> openSets; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); for (const auto& [_, openExt] : kPairs) { if (ext == openExt) { openSets[openExt].insert( {e.path().parent_path().string(), e.path().stem().string()}); break; } } } struct Entry { std::string path; uint64_t bytes; std::string ext; bool migrated; }; std::vector<Entry> entries; uint64_t totalBytes = 0; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); std::string openExt; for (const auto& [propExt, oExt] : kPairs) { if (ext == propExt) { openExt = oExt; break; } } if (openExt.empty()) continue; uint64_t sz = e.file_size(ec); if (ec) sz = 0; std::pair<std::string, std::string> key{ e.path().parent_path().string(), e.path().stem().string()}; bool migrated = openSets[openExt].count(key) > 0; entries.push_back({e.path().string(), sz, ext, migrated}); totalBytes += sz; } std::sort(entries.begin(), entries.end(), [](const Entry& a, const Entry& b) { return a.bytes > b.bytes; }); int shown = std::min(static_cast<int>(entries.size()), N); uint64_t shownBytes = 0; for (int k = 0; k < shown; ++k) shownBytes += entries[k].bytes; std::printf("list-data-tree-largest: %s\n", srcDir.c_str()); std::printf(" proprietary files : %zu (total %.1f MB)\n", entries.size(), totalBytes / (1024.0 * 1024.0)); std::printf(" showing top : %d (%.1f MB, %.1f%% of total)\n", shown, shownBytes / (1024.0 * 1024.0), totalBytes ? 100.0 * shownBytes / totalBytes : 0.0); if (entries.empty()) { std::printf("\n (no proprietary files found)\n"); return 0; } std::printf("\n rank ext bytes status path\n"); for (int k = 0; k < shown; ++k) { const auto& e = entries[k]; std::printf(" %4d %-4s %10llu %-7s %s\n", k + 1, e.ext.c_str(), static_cast<unsigned long long>(e.bytes), e.migrated ? "migrate" : "pending", e.path.c_str()); } return 0; } else if (std::strcmp(argv[i], "--export-data-tree-md") == 0 && i + 1 < argc) { // Markdown migration-progress report. Drops cleanly into // PR descriptions, CI artifacts, or status pages on // GitHub Pages. Same numbers as --info-data-tree but // formatted as a Markdown table with a status badge, // bytes summary, and recommended next steps so a reader // can act on the report without consulting the CLI help. std::string srcDir = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "export-data-tree-md: %s is not a directory\n", srcDir.c_str()); return 1; } if (outPath.empty()) outPath = srcDir + "/MIGRATION.md"; static const std::vector<std::pair<std::string, std::string>> kPairs = { {".m2", ".wom"}, {".wmo", ".wob"}, {".blp", ".png"}, {".dbc", ".json"}, }; // Same scan as --info-data-tree. std::map<std::string, std::set<std::pair<std::string, std::string>>> byExt; std::map<std::string, uint64_t> bytesByExt; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); byExt[ext].insert({e.path().parent_path().string(), e.path().stem().string()}); uint64_t sz = e.file_size(ec); if (!ec) bytesByExt[ext] += sz; } struct Row { std::string prop, open; int propCount, sidecarCount, orphanOpenCount; uint64_t propBytes; double share; }; std::vector<Row> rows; int totalProp = 0, totalSidecar = 0, totalOrphan = 0; uint64_t totalPropBytes = 0; for (const auto& [propExt, openExt] : kPairs) { Row r{propExt, openExt, 0, 0, 0, 0, 0.0}; const auto& propSet = byExt[propExt]; const auto& openSet = byExt[openExt]; r.propCount = static_cast<int>(propSet.size()); for (const auto& key : openSet) { if (propSet.count(key)) r.sidecarCount++; else r.orphanOpenCount++; } r.propBytes = bytesByExt[propExt]; r.share = r.propCount > 0 ? 100.0 * r.sidecarCount / r.propCount : 100.0; totalProp += r.propCount; totalSidecar += r.sidecarCount; totalOrphan += r.orphanOpenCount; totalPropBytes += r.propBytes; rows.push_back(r); } double overallShare = totalProp > 0 ? 100.0 * totalSidecar / totalProp : 100.0; const char* badge = overallShare >= 100.0 ? "**100% migrated**" : overallShare >= 75.0 ? "**Mostly migrated**" : overallShare >= 25.0 ? "*Partially migrated*" : "*Migration pending*"; std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "export-data-tree-md: cannot write %s\n", outPath.c_str()); return 1; } out << "# Data Tree Migration Report\n\n"; out << "Source: `" << srcDir << "`\n\n"; out << "Status: " << badge << " (" << std::fixed; out.precision(1); out << overallShare << "% sidecar coverage)\n\n"; out << "## Summary\n\n"; out << "- Proprietary files: **" << totalProp << "** (" << std::fixed; out.precision(2); out << (totalPropBytes / (1024.0 * 1024.0)) << " MB)\n"; out << "- Open sidecars present: **" << totalSidecar << "**\n"; out << "- Orphan open files (no proprietary source): **" << totalOrphan << "**\n\n"; out << "## Per-format pairs\n\n"; out << "| Pair | Proprietary | Sidecars | Orphan open | Prop bytes | Share |\n"; out << "|------|------------:|---------:|------------:|-----------:|------:|\n"; for (const auto& r : rows) { out << "| " << r.prop << " → " << r.open << " | " << r.propCount << " | " << r.sidecarCount << " | " << r.orphanOpenCount << " | " << r.propBytes << " | " << std::fixed; out.precision(1); out << r.share << "% |\n"; } out << "\n## Recommended next steps\n\n"; if (overallShare < 100.0) { out << "1. Run `wowee_editor --migrate-data-tree " << srcDir << "` to fill in the missing sidecars.\n"; out << "2. Run `wowee_editor --audit-data-tree " << srcDir << "` to confirm 100% coverage.\n"; out << "3. Run `wowee_editor --strip-data-tree " << srcDir << "` to delete the proprietary originals.\n"; } else { out << "All proprietary files are migrated. Run " << "`wowee_editor --strip-data-tree " << srcDir << "` to delete the originals and ship the open-only tree.\n"; } out.close(); std::printf("Wrote %s\n", outPath.c_str()); std::printf(" status : %s\n", badge); std::printf(" share : %.1f%%\n", overallShare); std::printf(" proprietary : %d files, %.2f MB\n", totalProp, totalPropBytes / (1024.0 * 1024.0)); return 0; } 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], "--gen-texture-gradient") == 0 && i + 3 < argc) { // Linear two-color gradient. Useful for sky strips, UI // fills, glow rings, dirt-on-grass terrain blends — the // common "fade" cases that --gen-texture's solid/checker/ // grid don't cover. // // Direction: "vertical" (top→bottom, default) or // "horizontal" (left→right). Colors are hex like // --gen-texture. std::string outPath = argv[++i]; std::string fromHex = argv[++i]; std::string toHex = argv[++i]; bool horizontal = false; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { std::string dir = argv[i + 1]; std::transform(dir.begin(), dir.end(), dir.begin(), [](unsigned char c) { return std::tolower(c); }); if (dir == "horizontal" || dir == "vertical") { horizontal = (dir == "horizontal"); i++; } } 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-gradient: invalid size %dx%d (1..8192)\n", W, H); return 1; } // Hex parser: shared local helper for both endpoints. Same // RRGGBB / RGB rules as --gen-texture. auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t r0, g0, b0, r1, g1, b1; if (!parseHex(fromHex, r0, g0, b0)) { std::fprintf(stderr, "gen-texture-gradient: '%s' is not a valid hex color\n", fromHex.c_str()); return 1; } if (!parseHex(toHex, r1, g1, b1)) { std::fprintf(stderr, "gen-texture-gradient: '%s' is not a valid hex color\n", toHex.c_str()); return 1; } std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { float t; if (horizontal) { t = (W <= 1) ? 0.0f : float(x) / float(W - 1); } else { t = (H <= 1) ? 0.0f : float(y) / float(H - 1); } auto lerp = [](uint8_t a, uint8_t b, float t) { return static_cast<uint8_t>(a + (b - a) * t + 0.5f); }; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = lerp(r0, r1, t); pixels[i2 + 1] = lerp(g0, g1, t); pixels[i2 + 2] = lerp(b0, b1, t); } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-gradient: 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(" direction : %s\n", horizontal ? "horizontal" : "vertical"); std::printf(" from : %s (rgb %u,%u,%u)\n", fromHex.c_str(), r0, g0, b0); std::printf(" to : %s (rgb %u,%u,%u)\n", toHex.c_str(), r1, g1, b1); return 0; } else if (std::strcmp(argv[i], "--gen-texture-noise") == 0 && i + 1 < argc) { // Smooth value-noise PNG. Useful for terrain detail // overlays, dirt/grass blends, magic-fog backdrops — // anywhere a "natural-looking" pseudo-random texture // beats a flat color or grid. // // Algorithm: bilinearly-interpolated 16×16 random lattice // sampled per pixel. Cheaper than perlin and produces a // similar visual signal at this resolution. // // Deterministic from the integer seed so CI runs and // re-runs are reproducible. Output is grayscale // (R==G==B per pixel) so users can tint it externally. std::string outPath = argv[++i]; uint32_t seed = 1; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } 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-noise: invalid size %dx%d (1..8192)\n", W, H); return 1; } // Tiny LCG (numerical recipes constants) so noise is // dependency-free and bit-for-bit identical across // platforms. const int latticeSize = 17; // 16 cells × bilinear corners std::vector<float> lattice(latticeSize * latticeSize); uint32_t state = seed ? seed : 1u; auto next = [&]() -> float { state = state * 1664525u + 1013904223u; return (state >> 8) / float(1 << 24); }; for (auto& v : lattice) v = next(); std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); for (int y = 0; y < H; ++y) { float fy = static_cast<float>(y) / H * (latticeSize - 1); int yi = static_cast<int>(fy); if (yi >= latticeSize - 1) yi = latticeSize - 2; float fty = fy - yi; // Smoothstep so cell boundaries don't show as bands. float ty = fty * fty * (3.0f - 2.0f * fty); for (int x = 0; x < W; ++x) { float fx = static_cast<float>(x) / W * (latticeSize - 1); int xi = static_cast<int>(fx); if (xi >= latticeSize - 1) xi = latticeSize - 2; float ftx = fx - xi; float tx = ftx * ftx * (3.0f - 2.0f * ftx); float a = lattice[yi * latticeSize + xi]; float b = lattice[yi * latticeSize + xi + 1]; float c = lattice[(yi + 1) * latticeSize + xi]; float d = lattice[(yi + 1) * latticeSize + xi + 1]; float ab = a + (b - a) * tx; float cd = c + (d - c) * tx; float v = ab + (cd - ab) * ty; uint8_t g = static_cast<uint8_t>(v * 255.0f + 0.5f); size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = g; pixels[i2 + 1] = g; pixels[i2 + 2] = g; } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-noise: 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(" seed : %u\n", seed); std::printf(" type : smooth value noise (16x16 bilinear lattice)\n"); return 0; } else if (std::strcmp(argv[i], "--gen-texture-noise-color") == 0 && i + 3 < argc) { // Two-color noise blend: same value-noise function as // --gen-texture-noise but interpolated between two RGB // endpoints rather than emitted as grayscale. Useful // for terrain detail (grass+dirt mottle), magic fog, // marble veining, or any "natural variation" pass that // shouldn't be desaturated. std::string outPath = argv[++i]; std::string aHex = argv[++i]; std::string bHex = argv[++i]; uint32_t seed = 1; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } 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-noise-color: invalid size %dx%d\n", W, H); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t ra, ga, ba, rb, gb, bb; if (!parseHex(aHex, ra, ga, ba)) { std::fprintf(stderr, "gen-texture-noise-color: '%s' is not a valid hex color\n", aHex.c_str()); return 1; } if (!parseHex(bHex, rb, gb, bb)) { std::fprintf(stderr, "gen-texture-noise-color: '%s' is not a valid hex color\n", bHex.c_str()); return 1; } // Same noise pipeline as --gen-texture-noise. const int latticeSize = 17; std::vector<float> lattice(latticeSize * latticeSize); uint32_t state = seed ? seed : 1u; auto next = [&]() -> float { state = state * 1664525u + 1013904223u; return (state >> 8) / float(1 << 24); }; for (auto& v : lattice) v = next(); std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); for (int y = 0; y < H; ++y) { float fy = static_cast<float>(y) / H * (latticeSize - 1); int yi = static_cast<int>(fy); if (yi >= latticeSize - 1) yi = latticeSize - 2; float fty = fy - yi; float ty = fty * fty * (3.0f - 2.0f * fty); for (int x = 0; x < W; ++x) { float fx = static_cast<float>(x) / W * (latticeSize - 1); int xi = static_cast<int>(fx); if (xi >= latticeSize - 1) xi = latticeSize - 2; float ftx = fx - xi; float tx = ftx * ftx * (3.0f - 2.0f * ftx); float a = lattice[yi * latticeSize + xi]; float b = lattice[yi * latticeSize + xi + 1]; float c = lattice[(yi + 1) * latticeSize + xi]; float d = lattice[(yi + 1) * latticeSize + xi + 1]; float ab = a + (b - a) * tx; float cd = c + (d - c) * tx; float v = ab + (cd - ab) * ty; auto lerp = [](uint8_t lo, uint8_t hi, float t) { return static_cast<uint8_t>(lo + (hi - lo) * t + 0.5f); }; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = lerp(ra, rb, v); pixels[i2 + 1] = lerp(ga, gb, v); pixels[i2 + 2] = lerp(ba, bb, v); } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-noise-color: 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(" seed : %u\n", seed); std::printf(" from : %s\n", aHex.c_str()); std::printf(" to : %s\n", bHex.c_str()); return 0; } else if (std::strcmp(argv[i], "--gen-texture-radial") == 0 && i + 3 < argc) { // Radial gradient: centerHex at the image center fading // smoothly to edgeHex at the corner. Useful for spell // glow rings, vignettes, soft-edged decals — the // common "circular blob" cases that linear gradients // can't produce. // // Distance is normalized so the corner is t=1 (image is // not necessarily square). A smoothstep curve gives a // soft falloff rather than a harsh disc edge. std::string outPath = argv[++i]; std::string centerHex = argv[++i]; std::string edgeHex = 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-radial: invalid size %dx%d (1..8192)\n", W, H); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t rc, gc, bc, re, ge, be; if (!parseHex(centerHex, rc, gc, bc)) { std::fprintf(stderr, "gen-texture-radial: '%s' is not a valid hex color\n", centerHex.c_str()); return 1; } if (!parseHex(edgeHex, re, ge, be)) { std::fprintf(stderr, "gen-texture-radial: '%s' is not a valid hex color\n", edgeHex.c_str()); return 1; } std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); float cx = (W - 1) * 0.5f; float cy = (H - 1) * 0.5f; // Max distance is the corner (cx, cy itself = half-diag). float maxD = std::sqrt(cx * cx + cy * cy); for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { float dx = x - cx; float dy = y - cy; float d = std::sqrt(dx * dx + dy * dy); float t = (maxD > 0) ? (d / maxD) : 0.0f; if (t > 1.0f) t = 1.0f; // Smoothstep so the falloff is soft. float smt = t * t * (3.0f - 2.0f * t); auto lerp = [](uint8_t a, uint8_t b, float t) { return static_cast<uint8_t>(a + (b - a) * t + 0.5f); }; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = lerp(rc, re, smt); pixels[i2 + 1] = lerp(gc, ge, smt); pixels[i2 + 2] = lerp(bc, be, smt); } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-radial: 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(" center : %s (rgb %u,%u,%u)\n", centerHex.c_str(), rc, gc, bc); std::printf(" edge : %s (rgb %u,%u,%u)\n", edgeHex.c_str(), re, ge, be); return 0; } else if (std::strcmp(argv[i], "--gen-texture-stripes") == 0 && i + 3 < argc) { // Two-color stripe pattern. Stripe width in pixels, plus // direction (diagonal default, or horizontal/vertical). // Useful for caution tape, marble bands, hazard markers, // and racing-style start/finish flags — patterns that // checker/grid don't capture. std::string outPath = argv[++i]; std::string aHex = argv[++i]; std::string bHex = argv[++i]; int stripePx = 16; std::string dir = "diagonal"; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { stripePx = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { std::string d = argv[i + 1]; std::transform(d.begin(), d.end(), d.begin(), [](unsigned char c) { return std::tolower(c); }); if (d == "diagonal" || d == "horizontal" || d == "vertical") { dir = d; i++; } } 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 || stripePx < 1 || stripePx > 4096) { std::fprintf(stderr, "gen-texture-stripes: invalid dims (W/H 1..8192, stripe 1..4096)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t ra, ga, ba, rb, gb, bb; if (!parseHex(aHex, ra, ga, ba)) { std::fprintf(stderr, "gen-texture-stripes: '%s' is not a valid hex color\n", aHex.c_str()); return 1; } if (!parseHex(bHex, rb, gb, bb)) { std::fprintf(stderr, "gen-texture-stripes: '%s' is not a valid hex color\n", bHex.c_str()); return 1; } std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { int proj; if (dir == "horizontal") proj = y; else if (dir == "vertical") proj = x; else proj = x + y; bool isA = ((proj / stripePx) & 1) == 0; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = isA ? ra : rb; pixels[i2 + 1] = isA ? ga : gb; pixels[i2 + 2] = isA ? ba : bb; } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-stripes: 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(" direction : %s\n", dir.c_str()); std::printf(" stripe : %d px\n", stripePx); std::printf(" colors : %s + %s\n", aHex.c_str(), bHex.c_str()); return 0; } else if (std::strcmp(argv[i], "--gen-texture-dots") == 0 && i + 3 < argc) { // Polka-dot pattern: solid background with circular dots // on a regular grid. Useful for fabric/clothing textures, // game-board patterns, or quick decorative tiling. std::string outPath = argv[++i]; std::string bgHex = argv[++i]; std::string dotHex = argv[++i]; int radius = 8, spacing = 32; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { radius = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { spacing = std::stoi(argv[++i]); } catch (...) {} } 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 || radius < 1 || radius > 1024 || spacing < 2 || spacing > 4096) { std::fprintf(stderr, "gen-texture-dots: invalid dims (W/H 1..8192, radius 1..1024, spacing 2..4096)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t br, bg, bb, dr, dg, db; if (!parseHex(bgHex, br, bg, bb)) { std::fprintf(stderr, "gen-texture-dots: '%s' is not a valid hex color\n", bgHex.c_str()); return 1; } if (!parseHex(dotHex, dr, dg, db)) { std::fprintf(stderr, "gen-texture-dots: '%s' is not a valid hex color\n", dotHex.c_str()); return 1; } std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); float r2 = static_cast<float>(radius) * radius; for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { // Distance to the nearest grid point. int gx = (x + spacing / 2) / spacing * spacing; int gy = (y + spacing / 2) / spacing * spacing; float dx = static_cast<float>(x - gx); float dy = static_cast<float>(y - gy); bool inDot = (dx * dx + dy * dy) < r2; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = inDot ? dr : br; pixels[i2 + 1] = inDot ? dg : bg; pixels[i2 + 2] = inDot ? db : bb; } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-dots: 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(" bg : %s\n", bgHex.c_str()); std::printf(" dot : %s\n", dotHex.c_str()); std::printf(" radius : %d px\n", radius); std::printf(" spacing : %d px\n", spacing); return 0; } else if (std::strcmp(argv[i], "--gen-texture-rings") == 0 && i + 3 < argc) { // Concentric rings centered on the image. Useful for // archery targets, magic seal floors, dartboards, hypnosis // visuals — anywhere a "circular alternation" reads as // intentional design. std::string outPath = argv[++i]; std::string aHex = argv[++i]; std::string bHex = argv[++i]; int ringPx = 16; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { ringPx = std::stoi(argv[++i]); } catch (...) {} } 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 || ringPx < 1 || ringPx > 4096) { std::fprintf(stderr, "gen-texture-rings: invalid dims (W/H 1..8192, ringPx 1..4096)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t ra, ga, ba, rb, gb, bb; if (!parseHex(aHex, ra, ga, ba)) { std::fprintf(stderr, "gen-texture-rings: '%s' is not a valid hex color\n", aHex.c_str()); return 1; } if (!parseHex(bHex, rb, gb, bb)) { std::fprintf(stderr, "gen-texture-rings: '%s' is not a valid hex color\n", bHex.c_str()); return 1; } std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); float cx = (W - 1) * 0.5f; float cy = (H - 1) * 0.5f; for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { float dx = x - cx; float dy = y - cy; float d = std::sqrt(dx * dx + dy * dy); bool isA = (static_cast<int>(d / ringPx) & 1) == 0; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = isA ? ra : rb; pixels[i2 + 1] = isA ? ga : gb; pixels[i2 + 2] = isA ? ba : bb; } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-rings: 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(" ring px : %d\n", ringPx); std::printf(" colors : %s + %s\n", aHex.c_str(), bHex.c_str()); return 0; } else if (std::strcmp(argv[i], "--gen-texture-checker") == 0 && i + 3 < argc) { // Two-color checkerboard with custom colors. The // existing --gen-texture's "checker" pattern is fixed // black/white at 32px; this is the configurable variant // for game boards, kitchen floors, hazard markers in // colors other than monochrome. std::string outPath = argv[++i]; std::string aHex = argv[++i]; std::string bHex = argv[++i]; int cellPx = 32; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { cellPx = std::stoi(argv[++i]); } catch (...) {} } 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 || cellPx < 1 || cellPx > 4096) { std::fprintf(stderr, "gen-texture-checker: invalid dims (W/H 1..8192, cellPx 1..4096)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t ra, ga, ba, rb, gb, bb; if (!parseHex(aHex, ra, ga, ba)) { std::fprintf(stderr, "gen-texture-checker: '%s' is not a valid hex color\n", aHex.c_str()); return 1; } if (!parseHex(bHex, rb, gb, bb)) { std::fprintf(stderr, "gen-texture-checker: '%s' is not a valid hex color\n", bHex.c_str()); return 1; } std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { bool isA = ((x / cellPx) + (y / cellPx)) % 2 == 0; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = isA ? ra : rb; pixels[i2 + 1] = isA ? ga : gb; pixels[i2 + 2] = isA ? ba : bb; } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-checker: 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(" cell px : %d\n", cellPx); std::printf(" colors : %s + %s\n", aHex.c_str(), bHex.c_str()); return 0; } else if (std::strcmp(argv[i], "--gen-texture-brick") == 0 && i + 3 < argc) { // Brick wall pattern: rectangular bricks with offset rows // (each row shifted by half a brick width) and mortar // lines between. Useful for walls, chimneys, paths, // medieval-zone props. std::string outPath = argv[++i]; std::string brickHex = argv[++i]; std::string mortarHex = argv[++i]; int brickW = 64, brickH = 24, mortarPx = 4; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { brickW = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { brickH = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { mortarPx = std::stoi(argv[++i]); } catch (...) {} } 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 || brickW < 4 || brickW > 4096 || brickH < 4 || brickH > 4096 || mortarPx < 0 || mortarPx > brickH / 2) { std::fprintf(stderr, "gen-texture-brick: invalid dims (W/H 1..8192, brick 4..4096, mortar < brickH/2)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t br, bg, bb_, mr, mg, mb; if (!parseHex(brickHex, br, bg, bb_)) { std::fprintf(stderr, "gen-texture-brick: '%s' is not a valid hex color\n", brickHex.c_str()); return 1; } if (!parseHex(mortarHex, mr, mg, mb)) { std::fprintf(stderr, "gen-texture-brick: '%s' is not a valid hex color\n", mortarHex.c_str()); return 1; } std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); int rowH = brickH; // total row height (brick + mortar) int halfBrick = brickW / 2; for (int y = 0; y < H; ++y) { int row = y / rowH; int yInRow = y % rowH; bool inMortarH = (yInRow < mortarPx); int xOffset = (row & 1) ? halfBrick : 0; for (int x = 0; x < W; ++x) { int xS = (x + xOffset) % brickW; bool inMortarV = (xS < mortarPx); bool isMortar = inMortarH || inMortarV; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = isMortar ? mr : br; pixels[i2 + 1] = isMortar ? mg : bg; pixels[i2 + 2] = isMortar ? mb : bb_; } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-brick: 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(" brick : %d × %d px (%s)\n", brickW, brickH, brickHex.c_str()); std::printf(" mortar : %d px (%s)\n", mortarPx, mortarHex.c_str()); return 0; } else if (std::strcmp(argv[i], "--gen-texture-wood") == 0 && i + 3 < argc) { // Wood grain pattern: vertical streaks of varying width // alternating between light and dark hues, plus a few // pseudo-random "knots" (small dark dots). Suitable for // doors, planks, fences, crates. std::string outPath = argv[++i]; std::string lightHex = argv[++i]; std::string darkHex = argv[++i]; int spacing = 12; // average grain spacing in px uint32_t seed = 1; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { spacing = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } 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 || spacing < 2 || spacing > 256) { std::fprintf(stderr, "gen-texture-wood: invalid dims (W/H 1..8192, spacing 2..256)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t lr, lg, lb, dr, dg, db; if (!parseHex(lightHex, lr, lg, lb)) { std::fprintf(stderr, "gen-texture-wood: '%s' is not a valid hex color\n", lightHex.c_str()); return 1; } if (!parseHex(darkHex, dr, dg, db)) { std::fprintf(stderr, "gen-texture-wood: '%s' is not a valid hex color\n", darkHex.c_str()); return 1; } // Tiny LCG so output is reproducible from `seed` alone // without pulling in <random>. uint32_t state = seed ? seed : 1u; auto next01 = [&state]() -> float { state = state * 1664525u + 1013904223u; return (state >> 8) * (1.0f / 16777216.0f); }; // Pre-compute per-column "darkness" weight by accumulating // grain bands of varying width across the image. A band's // weight bleeds into a few neighbors so transitions feel // soft rather than blocky. std::vector<float> colWeight(W, 0.0f); int x = 0; while (x < W) { int width = spacing + static_cast<int>(next01() * spacing); float weight = next01(); // 0..1 int feather = std::max(1, width / 6); for (int dx = -feather; dx < width + feather; ++dx) { int cx = x + dx; if (cx < 0 || cx >= W) continue; float t = 1.0f; if (dx < 0) t = 1.0f + dx / static_cast<float>(feather); else if (dx >= width) t = 1.0f - (dx - width) / static_cast<float>(feather); colWeight[cx] = std::max(colWeight[cx], weight * t); } x += width; } std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); for (int y = 0; y < H; ++y) { // Slight Y-axis warp so streaks aren't perfectly straight float yWave = std::sin(y * 0.025f) * 1.5f; for (int xi = 0; xi < W; ++xi) { int sx = xi + static_cast<int>(yWave); if (sx < 0) sx = 0; if (sx >= W) sx = W - 1; float w = colWeight[sx]; uint8_t r = static_cast<uint8_t>(lr * (1 - w) + dr * w); uint8_t g = static_cast<uint8_t>(lg * (1 - w) + dg * w); uint8_t b = static_cast<uint8_t>(lb * (1 - w) + db * w); size_t i2 = (static_cast<size_t>(y) * W + xi) * 3; pixels[i2 + 0] = r; pixels[i2 + 1] = g; pixels[i2 + 2] = b; } } // Sprinkle a handful of round "knots" using the same LCG. int knotCount = std::max(1, (W * H) / 32768); for (int k = 0; k < knotCount; ++k) { int kx = static_cast<int>(next01() * W); int ky = static_cast<int>(next01() * H); int radius = 3 + static_cast<int>(next01() * 4); for (int dy = -radius; dy <= radius; ++dy) { for (int dx = -radius; dx <= radius; ++dx) { int px = kx + dx, py = ky + dy; if (px < 0 || py < 0 || px >= W || py >= H) continue; float d = std::sqrt(static_cast<float>(dx * dx + dy * dy)); if (d > radius) continue; float t = 1.0f - d / radius; size_t i2 = (static_cast<size_t>(py) * W + px) * 3; pixels[i2 + 0] = static_cast<uint8_t>(pixels[i2 + 0] * (1 - t) + dr * t); pixels[i2 + 1] = static_cast<uint8_t>(pixels[i2 + 1] * (1 - t) + dg * t); pixels[i2 + 2] = static_cast<uint8_t>(pixels[i2 + 2] * (1 - t) + db * t); } } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-wood: 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(" light/dark: %s / %s\n", lightHex.c_str(), darkHex.c_str()); std::printf(" spacing : %d px\n", spacing); std::printf(" knots : %d\n", knotCount); std::printf(" seed : %u\n", seed); return 0; } else if (std::strcmp(argv[i], "--gen-texture-grass") == 0 && i + 3 < argc) { // Tiling grass texture. Starts from a slightly perturbed // base color (per-pixel jitter so the field doesn't read // as flat), then sprinkles short blade highlights using // the brighter blade color. Density controls roughly // what fraction of pixels get touched by a blade. std::string outPath = argv[++i]; std::string baseHex = argv[++i]; std::string bladeHex = argv[++i]; float density = 0.15f; uint32_t seed = 1; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { density = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } 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 || density < 0.0f || density > 1.0f) { std::fprintf(stderr, "gen-texture-grass: invalid dims (W/H 1..8192, density 0..1)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t br, bg, bb_, gr, gg, gb; if (!parseHex(baseHex, br, bg, bb_)) { std::fprintf(stderr, "gen-texture-grass: '%s' is not a valid hex color\n", baseHex.c_str()); return 1; } if (!parseHex(bladeHex, gr, gg, gb)) { std::fprintf(stderr, "gen-texture-grass: '%s' is not a valid hex color\n", bladeHex.c_str()); return 1; } uint32_t state = seed ? seed : 1u; auto next01 = [&state]() -> float { state = state * 1664525u + 1013904223u; return (state >> 8) * (1.0f / 16777216.0f); }; std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); // Base layer: per-pixel jitter ±10 around the base color. for (int y = 0; y < H; ++y) { for (int xi = 0; xi < W; ++xi) { float j = (next01() - 0.5f) * 20.0f; int r = std::clamp(static_cast<int>(br) + static_cast<int>(j), 0, 255); int g = std::clamp(static_cast<int>(bg) + static_cast<int>(j), 0, 255); int b = std::clamp(static_cast<int>(bb_) + static_cast<int>(j), 0, 255); size_t i2 = (static_cast<size_t>(y) * W + xi) * 3; pixels[i2 + 0] = static_cast<uint8_t>(r); pixels[i2 + 1] = static_cast<uint8_t>(g); pixels[i2 + 2] = static_cast<uint8_t>(b); } } // Blades: short vertical strokes at random positions. // Stroke length 2-5px, alpha-blended toward bladeHex. int strokeCount = static_cast<int>(W * H * density * 0.05f); for (int s = 0; s < strokeCount; ++s) { int sx = static_cast<int>(next01() * W); int sy = static_cast<int>(next01() * H); int slen = 2 + static_cast<int>(next01() * 4); float t = 0.4f + next01() * 0.4f; // blade strength for (int dy = 0; dy < slen; ++dy) { int py = (sy + dy) % H; // wrap so texture tiles int px = sx; size_t i2 = (static_cast<size_t>(py) * W + px) * 3; pixels[i2 + 0] = static_cast<uint8_t>(pixels[i2 + 0] * (1 - t) + gr * t); pixels[i2 + 1] = static_cast<uint8_t>(pixels[i2 + 1] * (1 - t) + gg * t); pixels[i2 + 2] = static_cast<uint8_t>(pixels[i2 + 2] * (1 - t) + gb * t); } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-grass: 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(" base/blade: %s / %s\n", baseHex.c_str(), bladeHex.c_str()); std::printf(" density : %.3f\n", density); std::printf(" blades : %d\n", strokeCount); std::printf(" seed : %u\n", seed); return 0; } else if (std::strcmp(argv[i], "--gen-texture-fabric") == 0 && i + 3 < argc) { // Woven fabric pattern. We model an over/under weave: each // "cell" of size threadPx × threadPx is alternately a warp // (vertical) thread or a weft (horizontal) thread. Within // a thread, brightness shades from edge to center so the // weave reads as 3D yarn rather than flat checkerboard. std::string outPath = argv[++i]; std::string warpHex = argv[++i]; std::string weftHex = argv[++i]; int threadPx = 4; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { threadPx = std::stoi(argv[++i]); } catch (...) {} } 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 || threadPx < 2 || threadPx > 256) { std::fprintf(stderr, "gen-texture-fabric: invalid dims (W/H 1..8192, threadPx 2..256)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t wr, wg, wb, fr, fg, fb; if (!parseHex(warpHex, wr, wg, wb)) { std::fprintf(stderr, "gen-texture-fabric: '%s' is not a valid hex color\n", warpHex.c_str()); return 1; } if (!parseHex(weftHex, fr, fg, fb)) { std::fprintf(stderr, "gen-texture-fabric: '%s' is not a valid hex color\n", weftHex.c_str()); return 1; } std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); for (int y = 0; y < H; ++y) { int cy = y / threadPx; int yInCell = y % threadPx; for (int x = 0; x < W; ++x) { int cx = x / threadPx; int xInCell = x % threadPx; // Plain weave: alternate warp/weft per cell on // a checkerboard. Warp threads run vertically // (so we shade across xInCell), weft threads // run horizontally (shade across yInCell). bool isWarp = ((cx + cy) & 1) == 0; int across = isWarp ? xInCell : yInCell; float t = static_cast<float>(across) / (threadPx - 1); // Center is brighter, edges darker — gives the // illusion of a rounded yarn cross-section. float shade = 1.0f - 0.4f * std::abs(t - 0.5f) * 2.0f; uint8_t r = isWarp ? static_cast<uint8_t>(wr * shade) : static_cast<uint8_t>(fr * shade); uint8_t g = isWarp ? static_cast<uint8_t>(wg * shade) : static_cast<uint8_t>(fg * shade); uint8_t b = isWarp ? static_cast<uint8_t>(wb * shade) : static_cast<uint8_t>(fb * shade); size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = r; pixels[i2 + 1] = g; pixels[i2 + 2] = b; } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-fabric: 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(" warp/weft : %s / %s\n", warpHex.c_str(), weftHex.c_str()); std::printf(" thread px : %d\n", threadPx); return 0; } else if (std::strcmp(argv[i], "--gen-texture-cobble") == 0 && i + 3 < argc) { // Cobblestone street pattern. Each pixel finds its // nearest "stone center" in a perturbed grid (Worley- // style cellular noise) and uses the distance to that // center to draw the stone face vs. mortar gaps. Stones // get small per-stone tint variation so the surface // doesn't read as flat. std::string outPath = argv[++i]; std::string stoneHex = argv[++i]; std::string mortarHex = argv[++i]; int stonePx = 24; uint32_t seed = 1; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { stonePx = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } 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 || stonePx < 8 || stonePx > 512) { std::fprintf(stderr, "gen-texture-cobble: invalid dims (W/H 1..8192, stonePx 8..512)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t sr, sg, sb, mr, mg, mb; if (!parseHex(stoneHex, sr, sg, sb)) { std::fprintf(stderr, "gen-texture-cobble: '%s' is not a valid hex color\n", stoneHex.c_str()); return 1; } if (!parseHex(mortarHex, mr, mg, mb)) { std::fprintf(stderr, "gen-texture-cobble: '%s' is not a valid hex color\n", mortarHex.c_str()); return 1; } // Seeded hash → stone center jitter + per-stone tint. // Hash takes (cellX, cellY, seed) and returns 4 floats // in [0,1): two for offset, two for tint variation. auto hash01 = [seed](int cx, int cy, int comp) -> float { uint32_t h = static_cast<uint32_t>(cx) * 374761393u + static_cast<uint32_t>(cy) * 668265263u + seed * 2147483647u + static_cast<uint32_t>(comp) * 16777619u; h = (h ^ (h >> 13)) * 1274126177u; h = h ^ (h >> 16); return (h >> 8) * (1.0f / 16777216.0f); }; std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); // For each pixel, find min distance among 9 neighboring // jittered cell centers (3x3 around current cell). The // closest center owns the pixel; second-closest sets // mortar boundary distance. for (int y = 0; y < H; ++y) { int cy0 = y / stonePx; for (int x = 0; x < W; ++x) { int cx0 = x / stonePx; float bestD = 1e9f, second = 1e9f; int bestCx = 0, bestCy = 0; for (int dy = -1; dy <= 1; ++dy) { for (int dx = -1; dx <= 1; ++dx) { int cx = cx0 + dx; int cy = cy0 + dy; float jx = (hash01(cx, cy, 0) - 0.5f) * 0.7f; float jy = (hash01(cx, cy, 1) - 0.5f) * 0.7f; float ccx = (cx + 0.5f + jx) * stonePx; float ccy = (cy + 0.5f + jy) * stonePx; float dxp = x - ccx, dyp = y - ccy; float d = std::sqrt(dxp * dxp + dyp * dyp); if (d < bestD) { second = bestD; bestD = d; bestCx = cx; bestCy = cy; } else if (d < second) { second = d; } } } // Pixels close to the boundary (small gap between // closest and second-closest) become mortar. float boundary = second - bestD; float mortarThresh = stonePx * 0.10f; if (boundary < mortarThresh) { size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = mr; pixels[i2 + 1] = mg; pixels[i2 + 2] = mb; } else { // Per-stone tint: ±15% on each channel. float tint = 0.85f + 0.30f * hash01(bestCx, bestCy, 2); // Subtle radial darkening toward edges so // the stone face reads as 3D rounded. float edgeFalloff = std::min(1.0f, (boundary - mortarThresh) / (stonePx * 0.4f)); float shade = (0.7f + 0.3f * edgeFalloff) * tint; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = static_cast<uint8_t>( std::clamp(sr * shade, 0.0f, 255.0f)); pixels[i2 + 1] = static_cast<uint8_t>( std::clamp(sg * shade, 0.0f, 255.0f)); pixels[i2 + 2] = static_cast<uint8_t>( std::clamp(sb * shade, 0.0f, 255.0f)); } } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-cobble: 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(" stone/mortar : %s / %s\n", stoneHex.c_str(), mortarHex.c_str()); std::printf(" stone px : %d\n", stonePx); std::printf(" seed : %u\n", seed); return 0; } else if (std::strcmp(argv[i], "--gen-texture-marble") == 0 && i + 2 < argc) { // Marble pattern via warped sinusoidal veining. The // canonical "marble shader": take a sine wave, warp its // input by smooth multi-octave noise, raise the absolute // value to a high power so the bright vein bands stay // narrow. Result: irregular bright veins on a base color // that tile with octave-driven low-freq variation. std::string outPath = argv[++i]; std::string baseHex = argv[++i]; std::string veinHex = argv[++i]; uint32_t seed = 1; float sharpness = 8.0f; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { sharpness = std::stof(argv[++i]); } catch (...) {} } 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 || sharpness < 1.0f || sharpness > 64.0f) { std::fprintf(stderr, "gen-texture-marble: invalid dims (W/H 1..8192, sharpness 1..64)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t br, bg, bb_, vr, vg, vb; if (!parseHex(baseHex, br, bg, bb_)) { std::fprintf(stderr, "gen-texture-marble: '%s' is not a valid hex color\n", baseHex.c_str()); return 1; } if (!parseHex(veinHex, vr, vg, vb)) { std::fprintf(stderr, "gen-texture-marble: '%s' is not a valid hex color\n", veinHex.c_str()); return 1; } // Cheap multi-octave noise: 4 sin/cos products at // doubling frequencies, seeded phase per octave. Smooth // and tiles imperfectly but for marble we want some // irregularity anyway. float seedF = static_cast<float>(seed); auto warpNoise = [&](float x, float y) -> float { float n = 0.0f; float freq = 0.02f; float amp = 1.0f; float total = 0.0f; for (int o = 0; o < 4; ++o) { n += amp * std::sin(x * freq + seedF * (1.0f + o)) * std::cos(y * freq + seedF * (0.6f + o)); total += amp; freq *= 2.0f; amp *= 0.5f; } return n / total; // -1..1 }; std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { // Warped sine: vein density is sin(turbulent x). // High exponent on |sin| concentrates brightness // into thin bands. float warp = warpNoise(static_cast<float>(x), static_cast<float>(y)); float v = std::sin((x + warp * 80.0f) * 0.07f); float vein = std::pow(std::abs(v), sharpness); uint8_t r = static_cast<uint8_t>(br * (1 - vein) + vr * vein); uint8_t g = static_cast<uint8_t>(bg * (1 - vein) + vg * vein); uint8_t b = static_cast<uint8_t>(bb_ * (1 - vein) + vb * vein); size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = r; pixels[i2 + 1] = g; pixels[i2 + 2] = b; } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-marble: 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(" base/vein : %s / %s\n", baseHex.c_str(), veinHex.c_str()); std::printf(" sharpness : %.1f\n", sharpness); std::printf(" seed : %u\n", seed); return 0; } else if (std::strcmp(argv[i], "--gen-texture-metal") == 0 && i + 2 < argc) { // Brushed-metal pattern. We generate per-pixel white // noise then box-blur it heavily along one axis (the // brush direction) and lightly along the other. Result: // long thin streaks of varying brightness, the visual // signature of brushed steel/aluminum/iron. Apply that // streaky shade as a multiplicative tint on the base // metal color. std::string outPath = argv[++i]; std::string baseHex = argv[++i]; uint32_t seed = 1; std::string orientation = "horizontal"; int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { orientation = argv[++i]; } 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-metal: invalid dims (W/H 1..8192)\n"); return 1; } if (orientation != "horizontal" && orientation != "vertical") { std::fprintf(stderr, "gen-texture-metal: orientation must be horizontal|vertical\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t mr, mg, mb; if (!parseHex(baseHex, mr, mg, mb)) { std::fprintf(stderr, "gen-texture-metal: '%s' is not a valid hex color\n", baseHex.c_str()); return 1; } uint32_t state = seed ? seed : 1u; auto next01 = [&state]() -> float { state = state * 1664525u + 1013904223u; return (state >> 8) * (1.0f / 16777216.0f); }; // Step 1: per-pixel white noise. std::vector<float> noise(static_cast<size_t>(W) * H); for (auto& v : noise) v = next01(); // Step 2: directional blur. For horizontal orientation, // blur strongly in X (long brush strokes) and lightly // in Y (thin variation across strokes). Vertical // orientation flips X and Y. std::vector<float> blurred(noise.size(), 0.0f); int rxLong = (orientation == "horizontal") ? 24 : 2; int ryLong = (orientation == "horizontal") ? 2 : 24; for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { float sum = 0.0f; int n = 0; for (int dy = -ryLong; dy <= ryLong; ++dy) { int py = y + dy; if (py < 0 || py >= H) continue; for (int dx = -rxLong; dx <= rxLong; ++dx) { int px = x + dx; if (px < 0 || px >= W) continue; sum += noise[static_cast<size_t>(py) * W + px]; n++; } } blurred[static_cast<size_t>(y) * W + x] = sum / n; } } // Step 3: stretch contrast back out so the streaks // are visible (blurring narrows the range). float minV = 1.0f, maxV = 0.0f; for (float v : blurred) { minV = std::min(minV, v); maxV = std::max(maxV, v); } float range = std::max(maxV - minV, 1e-6f); std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { float t = (blurred[static_cast<size_t>(y) * W + x] - minV) / range; // Map noise to a multiplicative shade in [0.7, 1.1] // so the metal looks polished but not flat. float shade = 0.7f + t * 0.4f; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = static_cast<uint8_t>( std::clamp(mr * shade, 0.0f, 255.0f)); pixels[i2 + 1] = static_cast<uint8_t>( std::clamp(mg * shade, 0.0f, 255.0f)); pixels[i2 + 2] = static_cast<uint8_t>( std::clamp(mb * shade, 0.0f, 255.0f)); } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-metal: 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(" base color : %s\n", baseHex.c_str()); std::printf(" orientation : %s\n", orientation.c_str()); std::printf(" seed : %u\n", seed); return 0; } else if (std::strcmp(argv[i], "--gen-texture-leather") == 0 && i + 2 < argc) { // Leather grain pattern. Cellular Worley noise where // each "pebble" cell darkens at its boundaries with // its neighbors — the look of fine-grain leather. // Each cell also gets per-cell tint variation so the // surface doesn't read as uniform. std::string outPath = argv[++i]; std::string baseHex = argv[++i]; uint32_t seed = 1; int grainSize = 4; // average pebble cell size in px int W = 256, H = 256; if (i + 1 < argc && argv[i + 1][0] != '-') { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { grainSize = std::stoi(argv[++i]); } catch (...) {} } 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 || grainSize < 2 || grainSize > 64) { std::fprintf(stderr, "gen-texture-leather: invalid dims (W/H 1..8192, grainSize 2..64)\n"); return 1; } auto parseHex = [](std::string hex, uint8_t& r, uint8_t& g, uint8_t& b) -> bool { std::transform(hex.begin(), hex.end(), hex.begin(), [](unsigned char c) { return std::tolower(c); }); if (!hex.empty() && hex[0] == '#') hex.erase(0, 1); auto fromHexC = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; return -1; }; int v[6]; if (hex.size() == 6) { for (int k = 0; k < 6; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[1]); g = static_cast<uint8_t>((v[2] << 4) | v[3]); b = static_cast<uint8_t>((v[4] << 4) | v[5]); return true; } if (hex.size() == 3) { for (int k = 0; k < 3; ++k) { v[k] = fromHexC(hex[k]); if (v[k] < 0) return false; } r = static_cast<uint8_t>((v[0] << 4) | v[0]); g = static_cast<uint8_t>((v[1] << 4) | v[1]); b = static_cast<uint8_t>((v[2] << 4) | v[2]); return true; } return false; }; uint8_t lr, lg, lb; if (!parseHex(baseHex, lr, lg, lb)) { std::fprintf(stderr, "gen-texture-leather: '%s' is not a valid hex color\n", baseHex.c_str()); return 1; } // Per-cell hash (same idea as cobble, but smaller cells). auto hash01 = [seed](int cx, int cy, int comp) -> float { uint32_t h = static_cast<uint32_t>(cx) * 374761393u + static_cast<uint32_t>(cy) * 668265263u + seed * 2147483647u + static_cast<uint32_t>(comp) * 16777619u; h = (h ^ (h >> 13)) * 1274126177u; h = h ^ (h >> 16); return (h >> 8) * (1.0f / 16777216.0f); }; std::vector<uint8_t> pixels(static_cast<size_t>(W) * H * 3, 0); for (int y = 0; y < H; ++y) { int cy0 = y / grainSize; for (int x = 0; x < W; ++x) { int cx0 = x / grainSize; float bestD = 1e9f, second = 1e9f; int bestCx = 0, bestCy = 0; for (int dy = -1; dy <= 1; ++dy) { for (int dx = -1; dx <= 1; ++dx) { int cx = cx0 + dx; int cy = cy0 + dy; float jx = (hash01(cx, cy, 0) - 0.5f) * 0.6f; float jy = (hash01(cx, cy, 1) - 0.5f) * 0.6f; float ccx = (cx + 0.5f + jx) * grainSize; float ccy = (cy + 0.5f + jy) * grainSize; float dxp = x - ccx, dyp = y - ccy; float d = std::sqrt(dxp * dxp + dyp * dyp); if (d < bestD) { second = bestD; bestD = d; bestCx = cx; bestCy = cy; } else if (d < second) { second = d; } } } // Boundary darkness: closer to the cell border // = darker. Scaled by grainSize for resolution // independence. float boundary = (second - bestD) / grainSize; float boundaryShade = std::clamp(boundary * 1.5f, 0.4f, 1.0f); // Per-cell tint: ±15% lightness. float tint = 0.85f + 0.30f * hash01(bestCx, bestCy, 2); float shade = boundaryShade * tint; size_t i2 = (static_cast<size_t>(y) * W + x) * 3; pixels[i2 + 0] = static_cast<uint8_t>( std::clamp(lr * shade, 0.0f, 255.0f)); pixels[i2 + 1] = static_cast<uint8_t>( std::clamp(lg * shade, 0.0f, 255.0f)); pixels[i2 + 2] = static_cast<uint8_t>( std::clamp(lb * shade, 0.0f, 255.0f)); } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "gen-texture-leather: 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(" base color : %s\n", baseHex.c_str()); std::printf(" grain size : %d px\n", grainSize); std::printf(" seed : %u\n", seed); return 0; } else if (std::strcmp(argv[i], "--gen-mesh") == 0 && i + 2 < argc) { // Synthesize a procedural primitive WOM. Generates proper // per-face normals, planar UVs, a bounding box, and a // single batch covering all indices so the model renders // immediately in the editor without further processing. // // Shapes: // cube — 24 verts / 12 tris, axis-aligned, ±size/2 // plane — 4 verts / 2 tris, on XY plane (Z=0), ±size/2 // sphere — UV sphere, 16 segments × 12 stacks, radius=size/2 std::string womBase = argv[++i]; std::string shape = argv[++i]; float size = 1.0f; if (i + 1 < argc && argv[i + 1][0] != '-') { try { size = std::stof(argv[++i]); } catch (...) {} } if (size <= 0.0f) { std::fprintf(stderr, "gen-mesh: size must be positive (got %g)\n", size); return 1; } // Strip .wom if user passed a full filename — saver expects base. if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; // Helper to push a vertex with explicit normal + uv. auto addVertex = [&](float x, float y, float z, float nx, float ny, float nz, float u, float v) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = glm::vec3(x, y, z); vtx.normal = glm::vec3(nx, ny, nz); vtx.texCoord = glm::vec2(u, v); wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; std::string s = shape; std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); }); float h = size * 0.5f; if (s == "cube") { // 6 faces, 4 verts each (so per-face normals are flat). struct Face { float nx, ny, nz; float verts[4][3]; }; Face faces[6] = { { 0, 0, 1, {{-h,-h, h},{ h,-h, h},{ h, h, h},{-h, h, h}}}, // +Z { 0, 0, -1, {{ h,-h,-h},{-h,-h,-h},{-h, h,-h},{ h, h,-h}}}, // -Z { 1, 0, 0, {{ h,-h, h},{ h,-h,-h},{ h, h,-h},{ h, h, h}}}, // +X {-1, 0, 0, {{-h,-h,-h},{-h,-h, h},{-h, h, h},{-h, h,-h}}}, // -X { 0, 1, 0, {{-h, h, h},{ h, h, h},{ h, h,-h},{-h, h,-h}}}, // +Y { 0, -1, 0, {{-h,-h,-h},{ h,-h,-h},{ h,-h, h},{-h,-h, h}}}, // -Y }; float uvs[4][2] = {{0,0},{1,0},{1,1},{0,1}}; for (auto& f : faces) { uint32_t base = static_cast<uint32_t>(wom.vertices.size()); for (int k = 0; k < 4; ++k) { addVertex(f.verts[k][0], f.verts[k][1], f.verts[k][2], f.nx, f.ny, f.nz, uvs[k][0], uvs[k][1]); } wom.indices.push_back(base + 0); wom.indices.push_back(base + 1); wom.indices.push_back(base + 2); wom.indices.push_back(base + 0); wom.indices.push_back(base + 2); wom.indices.push_back(base + 3); } } else if (s == "plane") { addVertex(-h, -h, 0, 0, 0, 1, 0, 0); addVertex( h, -h, 0, 0, 0, 1, 1, 0); addVertex( h, h, 0, 0, 0, 1, 1, 1); addVertex(-h, h, 0, 0, 0, 1, 0, 1); wom.indices = {0, 1, 2, 0, 2, 3}; } else if (s == "sphere") { const int segments = 16; const int stacks = 12; float r = h; for (int st = 0; st <= stacks; ++st) { float v = static_cast<float>(st) / stacks; float phi = v * 3.14159265358979f; float sphi = std::sin(phi), cphi = std::cos(phi); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float theta = u * 2.0f * 3.14159265358979f; float stheta = std::sin(theta), ctheta = std::cos(theta); float nx = sphi * ctheta; float ny = sphi * stheta; float nz = cphi; addVertex(r * nx, r * ny, r * nz, nx, ny, nz, u, v); } } int stride = segments + 1; for (int st = 0; st < stacks; ++st) { for (int sg = 0; sg < segments; ++sg) { uint32_t a = st * stride + sg; uint32_t b = a + 1; uint32_t c = a + stride; uint32_t d = c + 1; wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } } } else if (s == "cylinder") { // Capped cylinder along the Y axis. radius=size/2, // height=size. 24 side segments — smooth enough for // pillars and torches without exploding the vertex // count. UVs: side wraps the texture once around; // caps map [0..1] from a square sampled at the disc. const int segments = 24; float r = h; // Side ring: 2 vertex rows (top, bottom), each with // (segments+1) verts so UV-seam doesn't share verts. for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); // Bottom ring (Y = -h). addVertex(r * ca, -h, r * sa, ca, 0, sa, u, 0); // Top ring (Y = +h). addVertex(r * ca, h, r * sa, ca, 0, sa, u, 1); } // Side quad indices. for (int sg = 0; sg < segments; ++sg) { uint32_t a = sg * 2; uint32_t b = a + 1; uint32_t c = a + 2; uint32_t d = a + 3; wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } // Top cap fan. uint32_t topCenter = static_cast<uint32_t>(wom.vertices.size()); addVertex(0, h, 0, 0, 1, 0, 0.5f, 0.5f); uint32_t topRingStart = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); addVertex(r * ca, h, r * sa, 0, 1, 0, 0.5f + 0.5f * ca, 0.5f + 0.5f * sa); } for (int sg = 0; sg < segments; ++sg) { wom.indices.push_back(topCenter); wom.indices.push_back(topRingStart + sg); wom.indices.push_back(topRingStart + sg + 1); } // Bottom cap fan (winding flipped so normal points -Y). uint32_t botCenter = static_cast<uint32_t>(wom.vertices.size()); addVertex(0, -h, 0, 0, -1, 0, 0.5f, 0.5f); uint32_t botRingStart = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); addVertex(r * ca, -h, r * sa, 0, -1, 0, 0.5f + 0.5f * ca, 0.5f - 0.5f * sa); } for (int sg = 0; sg < segments; ++sg) { wom.indices.push_back(botCenter); wom.indices.push_back(botRingStart + sg + 1); wom.indices.push_back(botRingStart + sg); } } else if (s == "torus") { // Torus around the Y axis. Major radius (ring center // distance from origin) = size/2, minor radius (tube // thickness) = size/8 — the 4:1 ratio reads as a // ring rather than a fat donut. 32 ring segments × 16 // tube segments = ~544 verts / ~1024 tris. const int ringSeg = 32; const int tubeSeg = 16; float R = h; // major radius float r = h * 0.25f; // minor radius (h/4) for (int i2 = 0; i2 <= ringSeg; ++i2) { float u = static_cast<float>(i2) / ringSeg; float theta = u * 2.0f * 3.14159265358979f; float ct = std::cos(theta), st = std::sin(theta); for (int j2 = 0; j2 <= tubeSeg; ++j2) { float v = static_cast<float>(j2) / tubeSeg; float phi = v * 2.0f * 3.14159265358979f; float cp = std::cos(phi), sp = std::sin(phi); // Position on the surface. float x = (R + r * cp) * ct; float y = r * sp; float z = (R + r * cp) * st; // Normal: from the tube center outward. float nx = cp * ct; float ny = sp; float nz = cp * st; addVertex(x, y, z, nx, ny, nz, u, v); } } int stride = tubeSeg + 1; for (int i2 = 0; i2 < ringSeg; ++i2) { for (int j2 = 0; j2 < tubeSeg; ++j2) { uint32_t a = i2 * stride + j2; uint32_t b = a + 1; uint32_t c = a + stride; uint32_t d = c + 1; wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } } } else if (s == "cone") { // Cone with apex at +Y. radius=size/2, height=size. // 24 side segments. Side has smooth radial-ish normals // (slanted up by half the slope angle) for a curved // shaded surface; bottom cap has flat -Y normal. const int segments = 24; float r = h; float H = size; // Slant length used for the side normal Y component. // Side normal direction: (cos(a), nyComponent, sin(a)) // where the slope is r/H per unit of horizontal travel. // Normalize so the normal has unit length. float sideXZScale = H / std::sqrt(H * H + r * r); float sideY = r / std::sqrt(H * H + r * r); // Side ring (apex repeated per segment so each tri has // its own apex vertex with the correct normal). for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); // Base vertex (Y = 0). addVertex(r * ca, 0.0f, r * sa, sideXZScale * ca, sideY, sideXZScale * sa, u, 1.0f); // Apex vertex (Y = H), one per ring step so the // top vertex carries the segment-specific normal. addVertex(0.0f, H, 0.0f, sideXZScale * ca, sideY, sideXZScale * sa, u, 0.0f); } // Side triangle indices. for (int sg = 0; sg < segments; ++sg) { uint32_t base = sg * 2; // Two tris per quad band. The apex collapses to a // point, so really one triangle per segment, but // emitting both keeps the indexing uniform across // the cylinder/cone code paths. uint32_t a = base + 0; // base k uint32_t b = base + 1; // apex k uint32_t c = base + 2; // base k+1 uint32_t d = base + 3; // apex k+1 wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); // Second triangle would be (b,c,d) but b == d at // the apex visually — we still emit it so the // per-vertex normals on b and d shade the joining // seam smoothly. wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } // Bottom cap fan (flat -Y normal). uint32_t botCenter = static_cast<uint32_t>(wom.vertices.size()); addVertex(0.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.5f, 0.5f); uint32_t botRingStart = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); addVertex(r * ca, 0.0f, r * sa, 0.0f, -1.0f, 0.0f, 0.5f + 0.5f * ca, 0.5f - 0.5f * sa); } for (int sg = 0; sg < segments; ++sg) { wom.indices.push_back(botCenter); wom.indices.push_back(botRingStart + sg + 1); wom.indices.push_back(botRingStart + sg); } } else if (s == "ramp") { // Right-triangular prism: a wedge that climbs along // +X. Footprint is size×size on XY (centered on origin // in X, Y from 0 to size); rises from Z=0 at -X to // Z=size at +X. Useful for ramps onto platforms, // simple roof slopes, cliff faces. // // 6 verts × 5 faces = 18 verts so per-face normals // stay flat: top slope, bottom, back-tall, +Y side, // -Y side. Front-short (X = -size/2) is open since // the ramp meets ground there at zero height. // Actually we still emit 5 faces — the "front" edge // is just where slope and ground meet, no separate // face needed. float xMin = -h, xMax = h; float yMin = 0, yMax = size; float zMin = 0, zMax = size; // Faces: top slope (normal = normalize(-1,0,1) since // the slope rises with +X going up, normal points // up-and-back). float slopeLen = std::sqrt(size * size + size * size); float nSlopeX = -size / slopeLen; float nSlopeZ = size / slopeLen; struct Face { float nx, ny, nz; float verts[4][3]; }; Face faces[5] = { // Top sloped quad: from (xMin, yMin, zMin) up to // (xMax, yMin/yMax, zMax) { nSlopeX, 0, nSlopeZ, {{xMin, yMin, zMin},{xMin, yMax, zMin}, {xMax, yMax, zMax},{xMax, yMin, zMax}}}, // Bottom (-Z normal) { 0, 0, -1, {{xMin, yMin, zMin},{xMax, yMin, zMin}, {xMax, yMax, zMin},{xMin, yMax, zMin}}}, // Back-tall vertical wall (+X) { 1, 0, 0, {{xMax, yMin, zMin},{xMax, yMin, zMax}, {xMax, yMax, zMax},{xMax, yMax, zMin}}}, // -Y side triangle (degenerate quad — last 2 verts // collapse to a point — but indexing uniformly is // simpler than a special tri path) { 0, -1, 0, {{xMin, yMin, zMin},{xMax, yMin, zMin}, {xMax, yMin, zMax},{xMax, yMin, zMax}}}, // +Y side triangle (same shape mirrored) { 0, 1, 0, {{xMin, yMax, zMin},{xMax, yMax, zMax}, {xMax, yMax, zMin},{xMax, yMax, zMin}}}, }; float uvs[4][2] = {{0,0},{1,0},{1,1},{0,1}}; for (auto& f : faces) { uint32_t base = static_cast<uint32_t>(wom.vertices.size()); for (int k = 0; k < 4; ++k) { addVertex(f.verts[k][0], f.verts[k][1], f.verts[k][2], f.nx, f.ny, f.nz, uvs[k][0], uvs[k][1]); } wom.indices.push_back(base + 0); wom.indices.push_back(base + 1); wom.indices.push_back(base + 2); wom.indices.push_back(base + 0); wom.indices.push_back(base + 2); wom.indices.push_back(base + 3); } } else { std::fprintf(stderr, "gen-mesh: shape must be cube, plane, sphere, cylinder, torus, cone, or ramp (got '%s')\n", shape.c_str()); return 1; } // Compute bounds from the vertex positions we just emitted. wom.boundMin = glm::vec3(1e30f); wom.boundMax = glm::vec3(-1e30f); for (const auto& v : wom.vertices) { wom.boundMin = glm::min(wom.boundMin, v.position); wom.boundMax = glm::max(wom.boundMax, v.position); } wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; // Single material batch covering everything — keeps the // model immediately renderable. wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); // Empty texture path slot so batch.textureIndex=0 is a // valid index into texturePaths. The user can later set a // real path or run --gen-texture next to it. wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" shape : %s\n", s.c_str()); std::printf(" size : %.3f\n", size); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" indices : %zu (%zu tri%s)\n", wom.indices.size(), wom.indices.size() / 3, wom.indices.size() / 3 == 1 ? "" : "s"); std::printf(" bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-textured") == 0 && i + 3 < argc) { // One-shot composer: --gen-mesh + --gen-texture wired // together so the resulting WOM's texturePaths[0] points // at the freshly-written PNG sidecar. Output is a model // that renders with the synthesized texture out of the // box — useful for prototyping textured props without // chaining three commands by hand. // // The texture is written next to the mesh as // <wom-base>.png // and the WOM's texturePaths[0] is set to that filename // (just the leaf — runtime resolves it relative to the // model's own directory). std::string womBase = argv[++i]; std::string shape = argv[++i]; std::string colorSpec = argv[++i]; std::string sizeArg; if (i + 1 < argc && argv[i + 1][0] != '-') sizeArg = argv[++i]; // Strip .wom if user passed full filename. if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } std::string self = argv[0]; // 1) Mesh. std::string meshCmd = "\"" + self + "\" --gen-mesh \"" + womBase + "\" " + shape; if (!sizeArg.empty()) meshCmd += " " + sizeArg; meshCmd += " >/dev/null 2>&1"; int rc = std::system(meshCmd.c_str()); if (rc != 0) { std::fprintf(stderr, "gen-mesh-textured: gen-mesh step failed (rc=%d)\n", rc); return 1; } // 2) Texture as a PNG sidecar at the mesh's base path. std::string pngPath = womBase + ".png"; std::string texCmd = "\"" + self + "\" --gen-texture \"" + pngPath + "\" \"" + colorSpec + "\" 256 256"; texCmd += " >/dev/null 2>&1"; rc = std::system(texCmd.c_str()); if (rc != 0) { std::fprintf(stderr, "gen-mesh-textured: gen-texture step failed (rc=%d)\n", rc); return 1; } // 3) Load the WOM, set texturePaths[0] to the PNG leaf, // and re-save so the binding is permanent. auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "gen-mesh-textured: cannot load %s.wom after gen-mesh\n", womBase.c_str()); return 1; } std::string pngLeaf = std::filesystem::path(pngPath).filename().string(); if (wom.texturePaths.empty()) { wom.texturePaths.push_back(pngLeaf); } else { wom.texturePaths[0] = pngLeaf; } if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-textured: failed to re-save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom + %s\n", womBase.c_str(), pngPath.c_str()); std::printf(" shape : %s\n", shape.c_str()); std::printf(" color : %s\n", colorSpec.c_str()); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" texture : %s (wired into batch 0)\n", pngLeaf.c_str()); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-stairs") == 0 && i + 2 < argc) { // Procedural straight staircase along +X. N steps with // configurable rise/run/width. Each step is a closed // box, sharing no vertices with neighbors so per-face // normals are flat (looks correct without smoothing). // // Defaults: 5 steps, stepHeight=0.2, stepDepth=0.3, // width=1.0 — roughly 1m tall × 1.5m long × 1m wide, // a believable single flight. // // Useful for level-design placeholders ("I need a staircase // up to this platform"), test-bench geometry for camera/ // movement, and quick prototyping of stepped terrain. std::string womBase = argv[++i]; int steps = 5; float stepHeight = 0.2f, stepDepth = 0.3f, width = 1.0f; try { steps = std::stoi(argv[++i]); } catch (...) { std::fprintf(stderr, "gen-mesh-stairs: <steps> must be an integer\n"); return 1; } if (steps < 1 || steps > 256) { std::fprintf(stderr, "gen-mesh-stairs: steps %d out of range (1..256)\n", steps); return 1; } if (i + 1 < argc && argv[i + 1][0] != '-') { try { stepHeight = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { stepDepth = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { width = std::stof(argv[++i]); } catch (...) {} } if (stepHeight <= 0 || stepDepth <= 0 || width <= 0) { std::fprintf(stderr, "gen-mesh-stairs: dimensions must be positive\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; auto addV = [&](float x, float y, float z, float nx, float ny, float nz, float u, float v) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = glm::vec3(x, y, z); vtx.normal = glm::vec3(nx, ny, nz); vtx.texCoord = glm::vec2(u, v); wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; float halfW = width * 0.5f; // Each step is a box from y=0 to y=(k+1)*stepHeight, // depth-wise from x=k*stepDepth to x=(k+1)*stepDepth, // width-wise from z=-halfW to z=+halfW. Six faces per // step, four verts each = 24 verts / 12 tris per step. for (int k = 0; k < steps; ++k) { float x0 = k * stepDepth; float x1 = (k + 1) * stepDepth; float y0 = 0.0f; float y1 = (k + 1) * stepHeight; float z0 = -halfW; float z1 = halfW; struct Face { float nx, ny, nz; float verts[4][3]; }; Face faces[6] = { { 0, 1, 0, {{x0,y1,z0},{x1,y1,z0},{x1,y1,z1},{x0,y1,z1}}}, // top +Y { 0, -1, 0, {{x0,y0,z0},{x0,y0,z1},{x1,y0,z1},{x1,y0,z0}}}, // bot -Y {-1, 0, 0, {{x0,y0,z0},{x0,y1,z0},{x0,y1,z1},{x0,y0,z1}}}, // back -X { 1, 0, 0, {{x1,y0,z0},{x1,y0,z1},{x1,y1,z1},{x1,y1,z0}}}, // front+X (riser) { 0, 0, -1, {{x0,y0,z0},{x1,y0,z0},{x1,y1,z0},{x0,y1,z0}}}, // -Z { 0, 0, 1, {{x0,y0,z1},{x0,y1,z1},{x1,y1,z1},{x1,y0,z1}}}, // +Z }; float uvs[4][2] = {{0,0},{1,0},{1,1},{0,1}}; for (auto& f : faces) { uint32_t base = static_cast<uint32_t>(wom.vertices.size()); for (int q = 0; q < 4; ++q) { addV(f.verts[q][0], f.verts[q][1], f.verts[q][2], f.nx, f.ny, f.nz, uvs[q][0], uvs[q][1]); } wom.indices.push_back(base + 0); wom.indices.push_back(base + 1); wom.indices.push_back(base + 2); wom.indices.push_back(base + 0); wom.indices.push_back(base + 2); wom.indices.push_back(base + 3); } } wom.boundMin = glm::vec3(1e30f); wom.boundMax = glm::vec3(-1e30f); for (const auto& v : wom.vertices) { wom.boundMin = glm::min(wom.boundMin, v.position); wom.boundMax = glm::max(wom.boundMax, v.position); } wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-stairs: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" steps : %d\n", steps); std::printf(" stepHt : %.3f\n", stepHeight); std::printf(" stepDep : %.3f\n", stepDepth); std::printf(" width : %.3f\n", width); std::printf(" vertices : %zu (%d per step × %d)\n", wom.vertices.size(), 24, steps); std::printf(" triangles : %zu\n", wom.indices.size() / 3); std::printf(" span : %.3fL × %.3fH × %.3fW\n", steps * stepDepth, steps * stepHeight, width); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-grid") == 0 && i + 2 < argc) { // Flat plane subdivided into NxN cells. Useful for LOD // demos, deformable surfaces (later --displace passes), // testbench geometry that needs many triangles. Default // size is 1.0 (centered on origin). Hard cap at N=256 // so a typo doesn't generate a mesh with 130k+ vertices. std::string womBase = argv[++i]; int N = 0; try { N = std::stoi(argv[++i]); } catch (...) { std::fprintf(stderr, "gen-mesh-grid: <subdivisions> must be an integer\n"); return 1; } if (N < 1 || N > 256) { std::fprintf(stderr, "gen-mesh-grid: subdivisions %d out of range (1..256)\n", N); return 1; } float size = 1.0f; if (i + 1 < argc && argv[i + 1][0] != '-') { try { size = std::stof(argv[++i]); } catch (...) {} } if (size <= 0.0f) { std::fprintf(stderr, "gen-mesh-grid: size must be positive\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; // (N+1)x(N+1) vertices on the XY plane centered on origin, // Z=0. Normals all point +Z; UVs are 0..1 across the grid. float halfSize = size * 0.5f; float cellSize = size / N; for (int j = 0; j <= N; ++j) { for (int k = 0; k <= N; ++k) { wowee::pipeline::WoweeModel::Vertex v; v.position = glm::vec3(-halfSize + k * cellSize, -halfSize + j * cellSize, 0.0f); v.normal = glm::vec3(0, 0, 1); v.texCoord = glm::vec2(static_cast<float>(k) / N, static_cast<float>(j) / N); wom.vertices.push_back(v); } } int stride = N + 1; for (int j = 0; j < N; ++j) { for (int k = 0; k < N; ++k) { uint32_t a = j * stride + k; uint32_t b = a + 1; uint32_t c = a + stride; uint32_t d = c + 1; wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } } wom.boundMin = glm::vec3(-halfSize, -halfSize, 0); wom.boundMax = glm::vec3( halfSize, halfSize, 0); wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-grid: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" subdivisions : %d (%dx%d cells)\n", N, N, N); std::printf(" size : %.3f\n", size); std::printf(" vertices : %zu = (N+1)²\n", wom.vertices.size()); std::printf(" triangles : %zu = 2N²\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-disc") == 0 && i + 1 < argc) { // Flat circular disc on XY centered at origin. Center // vertex + ring of <segments> verts, indexed as a fan. // Useful for magic circles, coin meshes, lily pads, top // caps for cylinders the user wants without making a // full cylinder. std::string womBase = argv[++i]; float radius = 1.0f; int segments = 32; if (i + 1 < argc && argv[i + 1][0] != '-') { try { radius = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { segments = std::stoi(argv[++i]); } catch (...) {} } if (radius <= 0.0f || segments < 3 || segments > 1024) { std::fprintf(stderr, "gen-mesh-disc: radius must be positive, segments 3..1024\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; // Center vertex. { wowee::pipeline::WoweeModel::Vertex v; v.position = glm::vec3(0, 0, 0); v.normal = glm::vec3(0, 0, 1); v.texCoord = glm::vec2(0.5f, 0.5f); wom.vertices.push_back(v); } // Ring vertices (one extra at end so UV-seam isn't shared). for (int k = 0; k <= segments; ++k) { float t = static_cast<float>(k) / segments; float ang = t * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); wowee::pipeline::WoweeModel::Vertex v; v.position = glm::vec3(radius * ca, radius * sa, 0); v.normal = glm::vec3(0, 0, 1); v.texCoord = glm::vec2(0.5f + 0.5f * ca, 0.5f + 0.5f * sa); wom.vertices.push_back(v); } // Fan indices. for (int k = 0; k < segments; ++k) { wom.indices.push_back(0); wom.indices.push_back(1 + k); wom.indices.push_back(2 + k); } wom.boundMin = glm::vec3(-radius, -radius, 0); wom.boundMax = glm::vec3( radius, radius, 0); wom.boundRadius = radius; wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-disc: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" radius : %.3f\n", radius); std::printf(" segments : %d\n", segments); std::printf(" vertices : %zu (1 center + %d ring)\n", wom.vertices.size(), segments + 1); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-tube") == 0 && i + 1 < argc) { // Hollow cylinder along Y axis. Outer + inner walls + top // and bottom annular caps. Useful for railings, fence // posts, pipes, hollow logs, ring towers — anywhere a // solid cylinder would feel wrong because you should be // able to see through the middle. std::string womBase = argv[++i]; float outerR = 1.0f; float innerR = 0.7f; float height = 2.0f; int segments = 24; if (i + 1 < argc && argv[i + 1][0] != '-') { try { outerR = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { innerR = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { height = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { segments = std::stoi(argv[++i]); } catch (...) {} } if (outerR <= 0 || innerR <= 0 || innerR >= outerR || height <= 0 || segments < 3 || segments > 1024) { std::fprintf(stderr, "gen-mesh-tube: 0 < innerR < outerR, height > 0, segments 3..1024\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; float h = height * 0.5f; auto addV = [&](float x, float y, float z, float nx, float ny, float nz, float u, float v) { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = glm::vec3(x, y, z); vtx.normal = glm::vec3(nx, ny, nz); vtx.texCoord = glm::vec2(u, v); wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Outer wall: 2 rows × (segments+1) verts, normals point // radially outward. uint32_t outerStart = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); addV(outerR * ca, -h, outerR * sa, ca, 0, sa, u, 0); addV(outerR * ca, h, outerR * sa, ca, 0, sa, u, 1); } for (int sg = 0; sg < segments; ++sg) { uint32_t a = outerStart + sg * 2; uint32_t b = a + 1, c = a + 2, d = a + 3; wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } // Inner wall: normals point radially inward, winding // reversed so the inside-facing surfaces face the viewer // when looking through the tube. uint32_t innerStart = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); addV(innerR * ca, -h, innerR * sa, -ca, 0, -sa, u, 0); addV(innerR * ca, h, innerR * sa, -ca, 0, -sa, u, 1); } for (int sg = 0; sg < segments; ++sg) { uint32_t a = innerStart + sg * 2; uint32_t b = a + 1, c = a + 2, d = a + 3; wom.indices.push_back(a); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(d); wom.indices.push_back(c); } // Top annular cap: ring at +Y. Inner + outer ring of verts, // quads stitched between them, normal +Y. uint32_t topInner = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); addV(innerR * ca, h, innerR * sa, 0, 1, 0, 0.5f + 0.5f * (innerR / outerR) * ca, 0.5f + 0.5f * (innerR / outerR) * sa); } uint32_t topOuter = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); addV(outerR * ca, h, outerR * sa, 0, 1, 0, 0.5f + 0.5f * ca, 0.5f + 0.5f * sa); } for (int sg = 0; sg < segments; ++sg) { uint32_t a = topInner + sg; uint32_t b = topInner + sg + 1; uint32_t c = topOuter + sg; uint32_t d = topOuter + sg + 1; wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } // Bottom annular cap, normal -Y, winding reversed. uint32_t botInner = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); addV(innerR * ca, -h, innerR * sa, 0, -1, 0, 0.5f + 0.5f * (innerR / outerR) * ca, 0.5f - 0.5f * (innerR / outerR) * sa); } uint32_t botOuter = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * 3.14159265358979f; float ca = std::cos(ang), sa = std::sin(ang); addV(outerR * ca, -h, outerR * sa, 0, -1, 0, 0.5f + 0.5f * ca, 0.5f - 0.5f * sa); } for (int sg = 0; sg < segments; ++sg) { uint32_t a = botInner + sg; uint32_t b = botInner + sg + 1; uint32_t c = botOuter + sg; uint32_t d = botOuter + sg + 1; wom.indices.push_back(a); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(d); wom.indices.push_back(c); } wom.boundMin = glm::vec3(-outerR, -h, -outerR); wom.boundMax = glm::vec3( outerR, h, outerR); wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-tube: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" outer R : %.3f\n", outerR); std::printf(" inner R : %.3f\n", innerR); std::printf(" height : %.3f\n", height); std::printf(" segments : %d\n", segments); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-capsule") == 0 && i + 1 < argc) { // Capsule along the Y axis: cylindrical body of length // cylHeight bookended by two hemispheres of radius. Total // height is cylHeight + 2*radius. Useful for character // collision shells, pill-shaped buttons, hot-dog props, // and physics-friendly placeholders. std::string womBase = argv[++i]; float radius = 0.5f; float cylHeight = 1.0f; int segments = 16; int stacks = 8; // per hemisphere if (i + 1 < argc && argv[i + 1][0] != '-') { try { radius = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { cylHeight = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { segments = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { stacks = std::stoi(argv[++i]); } catch (...) {} } if (radius <= 0 || cylHeight < 0 || segments < 3 || segments > 1024 || stacks < 1 || stacks > 256) { std::fprintf(stderr, "gen-mesh-capsule: radius > 0, cylHeight >= 0, segments 3..1024, stacks 1..256\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; float halfBody = cylHeight * 0.5f; float totalH = cylHeight + 2.0f * radius; auto addV = [&](float x, float y, float z, float nx, float ny, float nz, float u, float v) { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = glm::vec3(x, y, z); vtx.normal = glm::vec3(nx, ny, nz); vtx.texCoord = glm::vec2(u, v); wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Top hemisphere: stacks rings from north pole down to // body top. Vertex layout per ring: (segments+1) verts. const float pi = 3.14159265358979f; int totalVPerRing = segments + 1; // Top hemisphere rings: stacks+1 rings (ring 0 is the // pole). v texcoord goes 0..0.25 across the cap. for (int st = 0; st <= stacks; ++st) { float t = static_cast<float>(st) / stacks; float phi = t * (pi * 0.5f); // 0 at pole, π/2 at body float sphi = std::sin(phi), cphi = std::cos(phi); float ringR = radius * sphi; float ringY = halfBody + radius * cphi; for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * pi; float ca = std::cos(ang), sa = std::sin(ang); addV(ringR * ca, ringY, ringR * sa, sphi * ca, cphi, sphi * sa, u, t * 0.25f); } } // Body: 2 rings (top and bottom of cylinder), normal // radial (no Y component). UV goes 0.25..0.75. int bodyTopRingStart = static_cast<int>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * pi; float ca = std::cos(ang), sa = std::sin(ang); addV(radius * ca, halfBody, radius * sa, ca, 0, sa, u, 0.25f); } int bodyBotRingStart = static_cast<int>(wom.vertices.size()); for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * pi; float ca = std::cos(ang), sa = std::sin(ang); addV(radius * ca, -halfBody, radius * sa, ca, 0, sa, u, 0.75f); } // Bottom hemisphere: mirror of top. int botHemiStart = static_cast<int>(wom.vertices.size()); for (int st = 0; st <= stacks; ++st) { float t = static_cast<float>(st) / stacks; float phi = t * (pi * 0.5f); float sphi = std::sin(phi), cphi = std::cos(phi); float ringR = radius * cphi; float ringY = -halfBody - radius * sphi; for (int sg = 0; sg <= segments; ++sg) { float u = static_cast<float>(sg) / segments; float ang = u * 2.0f * pi; float ca = std::cos(ang), sa = std::sin(ang); addV(ringR * ca, ringY, ringR * sa, cphi * ca, -sphi, cphi * sa, u, 0.75f + t * 0.25f); } } // Index the rings: top hemi (stacks rings → stacks-1 // bands), body (1 band), bottom hemi (stacks bands). auto stitch = [&](int topRingStart, int botRingStart) { for (int sg = 0; sg < segments; ++sg) { uint32_t a = topRingStart + sg; uint32_t b = a + 1; uint32_t c = botRingStart + sg; uint32_t d = c + 1; wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } }; // Top hemisphere bands. for (int st = 0; st < stacks; ++st) { stitch(st * totalVPerRing, (st + 1) * totalVPerRing); } // Body band: between bodyTopRingStart and bodyBotRingStart. stitch(bodyTopRingStart, bodyBotRingStart); // Bottom hemisphere bands. for (int st = 0; st < stacks; ++st) { stitch(botHemiStart + st * totalVPerRing, botHemiStart + (st + 1) * totalVPerRing); } wom.boundMin = glm::vec3(-radius, -totalH * 0.5f, -radius); wom.boundMax = glm::vec3( radius, totalH * 0.5f, radius); wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-capsule: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" radius : %.3f\n", radius); std::printf(" cylHeight : %.3f\n", cylHeight); std::printf(" total H : %.3f\n", totalH); std::printf(" segments : %d\n", segments); std::printf(" stacks : %d (per hemisphere)\n", stacks); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-arch") == 0 && i + 1 < argc) { // Doorway/portal arch: two rectangular columns connected // by a semicircular top band. Total width = openingWidth + // 2*thickness; total height = openingHeight + thickness + // archRadius (where archRadius = openingWidth/2). Depth // is the Y-axis thickness (extruded slab). // // Two box columns + curved arch band on top. Useful for // doorways, portal frames, gates. Aligned so the inside // of the opening is centered on the Y axis. std::string womBase = argv[++i]; float openingW = 1.0f, openingH = 1.5f; float thickness = 0.2f; // column thickness (X) float depth = 0.3f; // Y extrusion int segments = 12; // arch curve segments if (i + 1 < argc && argv[i + 1][0] != '-') { try { openingW = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { openingH = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { thickness = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { depth = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { segments = std::stoi(argv[++i]); } catch (...) {} } if (openingW <= 0 || openingH <= 0 || thickness <= 0 || depth <= 0 || segments < 2 || segments > 256) { std::fprintf(stderr, "gen-mesh-arch: dimensions must be positive, segments 2..256\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; // Helper to push a vertex. auto addV = [&](float x, float y, float z, float nx, float ny, float nz, float u, float v) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = glm::vec3(x, y, z); vtx.normal = glm::vec3(nx, ny, nz); vtx.texCoord = glm::vec2(u, v); wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Helper to emit an axis-aligned box from min to max. auto addBox = [&](glm::vec3 lo, glm::vec3 hi) { struct Face { float nx, ny, nz; float verts[4][3]; }; Face faces[6] = { { 0, 0, 1, {{lo.x,lo.y,hi.z},{hi.x,lo.y,hi.z},{hi.x,hi.y,hi.z},{lo.x,hi.y,hi.z}}}, { 0, 0, -1, {{hi.x,lo.y,lo.z},{lo.x,lo.y,lo.z},{lo.x,hi.y,lo.z},{hi.x,hi.y,lo.z}}}, { 1, 0, 0, {{hi.x,lo.y,hi.z},{hi.x,lo.y,lo.z},{hi.x,hi.y,lo.z},{hi.x,hi.y,hi.z}}}, {-1, 0, 0, {{lo.x,lo.y,lo.z},{lo.x,lo.y,hi.z},{lo.x,hi.y,hi.z},{lo.x,hi.y,lo.z}}}, { 0, 1, 0, {{lo.x,hi.y,hi.z},{hi.x,hi.y,hi.z},{hi.x,hi.y,lo.z},{lo.x,hi.y,lo.z}}}, { 0, -1, 0, {{lo.x,lo.y,lo.z},{hi.x,lo.y,lo.z},{hi.x,lo.y,hi.z},{lo.x,lo.y,hi.z}}}, }; float uvs[4][2] = {{0,0},{1,0},{1,1},{0,1}}; for (auto& f : faces) { uint32_t base = static_cast<uint32_t>(wom.vertices.size()); for (int k = 0; k < 4; ++k) { addV(f.verts[k][0], f.verts[k][1], f.verts[k][2], f.nx, f.ny, f.nz, uvs[k][0], uvs[k][1]); } wom.indices.push_back(base + 0); wom.indices.push_back(base + 1); wom.indices.push_back(base + 2); wom.indices.push_back(base + 0); wom.indices.push_back(base + 2); wom.indices.push_back(base + 3); } }; float halfOW = openingW * 0.5f; float halfD = depth * 0.5f; // Left column. addBox(glm::vec3(-halfOW - thickness, -halfD, 0), glm::vec3(-halfOW, halfD, openingH)); // Right column. addBox(glm::vec3(halfOW, -halfD, 0), glm::vec3(halfOW + thickness, halfD, openingH)); // Arch top band: curve from (-halfOW, openingH) through // (0, openingH+halfOW) to (halfOW, openingH). Radius = // halfOW. Outer surface follows the curve, inner surface // is the underside. Built from <segments> bands of 4 // verts each (front + back faces handled per band). float archCenterZ = openingH; float archR = halfOW; float pi = 3.14159265358979f; for (int sg = 0; sg < segments; ++sg) { float t0 = static_cast<float>(sg) / segments; float t1 = static_cast<float>(sg + 1) / segments; float a0 = pi - t0 * pi; // start at 180°, sweep to 0° float a1 = pi - t1 * pi; float c0 = std::cos(a0), s0 = std::sin(a0); float c1 = std::cos(a1), s1 = std::sin(a1); // Outer ring point at angle a. glm::vec3 outer0(archR * c0, 0, archCenterZ + archR * s0); glm::vec3 outer1(archR * c1, 0, archCenterZ + archR * s1); // Inner ring (offset down to be a thin band — we're // making just a bridge across the top, no thickness // for now to keep vertex count tractable). The arch // band is a flat strip from the outer curve down to // the column tops at the SAME XZ — use the column // tops at the band ends. For simplicity, treat the // band as a thin shell along the curve. glm::vec3 outer0b = outer0 + glm::vec3(0, depth, 0); glm::vec3 outer1b = outer1 + glm::vec3(0, depth, 0); // Top face of band (pointing radially outward from // arch center). glm::vec3 n((c0 + c1) * 0.5f, 0, (s0 + s1) * 0.5f); n = glm::normalize(n); uint32_t base = static_cast<uint32_t>(wom.vertices.size()); addV(outer0.x, outer0.y - halfD, outer0.z, n.x, 0, n.z, 0, 0); addV(outer1.x, outer1.y - halfD, outer1.z, n.x, 0, n.z, 1, 0); addV(outer1.x, outer1.y + halfD, outer1.z, n.x, 0, n.z, 1, 1); addV(outer0.x, outer0.y + halfD, outer0.z, n.x, 0, n.z, 0, 1); wom.indices.push_back(base + 0); wom.indices.push_back(base + 1); wom.indices.push_back(base + 2); wom.indices.push_back(base + 0); wom.indices.push_back(base + 2); wom.indices.push_back(base + 3); } wom.boundMin = glm::vec3(1e30f); wom.boundMax = glm::vec3(-1e30f); for (const auto& v : wom.vertices) { wom.boundMin = glm::min(wom.boundMin, v.position); wom.boundMax = glm::max(wom.boundMax, v.position); } wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-arch: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" opening : %.3f W × %.3f H\n", openingW, openingH); std::printf(" thickness : %.3f (column), depth %.3f (Y)\n", thickness, depth); std::printf(" segments : %d (arch curve)\n", segments); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); std::printf(" bounds : (%.2f, %.2f, %.2f) - (%.2f, %.2f, %.2f)\n", wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-pyramid") == 0 && i + 1 < argc) { // N-sided polygonal pyramid with apex at +Y. 4 sides // gives a square pyramid; 3 gives a tetrahedron-like // shape; 8+ approaches a cone. // // Different from --gen-mesh cone: cone has smooth // round sides with per-vertex radial-ish normals; // pyramid has flat per-face normals on N triangular // sides + a flat polygonal base. std::string womBase = argv[++i]; int sides = 4; float baseR = 1.0f; float height = 1.0f; if (i + 1 < argc && argv[i + 1][0] != '-') { try { sides = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { baseR = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { height = std::stof(argv[++i]); } catch (...) {} } if (sides < 3 || sides > 256 || baseR <= 0 || height <= 0) { std::fprintf(stderr, "gen-mesh-pyramid: sides 3..256, baseR > 0, height > 0\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; const float pi = 3.14159265358979f; auto addV = [&](glm::vec3 p, glm::vec3 n, glm::vec2 uv) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = n; vtx.texCoord = uv; wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Build base ring vertices (one per side). std::vector<glm::vec3> basePts; for (int k = 0; k < sides; ++k) { float a = static_cast<float>(k) / sides * 2.0f * pi; basePts.push_back(glm::vec3(baseR * std::cos(a), 0, baseR * std::sin(a))); } glm::vec3 apex(0, height, 0); // Side faces: per-face flat normals (cross of two edges). for (int k = 0; k < sides; ++k) { glm::vec3 a = basePts[k]; glm::vec3 b = basePts[(k + 1) % sides]; glm::vec3 e1 = b - a; glm::vec3 e2 = apex - a; glm::vec3 n = glm::normalize(glm::cross(e1, e2)); float u0 = static_cast<float>(k) / sides; float u1 = static_cast<float>(k + 1) / sides; uint32_t i0 = addV(a, n, glm::vec2(u0, 1)); uint32_t i1 = addV(b, n, glm::vec2(u1, 1)); uint32_t i2 = addV(apex, n, glm::vec2(0.5f * (u0 + u1), 0)); wom.indices.push_back(i0); wom.indices.push_back(i1); wom.indices.push_back(i2); } // Base: fan from a center vertex (normal -Y). uint32_t baseCenter = addV(glm::vec3(0, 0, 0), glm::vec3(0, -1, 0), glm::vec2(0.5f, 0.5f)); uint32_t baseRingStart = static_cast<uint32_t>(wom.vertices.size()); for (int k = 0; k < sides; ++k) { float a = static_cast<float>(k) / sides * 2.0f * pi; addV(basePts[k], glm::vec3(0, -1, 0), glm::vec2(0.5f + 0.5f * std::cos(a), 0.5f - 0.5f * std::sin(a))); } for (int k = 0; k < sides; ++k) { wom.indices.push_back(baseCenter); wom.indices.push_back(baseRingStart + (k + 1) % sides); wom.indices.push_back(baseRingStart + k); } wom.boundMin = glm::vec3(-baseR, 0, -baseR); wom.boundMax = glm::vec3( baseR, height, baseR); wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-pyramid: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" sides : %d\n", sides); std::printf(" base R : %.3f\n", baseR); std::printf(" height : %.3f\n", height); std::printf(" vertices : %zu (%d side tris × 3 + 1 base center + %d base ring)\n", wom.vertices.size(), sides, sides); std::printf(" triangles : %zu (%d sides + %d base)\n", wom.indices.size() / 3, sides, sides); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-fence") == 0 && i + 1 < argc) { // Repeating fence: N square posts along +X spaced // <postSpacing> apart, with two horizontal rails (top // and bottom) connecting consecutive posts. Posts span // from Y=0 up to Y=postHeight; each post is a small box // of width = railThick × 2. // // Useful for fences around plots, pen boundaries, // walkway dividers, garden beds. std::string womBase = argv[++i]; int posts = 5; float spacing = 1.0f; float postH = 1.0f; float rt = 0.05f; // rail/post thickness if (i + 1 < argc && argv[i + 1][0] != '-') { try { posts = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { spacing = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { postH = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { rt = std::stof(argv[++i]); } catch (...) {} } if (posts < 2 || posts > 256 || spacing <= 0 || postH <= 0 || rt <= 0) { std::fprintf(stderr, "gen-mesh-fence: posts 2..256, spacing/height/thick > 0\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; auto addV = [&](glm::vec3 p, glm::vec3 n, glm::vec2 uv) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = n; vtx.texCoord = uv; wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; auto addBox = [&](glm::vec3 lo, glm::vec3 hi) { struct Face { float nx, ny, nz; float verts[4][3]; }; Face faces[6] = { { 0, 1, 0, {{lo.x,hi.y,hi.z},{hi.x,hi.y,hi.z},{hi.x,hi.y,lo.z},{lo.x,hi.y,lo.z}}}, { 0, -1, 0, {{lo.x,lo.y,lo.z},{hi.x,lo.y,lo.z},{hi.x,lo.y,hi.z},{lo.x,lo.y,hi.z}}}, { 0, 0, 1, {{lo.x,lo.y,hi.z},{hi.x,lo.y,hi.z},{hi.x,hi.y,hi.z},{lo.x,hi.y,hi.z}}}, { 0, 0, -1, {{hi.x,lo.y,lo.z},{lo.x,lo.y,lo.z},{lo.x,hi.y,lo.z},{hi.x,hi.y,lo.z}}}, { 1, 0, 0, {{hi.x,lo.y,hi.z},{hi.x,lo.y,lo.z},{hi.x,hi.y,lo.z},{hi.x,hi.y,hi.z}}}, {-1, 0, 0, {{lo.x,lo.y,lo.z},{lo.x,lo.y,hi.z},{lo.x,hi.y,hi.z},{lo.x,hi.y,lo.z}}}, }; float uvs[4][2] = {{0,0},{1,0},{1,1},{0,1}}; for (auto& f : faces) { uint32_t base = static_cast<uint32_t>(wom.vertices.size()); for (int k = 0; k < 4; ++k) { addV(glm::vec3(f.verts[k][0], f.verts[k][1], f.verts[k][2]), glm::vec3(f.nx, f.ny, f.nz), glm::vec2(uvs[k][0], uvs[k][1])); } wom.indices.push_back(base + 0); wom.indices.push_back(base + 1); wom.indices.push_back(base + 2); wom.indices.push_back(base + 0); wom.indices.push_back(base + 2); wom.indices.push_back(base + 3); } }; float postHalfW = rt; // Posts along +X starting at X=0. for (int k = 0; k < posts; ++k) { float cx = k * spacing; addBox(glm::vec3(cx - postHalfW, -postHalfW, 0), glm::vec3(cx + postHalfW, postHalfW, postH)); } // Rails between consecutive posts. Two rails per gap: // top (~80% up) and bottom (~30% up). float topRailZ = postH * 0.8f; float botRailZ = postH * 0.3f; float railHalfH = rt * 0.5f; // rail is thinner than posts for (int k = 0; k + 1 < posts; ++k) { float xL = k * spacing + postHalfW; float xR = (k + 1) * spacing - postHalfW; if (xR <= xL) continue; // posts touching addBox(glm::vec3(xL, -railHalfH, topRailZ - railHalfH), glm::vec3(xR, railHalfH, topRailZ + railHalfH)); addBox(glm::vec3(xL, -railHalfH, botRailZ - railHalfH), glm::vec3(xR, railHalfH, botRailZ + railHalfH)); } // Bounds. wom.boundMin = glm::vec3(-postHalfW, -postHalfW, 0); wom.boundMax = glm::vec3((posts - 1) * spacing + postHalfW, postHalfW, postH); wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-fence: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" posts : %d\n", posts); std::printf(" spacing : %.3f\n", spacing); std::printf(" height : %.3f\n", postH); std::printf(" thickness : %.3f\n", rt); std::printf(" span X : %.3f\n", (posts - 1) * spacing); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-tree") == 0 && i + 1 < argc) { // Procedural tree: cylinder trunk + UV-sphere foliage. // Trunk goes from Y=0 up to Y=trunkHeight; foliage sphere // centered at trunk-top + foliageRadius/2 so the trunk // pokes up into the bottom of the canopy. // // Useful for ambient zone decoration, distant tree // placeholders, magic-grove props. The 15th procedural // primitive — pairs naturally with --add-texture-to-mesh // for trunk-bark and leaf textures (or just one texture // since this is a single-batch mesh). std::string womBase = argv[++i]; float trunkR = 0.1f; float trunkH = 2.0f; float foliR = 0.7f; if (i + 1 < argc && argv[i + 1][0] != '-') { try { trunkR = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { trunkH = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { foliR = std::stof(argv[++i]); } catch (...) {} } if (trunkR <= 0 || trunkH <= 0 || foliR <= 0) { std::fprintf(stderr, "gen-mesh-tree: trunkR / trunkH / foliR must be positive\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; const float pi = 3.14159265358979f; auto addV = [&](glm::vec3 p, glm::vec3 n, glm::vec2 uv) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = n; vtx.texCoord = uv; wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Trunk cylinder: 12 segments, side ring + top + bottom. const int trunkSegs = 12; uint32_t trunkSideStart = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= trunkSegs; ++sg) { float u = static_cast<float>(sg) / trunkSegs; float ang = u * 2.0f * pi; float ca = std::cos(ang), sa = std::sin(ang); addV(glm::vec3(trunkR * ca, 0, trunkR * sa), glm::vec3(ca, 0, sa), glm::vec2(u, 0)); addV(glm::vec3(trunkR * ca, trunkH, trunkR * sa), glm::vec3(ca, 0, sa), glm::vec2(u, 1)); } for (int sg = 0; sg < trunkSegs; ++sg) { uint32_t a = trunkSideStart + sg * 2; uint32_t b = a + 1, c = a + 2, d = a + 3; wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } // Foliage UV sphere: 12 segments × 8 stacks. Center at // (0, trunkH + foliR * 0.7, 0) so the trunk pokes into // the bottom of the canopy. const int fSegs = 12; const int fStacks = 8; float foliCY = trunkH + foliR * 0.7f; uint32_t foliStart = static_cast<uint32_t>(wom.vertices.size()); for (int st = 0; st <= fStacks; ++st) { float v = static_cast<float>(st) / fStacks; float phi = v * pi; float sphi = std::sin(phi), cphi = std::cos(phi); for (int sg = 0; sg <= fSegs; ++sg) { float u = static_cast<float>(sg) / fSegs; float theta = u * 2.0f * pi; float ctheta = std::cos(theta), stheta = std::sin(theta); float nx = sphi * ctheta; float ny = cphi; float nz = sphi * stheta; addV(glm::vec3(foliR * nx, foliCY + foliR * ny, foliR * nz), glm::vec3(nx, ny, nz), glm::vec2(u, v)); } } int fStride = fSegs + 1; for (int st = 0; st < fStacks; ++st) { for (int sg = 0; sg < fSegs; ++sg) { uint32_t a = foliStart + st * fStride + sg; uint32_t b = a + 1; uint32_t c = a + fStride; uint32_t d = c + 1; wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } } wom.boundMin = glm::vec3(-foliR, 0, -foliR); wom.boundMax = glm::vec3( foliR, foliCY + foliR, foliR); wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-tree: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" trunk R : %.3f\n", trunkR); std::printf(" trunk H : %.3f\n", trunkH); std::printf(" foliage R : %.3f\n", foliR); std::printf(" total H : %.3f\n", foliCY + foliR); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-rock") == 0 && i + 1 < argc) { // Procedural boulder. Starts as an octahedron, subdivides // each face N times to get a rounded base, then displaces // each vertex along its outward direction by a smooth // sin/cos noise term controlled by `seed` and `roughness`. // Result is a unique-shaped rock per seed — perfect for // scattering across a zone via random-populate-zone. // // The 16th procedural primitive in the WOM library. std::string womBase = argv[++i]; float radius = 1.0f; float roughness = 0.25f; // 0..1, fraction of radius int subdiv = 2; // 0=8 tris, 1=32, 2=128, 3=512 uint32_t seed = 1; if (i + 1 < argc && argv[i + 1][0] != '-') { try { radius = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { roughness = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { subdiv = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { seed = static_cast<uint32_t>(std::stoul(argv[++i])); } catch (...) {} } if (radius <= 0 || roughness < 0 || roughness > 1 || subdiv < 0 || subdiv > 4) { std::fprintf(stderr, "gen-mesh-rock: radius>0, roughness 0..1, subdiv 0..4\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } // Build sphere via octahedron subdivision. Vertices are // accumulated in unit-length form first, then displaced. std::vector<glm::vec3> sv; // sphere verts (unit) std::vector<glm::uvec3> st; // sphere tris (vertex indices) sv = { { 1, 0, 0}, {-1, 0, 0}, { 0, 1, 0}, { 0,-1, 0}, { 0, 0, 1}, { 0, 0,-1}, }; st = { {0, 2, 4}, {2, 1, 4}, {1, 3, 4}, {3, 0, 4}, {2, 0, 5}, {1, 2, 5}, {3, 1, 5}, {0, 3, 5}, }; // Edge-midpoint cache so shared edges don't duplicate verts. for (int s = 0; s < subdiv; ++s) { std::map<std::pair<uint32_t,uint32_t>, uint32_t> midCache; auto midpoint = [&](uint32_t a, uint32_t b) -> uint32_t { auto key = std::make_pair(std::min(a,b), std::max(a,b)); auto it = midCache.find(key); if (it != midCache.end()) return it->second; glm::vec3 m = glm::normalize((sv[a] + sv[b]) * 0.5f); uint32_t idx = static_cast<uint32_t>(sv.size()); sv.push_back(m); midCache[key] = idx; return idx; }; std::vector<glm::uvec3> next; next.reserve(st.size() * 4); for (auto& tri : st) { uint32_t a = tri.x, b = tri.y, c = tri.z; uint32_t ab = midpoint(a, b); uint32_t bc = midpoint(b, c); uint32_t ca = midpoint(c, a); next.push_back({a, ab, ca}); next.push_back({b, bc, ab}); next.push_back({c, ca, bc}); next.push_back({ab, bc, ca}); } st.swap(next); } // Smooth pseudo-noise displacement. Three orthogonal sin // products give a coherent bumpy surface; phase shift uses // the seed so each value yields a distinct silhouette. float sf = static_cast<float>(seed); auto displace = [&](glm::vec3 p) -> float { float n = std::sin(p.x * 3.1f + sf * 0.91f) * std::sin(p.y * 4.7f + sf * 1.37f) * std::sin(p.z * 5.3f + sf * 0.43f); float n2 = std::sin(p.x * 7.1f + sf * 0.11f) * std::sin(p.y * 8.3f + sf * 2.13f) * std::sin(p.z * 9.7f + sf * 1.91f); return 1.0f + roughness * (0.7f * n + 0.3f * n2); }; wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; std::vector<glm::vec3> finalPos(sv.size()); for (size_t v = 0; v < sv.size(); ++v) { finalPos[v] = sv[v] * (radius * displace(sv[v])); } // Per-vertex normals from triangle face normals (averaged). std::vector<glm::vec3> normals(sv.size(), glm::vec3(0)); for (auto& tri : st) { glm::vec3 a = finalPos[tri.x]; glm::vec3 b = finalPos[tri.y]; glm::vec3 c = finalPos[tri.z]; glm::vec3 fn = glm::normalize(glm::cross(b - a, c - a)); normals[tri.x] += fn; normals[tri.y] += fn; normals[tri.z] += fn; } for (auto& n : normals) n = glm::length(n) > 1e-6f ? glm::normalize(n) : glm::vec3(0, 1, 0); for (size_t v = 0; v < sv.size(); ++v) { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = finalPos[v]; vtx.normal = normals[v]; // Spherical UV unwrap. Visible seam at u=0/1 is // acceptable for rocks — usually hidden by terrain. glm::vec3 d = glm::normalize(sv[v]); vtx.texCoord = { 0.5f + std::atan2(d.z, d.x) / (2.0f * 3.14159265f), 0.5f - std::asin(d.y) / 3.14159265f, }; wom.vertices.push_back(vtx); } for (auto& tri : st) { wom.indices.push_back(tri.x); wom.indices.push_back(tri.y); wom.indices.push_back(tri.z); } float bound = radius * (1.0f + roughness); wom.boundMin = glm::vec3(-bound); wom.boundMax = glm::vec3( bound); wowee::pipeline::WoweeModel::Batch batch; batch.indexStart = 0; batch.indexCount = static_cast<uint32_t>(wom.indices.size()); batch.textureIndex = 0; wom.batches.push_back(batch); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-rock: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" radius : %.3f\n", radius); std::printf(" roughness : %.3f\n", roughness); std::printf(" subdiv : %d\n", subdiv); std::printf(" seed : %u\n", seed); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-pillar") == 0 && i + 1 < argc) { // Procedural classical column. Central shaft is a // cylinder with N concave flutes (radius modulated by // cos²(theta*flutes/2)), capped above and below by // wider disc caps that act as a simple capital and // base. The 17th procedural mesh primitive — useful // for ruins, temples, dungeons, plaza decoration. std::string womBase = argv[++i]; float radius = 0.4f; float height = 4.0f; int flutes = 12; float capScale = 1.25f; if (i + 1 < argc && argv[i + 1][0] != '-') { try { radius = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { height = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { flutes = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { capScale = std::stof(argv[++i]); } catch (...) {} } if (radius <= 0 || height <= 0 || flutes < 4 || flutes > 64 || capScale < 1.0f || capScale > 4.0f) { std::fprintf(stderr, "gen-mesh-pillar: radius>0, height>0, flutes 4..64, capScale 1..4\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } const float pi = 3.14159265358979f; // We use 8 segments per flute so the cosine-modulated // groove resolves smoothly. Vertical: 2 rings (top/bot // of shaft) + cap/base discs. const int radSegs = flutes * 8; const float fluteDepth = radius * 0.12f; float capR = radius * capScale; float capThick = radius * 0.25f; wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; auto addV = [&](glm::vec3 p, glm::vec3 n, glm::vec2 uv) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = n; vtx.texCoord = uv; wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Shaft side ring at given y. radius modulated by flute count. auto buildShaftRing = [&](float y) -> uint32_t { uint32_t start = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= radSegs; ++sg) { float u = static_cast<float>(sg) / radSegs; float ang = u * 2.0f * pi; float c = std::cos(ang * flutes * 0.5f); float r = radius - fluteDepth * (c * c); glm::vec3 p(r * std::cos(ang), y, r * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, glm::normalize(n), glm::vec2(u, y / height)); } return start; }; // Cap/base disc ring (constant radius capR) at given y. auto buildCapRing = [&](float y, float r) -> uint32_t { uint32_t start = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= radSegs; ++sg) { float u = static_cast<float>(sg) / radSegs; float ang = u * 2.0f * pi; glm::vec3 p(r * std::cos(ang), y, r * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, glm::normalize(n), glm::vec2(u, y / height)); } return start; }; // Layout (Y goes up): // capThick: base disc bottom // capThick: base disc top // ...shaft from capThick to height-capThick... // height-capThick: cap disc bottom // height: cap disc top float shaftY0 = capThick; float shaftY1 = height - capThick; uint32_t baseBot = buildCapRing(0.0f, capR); uint32_t baseTop = buildCapRing(shaftY0, capR); uint32_t shaftBot = buildShaftRing(shaftY0); uint32_t shaftTop = buildShaftRing(shaftY1); uint32_t capBot = buildCapRing(shaftY1, capR); uint32_t capTop = buildCapRing(height, capR); // Quad connector helper. auto connect = [&](uint32_t a0, uint32_t a1) { for (int sg = 0; sg < radSegs; ++sg) { uint32_t i00 = a0 + sg; uint32_t i01 = a0 + sg + 1; uint32_t i10 = a1 + sg; uint32_t i11 = a1 + sg + 1; wom.indices.insert(wom.indices.end(), { i00, i10, i01, i01, i10, i11 }); } }; connect(baseBot, baseTop); // base side connect(shaftBot, shaftTop); // shaft connect(capBot, capTop); // cap side // Bottom cap (downward fan), top cap (upward fan). uint32_t bottomCenter = addV({0, 0, 0}, {0, -1, 0}, {0.5f, 0.5f}); uint32_t topCenter = addV({0, height, 0}, {0, 1, 0}, {0.5f, 0.5f}); for (int sg = 0; sg < radSegs; ++sg) { wom.indices.insert(wom.indices.end(), { bottomCenter, baseBot + sg + 1, baseBot + sg }); wom.indices.insert(wom.indices.end(), { topCenter, capTop + sg, capTop + sg + 1 }); } // Annular surfaces where caps meet shaft (top of base disc // out to shaft, etc.). Just connect the two rings — they // sit at the same Y so this looks like a flat ring. connect(baseTop, shaftBot); connect(shaftTop, capBot); wowee::pipeline::WoweeModel::Batch batch; batch.indexStart = 0; batch.indexCount = static_cast<uint32_t>(wom.indices.size()); batch.textureIndex = 0; wom.batches.push_back(batch); wom.boundMin = glm::vec3(-capR, 0, -capR); wom.boundMax = glm::vec3( capR, height, capR); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-pillar: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" radius : %.3f\n", radius); std::printf(" height : %.3f\n", height); std::printf(" flutes : %d\n", flutes); std::printf(" cap scale : %.2fx (capR=%.3f)\n", capScale, capR); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-bridge") == 0 && i + 1 < argc) { // Procedural plank bridge. Deck is N axis-aligned planks // running across the bridge's width with small gaps // between, plus two side rails (top + bottom rails on // posts). Bridge length runs along +X, width is on Z. // The 18th procedural mesh primitive — useful for // river crossings, dungeon catwalks, scenic overlooks. std::string womBase = argv[++i]; float length = 6.0f; // along X float width = 2.0f; // along Z int planks = 6; // plank count across the length float railHeight = 1.0f; // rail height above deck (0 = no rails) if (i + 1 < argc && argv[i + 1][0] != '-') { try { length = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { width = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { planks = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { railHeight = std::stof(argv[++i]); } catch (...) {} } if (length <= 0 || width <= 0 || planks < 1 || planks > 64 || railHeight < 0 || railHeight > 4.0f) { std::fprintf(stderr, "gen-mesh-bridge: length>0, width>0, planks 1..64, rail 0..4\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; // Box helper — builds 24-vert / 12-tri box centered on // (cx, cy, cz) with half-extents (hx, hy, hz). Each face // gets unique vertices so flat-shading works. Indices are // pushed into wom.indices directly. auto addBox = [&](float cx, float cy, float cz, float hx, float hy, float hz) { glm::vec3 c(cx, cy, cz); struct Face { glm::vec3 n; glm::vec3 du, dv; // unit-length axes spanning the face }; Face faces[6] = { {{0, 1, 0}, {1, 0, 0}, {0, 0, 1}}, // top (+Y) {{0,-1, 0}, {1, 0, 0}, {0, 0,-1}}, // bottom (-Y) {{1, 0, 0}, {0, 0, 1}, {0, 1, 0}}, // right (+X) {{-1,0, 0}, {0, 0,-1}, {0, 1, 0}}, // left (-X) {{0, 0, 1}, {-1,0, 0}, {0, 1, 0}}, // front (+Z) {{0, 0,-1}, {1, 0, 0}, {0, 1, 0}}, // back (-Z) }; glm::vec3 ext(hx, hy, hz); for (const Face& f : faces) { glm::vec3 center = c + glm::vec3(f.n.x*hx, f.n.y*hy, f.n.z*hz); glm::vec3 du = glm::vec3(f.du.x*ext.x, f.du.y*ext.y, f.du.z*ext.z); glm::vec3 dv = glm::vec3(f.dv.x*ext.x, f.dv.y*ext.y, f.dv.z*ext.z); uint32_t base = static_cast<uint32_t>(wom.vertices.size()); auto push = [&](glm::vec3 p, float u, float v) { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = f.n; vtx.texCoord = {u, v}; wom.vertices.push_back(vtx); }; push(center - du - dv, 0, 0); push(center + du - dv, 1, 0); push(center + du + dv, 1, 1); push(center - du + dv, 0, 1); wom.indices.insert(wom.indices.end(), { base, base + 1, base + 2, base, base + 2, base + 3 }); } }; // Deck: planks along X, gap = 5% of plank pitch. float plankThickness = 0.08f; float plankPitch = length / planks; float plankWidth = plankPitch * 0.95f; for (int p = 0; p < planks; ++p) { float cx = -length * 0.5f + plankPitch * (p + 0.5f); addBox(cx, plankThickness * 0.5f, 0, plankWidth * 0.5f, plankThickness * 0.5f, width * 0.5f); } // Rails: 2 sides × (top rail + 3 posts) when railHeight > 0 if (railHeight > 0.0f) { float postR = 0.06f; float topRailR = 0.08f; int postCount = 3; float rzOffset = width * 0.5f - postR; for (int side = 0; side < 2; ++side) { float zSign = (side == 0) ? 1.0f : -1.0f; float z = zSign * rzOffset; // Top rail: long thin box spanning length addBox(0, plankThickness + railHeight, z, length * 0.5f, topRailR, topRailR); // Posts evenly spaced for (int p = 0; p < postCount; ++p) { float t = (postCount > 1) ? static_cast<float>(p) / (postCount - 1) : 0.5f; float cx = -length * 0.5f + length * t; if (p == 0) cx += postR; if (p == postCount - 1) cx -= postR; addBox(cx, plankThickness + railHeight * 0.5f, z, postR, railHeight * 0.5f, postR); } } } wowee::pipeline::WoweeModel::Batch batch; batch.indexStart = 0; batch.indexCount = static_cast<uint32_t>(wom.indices.size()); batch.textureIndex = 0; wom.batches.push_back(batch); float maxY = plankThickness + railHeight; wom.boundMin = glm::vec3(-length * 0.5f, 0, -width * 0.5f); wom.boundMax = glm::vec3( length * 0.5f, maxY, width * 0.5f); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-bridge: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" length : %.3f\n", length); std::printf(" width : %.3f\n", width); std::printf(" planks : %d\n", planks); std::printf(" rail H : %.3f%s\n", railHeight, railHeight > 0 ? "" : " (no rails)"); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-tower") == 0 && i + 1 < argc) { // Procedural castle tower. Solid cylindrical shaft with // crenellated battlements ringing the top: alternating // raised "merlons" and gaps. Each merlon is a thin // angular wedge sitting on the top rim. Useful for // keeps, watchtowers, perimeter walls. // // The 19th procedural mesh primitive. std::string womBase = argv[++i]; float radius = 1.5f; float height = 8.0f; int battlements = 8; // merlons around the rim float battlementH = 0.5f; if (i + 1 < argc && argv[i + 1][0] != '-') { try { radius = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { height = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { battlements = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { battlementH = std::stof(argv[++i]); } catch (...) {} } if (radius <= 0 || height <= 0 || battlements < 4 || battlements > 64 || battlementH < 0 || battlementH > 4.0f) { std::fprintf(stderr, "gen-mesh-tower: radius>0, height>0, battlements 4..64, bH 0..4\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; const float pi = 3.14159265358979f; const int radSegs = std::max(24, battlements * 4); auto addV = [&](glm::vec3 p, glm::vec3 n, glm::vec2 uv) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = n; vtx.texCoord = uv; wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Cylinder shaft: side ring at y=0 and y=height. uint32_t botRing = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= radSegs; ++sg) { float u = static_cast<float>(sg) / radSegs; float ang = u * 2.0f * pi; glm::vec3 p(radius * std::cos(ang), 0, radius * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, n, glm::vec2(u, 0)); } uint32_t topRing = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= radSegs; ++sg) { float u = static_cast<float>(sg) / radSegs; float ang = u * 2.0f * pi; glm::vec3 p(radius * std::cos(ang), height, radius * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, n, glm::vec2(u, 1)); } for (int sg = 0; sg < radSegs; ++sg) { wom.indices.insert(wom.indices.end(), { botRing + sg, topRing + sg, botRing + sg + 1, botRing + sg + 1, topRing + sg, topRing + sg + 1 }); } // Top cap (fan toward upward-facing center). uint32_t topCenter = addV({0, height, 0}, {0, 1, 0}, {0.5f, 0.5f}); for (int sg = 0; sg < radSegs; ++sg) { wom.indices.insert(wom.indices.end(), { topCenter, topRing + sg, topRing + sg + 1 }); } // Bottom cap (fan toward downward-facing center). uint32_t botCenter = addV({0, 0, 0}, {0, -1, 0}, {0.5f, 0.5f}); for (int sg = 0; sg < radSegs; ++sg) { wom.indices.insert(wom.indices.end(), { botCenter, botRing + sg + 1, botRing + sg }); } // Battlements: thin curved blocks around the top rim, // half the slots filled (alternating merlon/gap). // Each merlon is approximated by an extruded arc segment // at the wall radius extending outward slightly. if (battlementH > 0.0f) { int merlonSpan = radSegs / battlements; int merlonHalf = std::max(1, merlonSpan / 2); float outerR = radius * 1.05f; float innerR = radius * 0.95f; for (int b = 0; b < battlements; ++b) { int startSeg = b * merlonSpan; // Build 8-vert box-like segment between angles // covering merlonHalf slots (so half the rim is // filled, forming the merlon/gap pattern). float ang0 = 2.0f * pi * static_cast<float>(startSeg) / radSegs; float ang1 = 2.0f * pi * static_cast<float>(startSeg + merlonHalf) / radSegs; glm::vec3 outer0(outerR * std::cos(ang0), 0, outerR * std::sin(ang0)); glm::vec3 outer1(outerR * std::cos(ang1), 0, outerR * std::sin(ang1)); glm::vec3 inner0(innerR * std::cos(ang0), 0, innerR * std::sin(ang0)); glm::vec3 inner1(innerR * std::cos(ang1), 0, innerR * std::sin(ang1)); glm::vec3 yLow(0, height, 0); glm::vec3 yHigh(0, height + battlementH, 0); glm::vec3 norm = glm::normalize( outer0 + outer1 - inner0 - inner1); auto V = [&](glm::vec3 p, glm::vec3 n) { return addV(p, n, {0, 0}); }; // 8 verts: 4 corners × 2 heights uint32_t bbl = V(outer0 + yLow, norm); // bot outer left uint32_t bbr = V(outer1 + yLow, norm); uint32_t btl = V(outer0 + yHigh, norm); // top outer left uint32_t btr = V(outer1 + yHigh, norm); uint32_t ibl = V(inner0 + yLow, -norm); // bot inner left uint32_t ibr = V(inner1 + yLow, -norm); uint32_t itl = V(inner0 + yHigh, -norm); // top inner left uint32_t itr = V(inner1 + yHigh, -norm); // outer face wom.indices.insert(wom.indices.end(), {bbl, btl, bbr, bbr, btl, btr}); // inner face wom.indices.insert(wom.indices.end(), {ibr, itr, ibl, ibl, itr, itl}); // top face wom.indices.insert(wom.indices.end(), {btl, itl, btr, btr, itl, itr}); // left and right end caps wom.indices.insert(wom.indices.end(), {bbl, ibl, btl, btl, ibl, itl}); wom.indices.insert(wom.indices.end(), {bbr, btr, ibr, ibr, btr, itr}); } } wowee::pipeline::WoweeModel::Batch batch; batch.indexStart = 0; batch.indexCount = static_cast<uint32_t>(wom.indices.size()); batch.textureIndex = 0; wom.batches.push_back(batch); float maxY = height + battlementH; float maxR = radius * 1.05f; wom.boundMin = glm::vec3(-maxR, 0, -maxR); wom.boundMax = glm::vec3( maxR, maxY, maxR); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-tower: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" radius : %.3f\n", radius); std::printf(" height : %.3f\n", height); std::printf(" battlements : %d (%.3fm tall)\n", battlements, battlementH); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-house") == 0 && i + 1 < argc) { // Simple procedural house: cube body + pyramid roof // meeting at a central apex above the body's roofline. // The pyramid sits flush on the body so the eaves // line up with the wall edges. No door cutout — that // can be added later via mesh boolean ops or texture. // // The 20th procedural mesh primitive. std::string womBase = argv[++i]; float width = 4.0f; // along X float depth = 4.0f; // along Z float height = 3.0f; // wall height (Y) float roofH = 2.0f; // pyramid above walls if (i + 1 < argc && argv[i + 1][0] != '-') { try { width = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { depth = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { height = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { roofH = std::stof(argv[++i]); } catch (...) {} } if (width <= 0 || depth <= 0 || height <= 0 || roofH < 0 || roofH > 20.0f) { std::fprintf(stderr, "gen-mesh-house: width/depth/height>0, roof 0..20\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; auto addV = [&](glm::vec3 p, glm::vec3 n, glm::vec2 uv) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = n; vtx.texCoord = uv; wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; float hx = width * 0.5f; float hz = depth * 0.5f; // 4 walls — each a quad with an outward-facing normal so // the house reads as solid even with backface culling on. struct Wall { glm::vec3 a, b, c, d; // CCW from outside glm::vec3 n; }; Wall walls[4] = { {{ hx, 0, hz}, {-hx, 0, hz}, {-hx, height, hz}, { hx, height, hz}, { 0, 0, 1}}, // +Z {{-hx, 0, -hz}, { hx, 0, -hz}, { hx, height, -hz}, {-hx, height, -hz}, { 0, 0, -1}}, // -Z {{ hx, 0, -hz}, { hx, 0, hz}, { hx, height, hz}, { hx, height, -hz}, { 1, 0, 0}}, // +X {{-hx, 0, hz}, {-hx, 0, -hz}, {-hx, height, -hz}, {-hx, height, hz}, {-1, 0, 0}}, // -X }; for (const Wall& w : walls) { uint32_t a = addV(w.a, w.n, {0, 0}); uint32_t b = addV(w.b, w.n, {1, 0}); uint32_t c = addV(w.c, w.n, {1, 1}); uint32_t d = addV(w.d, w.n, {0, 1}); wom.indices.insert(wom.indices.end(), {a, b, c, a, c, d}); } // Floor (single quad, normal-down so it shows from below; // texturable as a foundation slab). { uint32_t a = addV({-hx, 0, -hz}, {0, -1, 0}, {0, 0}); uint32_t b = addV({ hx, 0, -hz}, {0, -1, 0}, {1, 0}); uint32_t c = addV({ hx, 0, hz}, {0, -1, 0}, {1, 1}); uint32_t d = addV({-hx, 0, hz}, {0, -1, 0}, {0, 1}); wom.indices.insert(wom.indices.end(), {a, c, b, a, d, c}); } // Roof: 4 triangles meeting at central apex. float apexY = height + roofH; glm::vec3 apex(0, apexY, 0); // Eave corners (Y = wall height) — each triangle shares // two adjacent corners + the apex. Per-face normal is // computed once so flat shading works. glm::vec3 eaves[4] = { {-hx, height, hz}, { hx, height, hz}, { hx, height, -hz}, {-hx, height, -hz}, }; for (int s = 0; s < 4; ++s) { glm::vec3 e0 = eaves[s]; glm::vec3 e1 = eaves[(s + 1) % 4]; glm::vec3 fn = glm::normalize(glm::cross(e1 - e0, apex - e0)); uint32_t a = addV(e0, fn, {0, 0}); uint32_t b = addV(e1, fn, {1, 0}); uint32_t c = addV(apex, fn, {0.5f, 1}); wom.indices.insert(wom.indices.end(), {a, b, c}); } wowee::pipeline::WoweeModel::Batch batch; batch.indexStart = 0; batch.indexCount = static_cast<uint32_t>(wom.indices.size()); batch.textureIndex = 0; wom.batches.push_back(batch); wom.boundMin = glm::vec3(-hx, 0, -hz); wom.boundMax = glm::vec3( hx, apexY, hz); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-house: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" width : %.3f\n", width); std::printf(" depth : %.3f\n", depth); std::printf(" wall H : %.3f\n", height); std::printf(" roof H : %.3f (apex %.3f)\n", roofH, apexY); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-fountain") == 0 && i + 1 < argc) { // Procedural fountain: low cylindrical basin with a // narrower spout column rising from its center. Solid // basin (not hollow) for simplicity — readable as a // fountain because of the spout silhouette. Useful for // town squares, plazas, garden centerpieces. // // The 21st procedural mesh primitive. std::string womBase = argv[++i]; float basinR = 1.5f; float basinH = 0.5f; float spoutR = 0.2f; float spoutH = 1.5f; if (i + 1 < argc && argv[i + 1][0] != '-') { try { basinR = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { basinH = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { spoutR = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { spoutH = std::stof(argv[++i]); } catch (...) {} } if (basinR <= 0 || basinH <= 0 || spoutR <= 0 || spoutH <= 0 || spoutR >= basinR) { std::fprintf(stderr, "gen-mesh-fountain: all dims > 0; spoutR must be < basinR\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; const float pi = 3.14159265358979f; const int segs = 24; auto addV = [&](glm::vec3 p, glm::vec3 n, glm::vec2 uv) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = n; vtx.texCoord = uv; wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Cylinder helper: build side ring + caps from y0 to y1 // at given radius. Returns when done; indices appended // directly. Side ring is 2× (segs+1) verts at y0 then y1. auto cylinder = [&](float r, float y0, float y1) { uint32_t bot = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segs; ++sg) { float u = static_cast<float>(sg) / segs; float ang = u * 2.0f * pi; glm::vec3 p(r * std::cos(ang), y0, r * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, n, glm::vec2(u, 0)); } uint32_t top = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segs; ++sg) { float u = static_cast<float>(sg) / segs; float ang = u * 2.0f * pi; glm::vec3 p(r * std::cos(ang), y1, r * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, n, glm::vec2(u, 1)); } for (int sg = 0; sg < segs; ++sg) { wom.indices.insert(wom.indices.end(), { bot + sg, top + sg, bot + sg + 1, bot + sg + 1, top + sg, top + sg + 1 }); } // Top cap (faces +Y) uint32_t topC = addV({0, y1, 0}, {0, 1, 0}, {0.5f, 0.5f}); for (int sg = 0; sg < segs; ++sg) { wom.indices.insert(wom.indices.end(), {topC, top + sg, top + sg + 1}); } // Bottom cap (faces -Y) uint32_t botC = addV({0, y0, 0}, {0, -1, 0}, {0.5f, 0.5f}); for (int sg = 0; sg < segs; ++sg) { wom.indices.insert(wom.indices.end(), {botC, bot + sg + 1, bot + sg}); } }; // Basin: cylinder from y=0 to y=basinH at basinR. cylinder(basinR, 0.0f, basinH); // Spout: cylinder from y=basinH to y=basinH+spoutH at spoutR. cylinder(spoutR, basinH, basinH + spoutH); wowee::pipeline::WoweeModel::Batch batch; batch.indexStart = 0; batch.indexCount = static_cast<uint32_t>(wom.indices.size()); batch.textureIndex = 0; wom.batches.push_back(batch); float maxY = basinH + spoutH; wom.boundMin = glm::vec3(-basinR, 0, -basinR); wom.boundMax = glm::vec3( basinR, maxY, basinR); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-fountain: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" basin : R=%.3f H=%.3f\n", basinR, basinH); std::printf(" spout : R=%.3f H=%.3f\n", spoutR, spoutH); std::printf(" total H : %.3f\n", maxY); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles: %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-statue") == 0 && i + 1 < argc) { // Humanoid placeholder: square pedestal block + tall // narrow body cylinder + head sphere. The silhouette // reads as a statue without needing limbs. Useful for // monuments, hero statues, plaza centerpieces, religious // shrines. // // The 22nd procedural mesh primitive. std::string womBase = argv[++i]; float pedSize = 1.0f; // pedestal width and depth float bodyH = 2.5f; // body cylinder height float headR = 0.4f; // head sphere radius if (i + 1 < argc && argv[i + 1][0] != '-') { try { pedSize = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { bodyH = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { headR = std::stof(argv[++i]); } catch (...) {} } if (pedSize <= 0 || bodyH <= 0 || headR <= 0) { std::fprintf(stderr, "gen-mesh-statue: all dims must be positive\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; const float pi = 3.14159265358979f; auto addV = [&](glm::vec3 p, glm::vec3 n, glm::vec2 uv) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = n; vtx.texCoord = uv; wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Pedestal: low square block (24 unique verts). float pedH = pedSize * 0.4f; float hp = pedSize * 0.5f; { struct Face { glm::vec3 n, du, dv; }; Face faces[6] = { {{0, 1, 0}, {1, 0, 0}, {0, 0, 1}}, {{0,-1, 0}, {1, 0, 0}, {0, 0,-1}}, {{1, 0, 0}, {0, 0, 1}, {0, 1, 0}}, {{-1,0, 0}, {0, 0,-1}, {0, 1, 0}}, {{0, 0, 1}, {-1,0, 0}, {0, 1, 0}}, {{0, 0,-1}, {1, 0, 0}, {0, 1, 0}}, }; glm::vec3 c(0, pedH * 0.5f, 0); glm::vec3 ext(hp, pedH * 0.5f, hp); for (const Face& f : faces) { glm::vec3 center = c + glm::vec3(f.n.x*ext.x, f.n.y*ext.y, f.n.z*ext.z); glm::vec3 du = glm::vec3(f.du.x*ext.x, f.du.y*ext.y, f.du.z*ext.z); glm::vec3 dv = glm::vec3(f.dv.x*ext.x, f.dv.y*ext.y, f.dv.z*ext.z); uint32_t base = static_cast<uint32_t>(wom.vertices.size()); addV(center - du - dv, f.n, {0, 0}); addV(center + du - dv, f.n, {1, 0}); addV(center + du + dv, f.n, {1, 1}); addV(center - du + dv, f.n, {0, 1}); wom.indices.insert(wom.indices.end(), {base, base + 1, base + 2, base, base + 2, base + 3}); } } // Body cylinder from y=pedH to y=pedH+bodyH at radius pedSize*0.2 float bodyR = pedSize * 0.2f; float bodyY0 = pedH; float bodyY1 = pedH + bodyH; const int segs = 16; uint32_t bodyBot = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segs; ++sg) { float u = static_cast<float>(sg) / segs; float ang = u * 2.0f * pi; glm::vec3 p(bodyR * std::cos(ang), bodyY0, bodyR * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, n, {u, 0}); } uint32_t bodyTop = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segs; ++sg) { float u = static_cast<float>(sg) / segs; float ang = u * 2.0f * pi; glm::vec3 p(bodyR * std::cos(ang), bodyY1, bodyR * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, n, {u, 1}); } for (int sg = 0; sg < segs; ++sg) { wom.indices.insert(wom.indices.end(), { bodyBot + sg, bodyTop + sg, bodyBot + sg + 1, bodyBot + sg + 1, bodyTop + sg, bodyTop + sg + 1 }); } // Head sphere centered above body. UV-sphere with 16 // longitude × 12 latitude segments. float headY = bodyY1 + headR; const int headLon = 16; const int headLat = 12; uint32_t headStart = static_cast<uint32_t>(wom.vertices.size()); for (int la = 0; la <= headLat; ++la) { float v = static_cast<float>(la) / headLat; float phi = v * pi; // 0..pi float sphi = std::sin(phi), cphi = std::cos(phi); for (int lo = 0; lo <= headLon; ++lo) { float u = static_cast<float>(lo) / headLon; float theta = u * 2.0f * pi; glm::vec3 dir(sphi * std::cos(theta), cphi, sphi * std::sin(theta)); glm::vec3 p = glm::vec3(0, headY, 0) + dir * headR; addV(p, dir, {u, v}); } } int rowSize = headLon + 1; for (int la = 0; la < headLat; ++la) { for (int lo = 0; lo < headLon; ++lo) { uint32_t i00 = headStart + la * rowSize + lo; uint32_t i01 = headStart + la * rowSize + lo + 1; uint32_t i10 = headStart + (la + 1) * rowSize + lo; uint32_t i11 = headStart + (la + 1) * rowSize + lo + 1; wom.indices.insert(wom.indices.end(), {i00, i10, i01, i01, i10, i11}); } } wowee::pipeline::WoweeModel::Batch batch; batch.indexStart = 0; batch.indexCount = static_cast<uint32_t>(wom.indices.size()); batch.textureIndex = 0; wom.batches.push_back(batch); float maxY = headY + headR; wom.boundMin = glm::vec3(-hp, 0, -hp); wom.boundMax = glm::vec3( hp, maxY, hp); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-statue: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" pedestal : %.3f × %.3f × %.3f\n", pedSize, pedH, pedSize); std::printf(" body : R=%.3f H=%.3f\n", bodyR, bodyH); std::printf(" head : R=%.3f\n", headR); std::printf(" total H : %.3f\n", maxY); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-altar") == 0 && i + 1 < argc) { // Round altar: stack of N stepped cylindrical discs, // each one wider and shorter than the next so the // silhouette descends like a wedding cake. Top disc is // the altar surface (where offerings would go); base // discs widen out to anchor the structure visually. // // The 23rd procedural mesh primitive — pairs naturally // with --gen-texture-marble for a temple aesthetic. std::string womBase = argv[++i]; float topR = 0.7f; // top altar disc radius float topH = 0.3f; // top altar disc height int steps = 3; // base steps below the top float stepStride = 0.3f; // each step grows R by this much, shrinks H if (i + 1 < argc && argv[i + 1][0] != '-') { try { topR = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { topH = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { steps = std::stoi(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { stepStride = std::stof(argv[++i]); } catch (...) {} } if (topR <= 0 || topH <= 0 || steps < 0 || steps > 16 || stepStride <= 0 || stepStride > 5.0f) { std::fprintf(stderr, "gen-mesh-altar: topR/topH > 0, steps 0..16, stride 0..5\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; const float pi = 3.14159265358979f; const int segs = 24; auto addV = [&](glm::vec3 p, glm::vec3 n, glm::vec2 uv) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = n; vtx.texCoord = uv; wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Build a cylindrical disc from y0 to y1 at radius r. // Side ring + top cap (faces +Y). Bottom of each disc // is hidden by the next disc below, so we skip a bottom // cap on all discs except the last (saves ~24 tris/disc). auto disc = [&](float r, float y0, float y1, bool capBottom) { uint32_t bot = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segs; ++sg) { float u = static_cast<float>(sg) / segs; float ang = u * 2.0f * pi; glm::vec3 p(r * std::cos(ang), y0, r * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, n, {u, 0}); } uint32_t top = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= segs; ++sg) { float u = static_cast<float>(sg) / segs; float ang = u * 2.0f * pi; glm::vec3 p(r * std::cos(ang), y1, r * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, n, {u, 1}); } for (int sg = 0; sg < segs; ++sg) { wom.indices.insert(wom.indices.end(), { bot + sg, top + sg, bot + sg + 1, bot + sg + 1, top + sg, top + sg + 1 }); } // Top cap fan (faces +Y). uint32_t tc = addV({0, y1, 0}, {0, 1, 0}, {0.5f, 0.5f}); for (int sg = 0; sg < segs; ++sg) { wom.indices.insert(wom.indices.end(), {tc, top + sg, top + sg + 1}); } if (capBottom) { uint32_t bc = addV({0, y0, 0}, {0, -1, 0}, {0.5f, 0.5f}); for (int sg = 0; sg < segs; ++sg) { wom.indices.insert(wom.indices.end(), {bc, bot + sg + 1, bot + sg}); } } }; // Build bottom-up so y0 starts at floor and tops stack. // Step k (k=0 is bottom-most) has radius = topR + (steps-k)*stride // and height = topH * (1 - 0.2 * k). Y position accumulates. float curY = 0.0f; for (int k = steps - 1; k >= 0; --k) { // bottom step first float r = topR + (k + 1) * stepStride; float h = topH * (1.0f - 0.2f * k); if (h < topH * 0.4f) h = topH * 0.4f; bool isBottom = (k == steps - 1); disc(r, curY, curY + h, isBottom); curY += h; } // Top disc (the actual altar surface) disc(topR, curY, curY + topH, steps == 0); float maxY = curY + topH; wowee::pipeline::WoweeModel::Batch batch; batch.indexStart = 0; batch.indexCount = static_cast<uint32_t>(wom.indices.size()); batch.textureIndex = 0; wom.batches.push_back(batch); float maxR = topR + steps * stepStride; wom.boundMin = glm::vec3(-maxR, 0, -maxR); wom.boundMax = glm::vec3( maxR, maxY, maxR); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-altar: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" top : R=%.3f H=%.3f\n", topR, topH); std::printf(" steps : %d (stride %.3f)\n", steps, stepStride); std::printf(" base R : %.3f\n", maxR); std::printf(" total H : %.3f\n", maxY); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles: %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-portal") == 0 && i + 1 < argc) { // Doorway portal: two vertical post boxes plus a // horizontal lintel box across the top. Posts run along // the Z axis (so width spans Z), opening faces +X. The // gap between the posts is the actual doorway. Useful // for entrances, gates, magical portals, ruins. // // The 24th procedural mesh primitive. std::string womBase = argv[++i]; float width = 2.5f; // outer-to-outer along Z float height = 4.0f; // total Y float postThick = 0.4f; // post width in X and Z float lintelH = 0.5f; // top lintel height (Y) if (i + 1 < argc && argv[i + 1][0] != '-') { try { width = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { height = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { postThick = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { lintelH = std::stof(argv[++i]); } catch (...) {} } if (width <= 0 || height <= 0 || postThick <= 0 || lintelH < 0 || postThick * 2 >= width || lintelH > height) { std::fprintf(stderr, "gen-mesh-portal: posts must fit inside width; lintel <= height\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; // Box helper — same pattern as other multi-box meshes. auto addBox = [&](float cx, float cy, float cz, float hx, float hy, float hz) { struct Face { glm::vec3 n, du, dv; }; Face faces[6] = { {{0, 1, 0}, {1, 0, 0}, {0, 0, 1}}, {{0,-1, 0}, {1, 0, 0}, {0, 0,-1}}, {{1, 0, 0}, {0, 0, 1}, {0, 1, 0}}, {{-1,0, 0}, {0, 0,-1}, {0, 1, 0}}, {{0, 0, 1}, {-1,0, 0}, {0, 1, 0}}, {{0, 0,-1}, {1, 0, 0}, {0, 1, 0}}, }; glm::vec3 c(cx, cy, cz); glm::vec3 ext(hx, hy, hz); for (const Face& f : faces) { glm::vec3 center = c + glm::vec3(f.n.x*hx, f.n.y*hy, f.n.z*hz); glm::vec3 du = glm::vec3(f.du.x*ext.x, f.du.y*ext.y, f.du.z*ext.z); glm::vec3 dv = glm::vec3(f.dv.x*ext.x, f.dv.y*ext.y, f.dv.z*ext.z); uint32_t base = static_cast<uint32_t>(wom.vertices.size()); auto push = [&](glm::vec3 p, float u, float v) { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = f.n; vtx.texCoord = {u, v}; wom.vertices.push_back(vtx); }; push(center - du - dv, 0, 0); push(center + du - dv, 1, 0); push(center + du + dv, 1, 1); push(center - du + dv, 0, 1); wom.indices.insert(wom.indices.end(), {base, base + 1, base + 2, base, base + 2, base + 3}); } }; // Two posts at z = ±(width/2 - postThick/2). Each // post extends from y=0 to y=height-lintelH so it // tucks under the lintel. float postY = (height - lintelH) * 0.5f; float postHy = (height - lintelH) * 0.5f; float postZ = (width - postThick) * 0.5f; float postHt = postThick * 0.5f; addBox(0, postY, postZ, postHt, postHy, postHt); addBox(0, postY, -postZ, postHt, postHy, postHt); // Lintel: spans full width across the top, same X // thickness as posts. if (lintelH > 0.0f) { float lintelY = height - lintelH * 0.5f; addBox(0, lintelY, 0, postHt, lintelH * 0.5f, width * 0.5f); } wowee::pipeline::WoweeModel::Batch batch; batch.indexStart = 0; batch.indexCount = static_cast<uint32_t>(wom.indices.size()); batch.textureIndex = 0; wom.batches.push_back(batch); wom.boundMin = glm::vec3(-postHt, 0, -width * 0.5f); wom.boundMax = glm::vec3( postHt, height, width * 0.5f); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-portal: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" width : %.3f\n", width); std::printf(" height : %.3f\n", height); std::printf(" post thick : %.3f\n", postThick); std::printf(" lintel H : %.3f%s\n", lintelH, lintelH > 0 ? "" : " (no lintel)"); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-archway") == 0 && i + 1 < argc) { // Semicircular arched doorway. Two cylindrical pillars // hold up a curved keystone vault: the vault is a series // of N angular wedge segments tracing a half-circle from // pillar-top to pillar-top. The opening is the empty // semicircular space below. // // The 25th procedural mesh primitive — the "fancier" // sibling of --gen-mesh-portal which uses a flat lintel. std::string womBase = argv[++i]; float width = 3.0f; // outer-to-outer pillar centers along Z float pillarH = 3.0f; // pillar height (Y) float thickness = 0.4f; // pillar radius and arch radial thickness int archSegs = 12; // segments around the half-circle if (i + 1 < argc && argv[i + 1][0] != '-') { try { width = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { pillarH = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { thickness = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { archSegs = std::stoi(argv[++i]); } catch (...) {} } if (width <= 0 || pillarH <= 0 || thickness <= 0 || archSegs < 4 || archSegs > 64 || thickness * 4 >= width) { std::fprintf(stderr, "gen-mesh-archway: thickness×4 < width, archSegs 4..64\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; const float pi = 3.14159265358979f; const int pillarSegs = 16; auto addV = [&](glm::vec3 p, glm::vec3 n, glm::vec2 uv) -> uint32_t { wowee::pipeline::WoweeModel::Vertex vtx; vtx.position = p; vtx.normal = n; vtx.texCoord = uv; wom.vertices.push_back(vtx); return static_cast<uint32_t>(wom.vertices.size() - 1); }; // Cylindrical pillar at given (cx, cz), from y=0 to y=pillarH. auto pillar = [&](float cx, float cz) { float r = thickness; uint32_t bot = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= pillarSegs; ++sg) { float u = static_cast<float>(sg) / pillarSegs; float ang = u * 2.0f * pi; glm::vec3 p(cx + r * std::cos(ang), 0, cz + r * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, n, {u, 0}); } uint32_t top = static_cast<uint32_t>(wom.vertices.size()); for (int sg = 0; sg <= pillarSegs; ++sg) { float u = static_cast<float>(sg) / pillarSegs; float ang = u * 2.0f * pi; glm::vec3 p(cx + r * std::cos(ang), pillarH, cz + r * std::sin(ang)); glm::vec3 n(std::cos(ang), 0, std::sin(ang)); addV(p, n, {u, 1}); } for (int sg = 0; sg < pillarSegs; ++sg) { wom.indices.insert(wom.indices.end(), { bot + sg, top + sg, bot + sg + 1, bot + sg + 1, top + sg, top + sg + 1 }); } // Caps uint32_t bc = addV({cx, 0, cz}, {0, -1, 0}, {0.5f, 0.5f}); uint32_t tc = addV({cx, pillarH, cz}, {0, 1, 0}, {0.5f, 0.5f}); for (int sg = 0; sg < pillarSegs; ++sg) { wom.indices.insert(wom.indices.end(), {bc, bot + sg + 1, bot + sg}); wom.indices.insert(wom.indices.end(), {tc, top + sg, top + sg + 1}); } }; float pillarZ = (width - 2 * thickness) * 0.5f; pillar(0, pillarZ); pillar(0, -pillarZ); // Arch vault: trace half-circle from (z = +pillarZ, y = pillarH) // up over to (z = -pillarZ, y = pillarH). Center of arch: // (z = 0, y = pillarH). Arch radius = pillarZ. // Inner arch (radius pillarZ - thickness*0.5) and outer // (radius pillarZ + thickness*0.5) — the vault sits between. float archCY = pillarH; float arcInner = pillarZ - thickness * 0.5f; float arcOuter = pillarZ + thickness * 0.5f; // Each segment: 4 verts (inner-near, outer-near, inner-far, // outer-far) extruded along X by thickness so the vault // has front and back faces. float archX = thickness * 0.5f; // half-depth in X // Build vertex rings for inner and outer surfaces at // each segment boundary, then connect. // Top half-circle goes from theta=0 to theta=pi. std::vector<glm::vec3> innerRing; std::vector<glm::vec3> outerRing; for (int s = 0; s <= archSegs; ++s) { float t = static_cast<float>(s) / archSegs; float theta = t * pi; // 0..pi float zi = arcInner * std::cos(theta); float yi = arcInner * std::sin(theta); float zo = arcOuter * std::cos(theta); float yo = arcOuter * std::sin(theta); innerRing.push_back({0, archCY + yi, zi}); outerRing.push_back({0, archCY + yo, zo}); } // For each segment, add 8 vertices (4 corners × front/back face) // and stitch them into 6 quads = 12 tris each. for (int s = 0; s < archSegs; ++s) { glm::vec3 i0 = innerRing[s]; glm::vec3 i1 = innerRing[s + 1]; glm::vec3 o0 = outerRing[s]; glm::vec3 o1 = outerRing[s + 1]; // Estimate outward (radial) normal as midpoint of o0+o1 // direction from center. glm::vec3 outDir = glm::normalize(glm::vec3(0, (i0.y + i1.y + o0.y + o1.y) * 0.25f - archCY, (i0.z + i1.z + o0.z + o1.z) * 0.25f)); glm::vec3 frontN(1, 0, 0); glm::vec3 backN(-1, 0, 0); auto V = [&](glm::vec3 p, glm::vec3 n) { return addV(p, n, {0, 0}); }; // Outer surface (top of arch): faces outward radially uint32_t a = V({-archX, o0.y, o0.z}, outDir); uint32_t b = V({ archX, o0.y, o0.z}, outDir); uint32_t c = V({ archX, o1.y, o1.z}, outDir); uint32_t d = V({-archX, o1.y, o1.z}, outDir); wom.indices.insert(wom.indices.end(), {a, b, c, a, c, d}); // Inner surface (underside of arch): faces inward uint32_t e = V({-archX, i0.y, i0.z}, -outDir); uint32_t f = V({ archX, i0.y, i0.z}, -outDir); uint32_t g = V({ archX, i1.y, i1.z}, -outDir); uint32_t h = V({-archX, i1.y, i1.z}, -outDir); wom.indices.insert(wom.indices.end(), {e, g, f, e, h, g}); // Front face (+X) of this wedge uint32_t fi0 = V({ archX, i0.y, i0.z}, frontN); uint32_t fo0 = V({ archX, o0.y, o0.z}, frontN); uint32_t fo1 = V({ archX, o1.y, o1.z}, frontN); uint32_t fi1 = V({ archX, i1.y, i1.z}, frontN); wom.indices.insert(wom.indices.end(), {fi0, fo0, fo1, fi0, fo1, fi1}); // Back face (-X) uint32_t bi0 = V({-archX, i0.y, i0.z}, backN); uint32_t bo0 = V({-archX, o0.y, o0.z}, backN); uint32_t bo1 = V({-archX, o1.y, o1.z}, backN); uint32_t bi1 = V({-archX, i1.y, i1.z}, backN); wom.indices.insert(wom.indices.end(), {bi0, bo1, bo0, bi0, bi1, bo1}); } wowee::pipeline::WoweeModel::Batch batch; batch.indexStart = 0; batch.indexCount = static_cast<uint32_t>(wom.indices.size()); batch.textureIndex = 0; wom.batches.push_back(batch); float maxY = pillarH + arcOuter; wom.boundMin = glm::vec3(-thickness, 0, -width * 0.5f); wom.boundMax = glm::vec3( thickness, maxY, width * 0.5f); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-archway: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom\n", womBase.c_str()); std::printf(" width : %.3f\n", width); std::printf(" pillar H : %.3f\n", pillarH); std::printf(" thickness : %.3f\n", thickness); std::printf(" arch segs : %d (radius %.3f)\n", archSegs, arcOuter); std::printf(" apex Y : %.3f\n", maxY); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--displace-mesh") == 0 && i + 2 < argc) { // Displaces each vertex along its current normal by the // heightmap brightness × scale. UVs determine where each // vertex samples the heightmap. // // Pairs naturally with --gen-mesh-grid: gen a flat grid, // then --displace-mesh with a noise PNG to create // procedural terrain. Or use it on a sphere to make a // bumpy planet. std::string womBase = argv[++i]; std::string pngPath = argv[++i]; float scale = 1.0f; if (i + 1 < argc && argv[i + 1][0] != '-') { try { scale = std::stof(argv[++i]); } catch (...) {} } if (!std::isfinite(scale)) scale = 1.0f; if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } namespace fs = std::filesystem; if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "displace-mesh: %s.wom does not exist\n", womBase.c_str()); return 1; } int W, H, comp; uint8_t* data = stbi_load(pngPath.c_str(), &W, &H, &comp, 1); if (!data) { std::fprintf(stderr, "displace-mesh: cannot read %s (%s)\n", pngPath.c_str(), stbi_failure_reason()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "displace-mesh: failed to load %s.wom\n", womBase.c_str()); stbi_image_free(data); return 1; } float minDelta = 1e30f, maxDelta = -1e30f; for (auto& v : wom.vertices) { // Sample the heightmap with bilinear filtering at // (u, v). Wrap repeating UVs. float u = v.texCoord.x - std::floor(v.texCoord.x); float vv = v.texCoord.y - std::floor(v.texCoord.y); float fx = u * (W - 1); float fy = vv * (H - 1); int x0 = static_cast<int>(fx); int y0 = static_cast<int>(fy); int x1 = std::min(x0 + 1, W - 1); int y1 = std::min(y0 + 1, H - 1); float tx = fx - x0; float ty = fy - y0; auto sample = [&](int x, int y) { return data[y * W + x] / 255.0f; }; float a = sample(x0, y0); float b = sample(x1, y0); float c = sample(x0, y1); float d = sample(x1, y1); float ab = a + (b - a) * tx; float cd = c + (d - c) * tx; float h = ab + (cd - ab) * ty; float delta = h * scale; v.position += v.normal * delta; if (delta < minDelta) minDelta = delta; if (delta > maxDelta) maxDelta = delta; } stbi_image_free(data); // Recompute bounds; normals stay (they're now stale to // the displaced surface but the user can run --smooth- // mesh-normals if they want shading to follow the bumps). wom.boundMin = glm::vec3(1e30f); wom.boundMax = glm::vec3(-1e30f); for (const auto& v : wom.vertices) { wom.boundMin = glm::min(wom.boundMin, v.position); wom.boundMax = glm::max(wom.boundMax, v.position); } wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "displace-mesh: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Displaced %s.wom with %s\n", womBase.c_str(), pngPath.c_str()); std::printf(" source PNG : %dx%d\n", W, H); std::printf(" scale : %g\n", scale); std::printf(" vertices : %zu touched\n", wom.vertices.size()); std::printf(" delta : %.3f to %.3f\n", minDelta, maxDelta); std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); std::printf(" hint : run --smooth-mesh-normals so shading follows the bumps\n"); return 0; } else if (std::strcmp(argv[i], "--gen-mesh-from-heightmap") == 0 && i + 2 < argc) { // Convert a grayscale PNG into a heightmap mesh. Each // pixel becomes one vertex; brightness becomes Y. The // mesh is centered on the XZ plane with X spanning // [-W*scaleXZ/2, +W*scaleXZ/2] and Z spanning the same // for H. Default scaleXZ=0.1 (so a 64×64 PNG covers a // 6.4×6.4 yard patch) and scaleY=2.0 (so full white // pixels rise 2 yards above black). // // Normals are computed from finite differences against // the height field — gives smooth shading across the // surface. Single batch covers all indices; one empty // texture slot for downstream binding via --add- // texture-to-mesh. std::string womBase = argv[++i]; std::string pngPath = argv[++i]; float scaleXZ = 0.1f; float scaleY = 2.0f; if (i + 1 < argc && argv[i + 1][0] != '-') { try { scaleXZ = std::stof(argv[++i]); } catch (...) {} } if (i + 1 < argc && argv[i + 1][0] != '-') { try { scaleY = std::stof(argv[++i]); } catch (...) {} } if (scaleXZ <= 0 || !std::isfinite(scaleXZ) || !std::isfinite(scaleY)) { std::fprintf(stderr, "gen-mesh-from-heightmap: scales must be finite, scaleXZ > 0\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } int W, H, comp; // Force 1-channel grayscale on read; stb downsamples // automatically. uint8_t* data = stbi_load(pngPath.c_str(), &W, &H, &comp, 1); if (!data) { std::fprintf(stderr, "gen-mesh-from-heightmap: cannot read %s (%s)\n", pngPath.c_str(), stbi_failure_reason()); return 1; } if (W < 2 || H < 2) { std::fprintf(stderr, "gen-mesh-from-heightmap: image must be at least 2x2 (got %dx%d)\n", W, H); stbi_image_free(data); return 1; } // Capacity guard: a 1024x1024 PNG would be 1M verts / // ~6M tris — well past what makes sense for a single // WOM placeholder. Cap at 512×512 = 262K verts. if (W > 512 || H > 512) { std::fprintf(stderr, "gen-mesh-from-heightmap: image too large (%dx%d > 512x512)\n", W, H); stbi_image_free(data); return 1; } wowee::pipeline::WoweeModel wom; wom.name = std::filesystem::path(womBase).stem().string(); wom.version = 3; float halfW = W * scaleXZ * 0.5f; float halfH = H * scaleXZ * 0.5f; auto sample = [&](int x, int y) { if (x < 0) x = 0; if (x >= W) x = W - 1; if (y < 0) y = 0; if (y >= H) y = H - 1; return data[y * W + x] / 255.0f * scaleY; }; wom.vertices.reserve(static_cast<size_t>(W) * H); for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { float h = sample(x, y); // Central-difference normal: (-dh/dx, 1, -dh/dz), // normalized. float dx = (sample(x + 1, y) - sample(x - 1, y)) / (2.0f * scaleXZ); float dz = (sample(x, y + 1) - sample(x, y - 1)) / (2.0f * scaleXZ); glm::vec3 n(-dx, 1.0f, -dz); n = glm::normalize(n); wowee::pipeline::WoweeModel::Vertex v; v.position = glm::vec3(x * scaleXZ - halfW, h, y * scaleXZ - halfH); v.normal = n; v.texCoord = glm::vec2(static_cast<float>(x) / (W - 1), static_cast<float>(y) / (H - 1)); wom.vertices.push_back(v); } } wom.indices.reserve(static_cast<size_t>(W - 1) * (H - 1) * 6); for (int y = 0; y < H - 1; ++y) { for (int x = 0; x < W - 1; ++x) { uint32_t a = y * W + x; uint32_t b = a + 1; uint32_t c = a + W; uint32_t d = c + 1; wom.indices.push_back(a); wom.indices.push_back(c); wom.indices.push_back(b); wom.indices.push_back(b); wom.indices.push_back(c); wom.indices.push_back(d); } } stbi_image_free(data); // Bounds from vertex extents. wom.boundMin = glm::vec3(1e30f); wom.boundMax = glm::vec3(-1e30f); for (const auto& v : wom.vertices) { wom.boundMin = glm::min(wom.boundMin, v.position); wom.boundMax = glm::max(wom.boundMax, v.position); } wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); wom.texturePaths.push_back(""); std::filesystem::path womPath(womBase); std::filesystem::create_directories(womPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "gen-mesh-from-heightmap: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Wrote %s.wom from %s\n", womBase.c_str(), pngPath.c_str()); std::printf(" source PNG : %dx%d\n", W, H); std::printf(" scaleXZ : %g (mesh span %.2f × %.2f)\n", scaleXZ, W * scaleXZ, H * scaleXZ); std::printf(" scaleY : %g (height range %.3f to %.3f)\n", scaleY, wom.boundMin.y, wom.boundMax.y); std::printf(" vertices : %zu\n", wom.vertices.size()); std::printf(" triangles : %zu\n", wom.indices.size() / 3); return 0; } else if (std::strcmp(argv[i], "--export-mesh-heightmap") == 0 && i + 4 < argc) { // Inverse of --gen-mesh-from-heightmap: extract a // grayscale PNG from a row-major W×H heightmap mesh. // The user supplies W and H since arbitrary meshes // aren't necessarily heightmap-shaped — taking the // dimensions explicitly avoids guessing wrong on a // mesh with vertex count W*H but a different layout. // // Y values are normalized to 0..255 using the mesh // bounds (Y_min → 0, Y_max → 255). Round-trips with // --gen-mesh-from-heightmap modulo the 1-byte // quantization step. std::string womBase = argv[++i]; std::string outPath = argv[++i]; int W = 0, H = 0; try { W = std::stoi(argv[++i]); H = std::stoi(argv[++i]); } catch (...) { std::fprintf(stderr, "export-mesh-heightmap: W and H must be integers\n"); return 1; } if (W < 2 || H < 2 || W > 8192 || H > 8192) { std::fprintf(stderr, "export-mesh-heightmap: W and H must be 2..8192\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "export-mesh-heightmap: %s.wom does not exist\n", womBase.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "export-mesh-heightmap: failed to load %s.wom\n", womBase.c_str()); return 1; } size_t expected = static_cast<size_t>(W) * H; if (wom.vertices.size() < expected) { std::fprintf(stderr, "export-mesh-heightmap: %s.wom has %zu vertices, " "need at least %zu for %dx%d\n", womBase.c_str(), wom.vertices.size(), expected, W, H); return 1; } float yMin = wom.boundMin.y; float yMax = wom.boundMax.y; float range = yMax - yMin; std::vector<uint8_t> pixels(expected * 3, 0); for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { size_t idx = static_cast<size_t>(y) * W + x; float h = wom.vertices[idx].position.y; float t = (range > 1e-6f) ? (h - yMin) / range : 0.0f; if (t < 0) t = 0; if (t > 1) t = 1; uint8_t g = static_cast<uint8_t>(t * 255.0f + 0.5f); size_t i2 = idx * 3; pixels[i2 + 0] = g; pixels[i2 + 1] = g; pixels[i2 + 2] = g; } } if (!stbi_write_png(outPath.c_str(), W, H, 3, pixels.data(), W * 3)) { std::fprintf(stderr, "export-mesh-heightmap: stbi_write_png failed for %s\n", outPath.c_str()); return 1; } std::printf("Wrote %s from %s.wom\n", outPath.c_str(), womBase.c_str()); std::printf(" size : %dx%d\n", W, H); std::printf(" height : %.3f to %.3f (mapped to 0..255)\n", yMin, yMax); std::printf(" pixels : %zu (W*H)\n", expected); return 0; } else if (std::strcmp(argv[i], "--add-texture-to-mesh") == 0 && i + 2 < argc) { // Manual companion to --gen-mesh-textured. Binds an // existing PNG to a WOM by appending it to texturePaths // (or reusing the slot if already present) and pointing // the chosen batch at it. // // The PNG path stored in the WOM is just the leaf — the // runtime resolves textures relative to the model's own // directory, so the user is responsible for placing the // PNG next to the WOM. std::string womBase = argv[++i]; std::string pngPath = argv[++i]; int batchIdx = 0; if (i + 1 < argc && argv[i + 1][0] != '-') { try { batchIdx = std::stoi(argv[++i]); } catch (...) { std::fprintf(stderr, "add-texture-to-mesh: batchIdx must be an integer\n"); return 1; } } // Strip .wom if user passed a full filename. if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } namespace fs = std::filesystem; if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "add-texture-to-mesh: %s.wom does not exist\n", womBase.c_str()); return 1; } if (!fs::exists(pngPath)) { std::fprintf(stderr, "add-texture-to-mesh: png '%s' does not exist\n", pngPath.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "add-texture-to-mesh: failed to load %s.wom\n", womBase.c_str()); return 1; } if (wom.batches.empty()) { std::fprintf(stderr, "add-texture-to-mesh: %s.wom has no batches " "(run --migrate-wom to upgrade WOM1/WOM2 first)\n", womBase.c_str()); return 1; } if (batchIdx < 0 || static_cast<size_t>(batchIdx) >= wom.batches.size()) { std::fprintf(stderr, "add-texture-to-mesh: batchIdx %d out of range " "(have %zu batches)\n", batchIdx, wom.batches.size()); return 1; } std::string pngLeaf = fs::path(pngPath).filename().string(); // Reuse texture slot if the leaf is already in the table; // otherwise append a new slot at the end. uint32_t texIdx = static_cast<uint32_t>(wom.texturePaths.size()); for (size_t k = 0; k < wom.texturePaths.size(); ++k) { if (wom.texturePaths[k] == pngLeaf) { texIdx = static_cast<uint32_t>(k); break; } } if (texIdx == wom.texturePaths.size()) { wom.texturePaths.push_back(pngLeaf); } wom.batches[batchIdx].textureIndex = texIdx; if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "add-texture-to-mesh: failed to re-save %s.wom\n", womBase.c_str()); return 1; } std::printf("Bound %s -> %s.wom batch %d (texture slot %u)\n", pngLeaf.c_str(), womBase.c_str(), batchIdx, texIdx); std::printf(" total texture slots : %zu\n", wom.texturePaths.size()); // Warn if the PNG isn't sitting next to the WOM — the // runtime resolves leaf paths relative to the WOM dir. std::string womDir = fs::path(womBase).parent_path().string(); if (womDir.empty()) womDir = "."; std::string expected = womDir + "/" + pngLeaf; if (!fs::exists(expected)) { std::printf(" NOTE: %s does not exist next to the WOM\n", expected.c_str()); std::printf(" copy or move %s -> %s before shipping\n", pngPath.c_str(), expected.c_str()); } return 0; } else if (std::strcmp(argv[i], "--scale-mesh") == 0 && i + 2 < argc) { // Uniformly scale a WOM in place. Multiplies every // vertex position, every bone pivot, and the bounds by // <factor>. Normals are unchanged (uniform scale // preserves direction). Useful for "I imported this OBJ // but it's the wrong size" cleanup. std::string womBase = argv[++i]; float factor = 1.0f; try { factor = std::stof(argv[++i]); } catch (...) { std::fprintf(stderr, "scale-mesh: <factor> must be a number\n"); return 1; } if (factor <= 0.0f || !std::isfinite(factor)) { std::fprintf(stderr, "scale-mesh: factor must be positive and finite (got %g)\n", factor); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "scale-mesh: %s.wom does not exist\n", womBase.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "scale-mesh: failed to load %s.wom\n", womBase.c_str()); return 1; } for (auto& v : wom.vertices) v.position *= factor; for (auto& b : wom.bones) b.pivot *= factor; // Animation translations also scale; rotation/scale // tracks are dimensionless. for (auto& a : wom.animations) { for (auto& bone : a.boneKeyframes) { for (auto& kf : bone) kf.translation *= factor; } } wom.boundMin *= factor; wom.boundMax *= factor; wom.boundRadius *= factor; if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "scale-mesh: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Scaled %s.wom by %g\n", womBase.c_str(), factor); std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); std::printf(" new radius : %.3f\n", wom.boundRadius); return 0; } else if (std::strcmp(argv[i], "--translate-mesh") == 0 && i + 4 < argc) { // Offset every vertex (and bones / anim translations / // bounds) by (dx, dy, dz). Useful for re-centering a // mesh whose origin was wrong on import, or for shifting // a procedural primitive that isn't centered the way // you want. std::string womBase = argv[++i]; float dx = 0, dy = 0, dz = 0; try { dx = std::stof(argv[++i]); dy = std::stof(argv[++i]); dz = std::stof(argv[++i]); } catch (...) { std::fprintf(stderr, "translate-mesh: dx/dy/dz must be numbers\n"); return 1; } if (!std::isfinite(dx) || !std::isfinite(dy) || !std::isfinite(dz)) { std::fprintf(stderr, "translate-mesh: offsets must be finite\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "translate-mesh: %s.wom does not exist\n", womBase.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "translate-mesh: failed to load %s.wom\n", womBase.c_str()); return 1; } glm::vec3 d(dx, dy, dz); for (auto& v : wom.vertices) v.position += d; for (auto& b : wom.bones) b.pivot += d; // Bone-relative animation translations don't shift with // the model — only the bone pivots do, since translations // are in bone-local space. Leave anim keyframes alone. wom.boundMin += d; wom.boundMax += d; // Radius is unchanged (translation is rigid, doesn't // change extent). if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "translate-mesh: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Translated %s.wom by (%g, %g, %g)\n", womBase.c_str(), dx, dy, dz); std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); return 0; } else if (std::strcmp(argv[i], "--strip-mesh") == 0 && i + 1 < argc) { // Drop bones and/or animations from a WOM in place. Use // case: a model imported with full skeleton + anims that // will only ever be placed as static decoration — there's // no point shipping the bone data, and stripping it can // shrink the file substantially. // // Default (no flags) is a no-op so the user explicitly // opts in to destruction. --bones drops bones (and // therefore animations, since they reference bones). // --anims drops only animations. --all is shorthand for // both. std::string womBase = argv[++i]; bool dropBones = false, dropAnims = false; while (i + 1 < argc && argv[i + 1][0] == '-') { std::string flag = argv[++i]; if (flag == "--bones") { dropBones = true; } else if (flag == "--anims") { dropAnims = true; } else if (flag == "--all") { dropBones = true; dropAnims = true; } else { std::fprintf(stderr, "strip-mesh: unknown flag '%s'\n", flag.c_str()); return 1; } } if (!dropBones && !dropAnims) { std::fprintf(stderr, "strip-mesh: no --bones / --anims / --all specified — nothing to do\n"); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } namespace fs = std::filesystem; std::string fullPath = womBase + ".wom"; if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "strip-mesh: %s.wom does not exist\n", womBase.c_str()); return 1; } uint64_t bytesBefore = fs::file_size(fullPath); auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "strip-mesh: failed to load %s.wom\n", womBase.c_str()); return 1; } size_t bonesBefore = wom.bones.size(); size_t animsBefore = wom.animations.size(); if (dropBones) { wom.bones.clear(); // Bones implies anims (anims reference bones). wom.animations.clear(); // Reset per-vertex skinning to identity-on-bone-0 so // a renderer that expects the field doesn't read // stale indices. for (auto& v : wom.vertices) { v.boneWeights[0] = 255; v.boneWeights[1] = 0; v.boneWeights[2] = 0; v.boneWeights[3] = 0; v.boneIndices[0] = 0; v.boneIndices[1] = 0; v.boneIndices[2] = 0; v.boneIndices[3] = 0; } } else if (dropAnims) { wom.animations.clear(); } if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "strip-mesh: failed to save %s.wom\n", womBase.c_str()); return 1; } uint64_t bytesAfter = fs::file_size(fullPath); std::printf("Stripped %s.wom\n", womBase.c_str()); std::printf(" bones : %zu -> %zu\n", bonesBefore, wom.bones.size()); std::printf(" animations : %zu -> %zu\n", animsBefore, wom.animations.size()); std::printf(" bytes : %llu -> %llu (%+lld)\n", static_cast<unsigned long long>(bytesBefore), static_cast<unsigned long long>(bytesAfter), static_cast<long long>(bytesAfter) - static_cast<long long>(bytesBefore)); return 0; } else if (std::strcmp(argv[i], "--rotate-mesh") == 0 && i + 3 < argc) { // Rotate every vertex position and normal around the // chosen axis (x, y, or z) by <degrees>. Bone pivots // also rotate so the skeleton stays in sync. Bounds are // recomputed from rotated positions (axis-aligned bbox // grows during rotation). std::string womBase = argv[++i]; std::string axisStr = argv[++i]; float degrees = 0.0f; try { degrees = std::stof(argv[++i]); } catch (...) { std::fprintf(stderr, "rotate-mesh: <degrees> must be a number\n"); return 1; } if (!std::isfinite(degrees)) { std::fprintf(stderr, "rotate-mesh: degrees must be finite\n"); return 1; } std::transform(axisStr.begin(), axisStr.end(), axisStr.begin(), [](unsigned char c) { return std::tolower(c); }); int axis = -1; if (axisStr == "x") axis = 0; else if (axisStr == "y") axis = 1; else if (axisStr == "z") axis = 2; else { std::fprintf(stderr, "rotate-mesh: axis must be x, y, or z (got '%s')\n", axisStr.c_str()); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "rotate-mesh: %s.wom does not exist\n", womBase.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "rotate-mesh: failed to load %s.wom\n", womBase.c_str()); return 1; } float rad = degrees * 3.14159265358979f / 180.0f; float cs = std::cos(rad), sn = std::sin(rad); // Rotation around each axis: standard right-hand rule. auto rot = [axis, cs, sn](glm::vec3 v) -> glm::vec3 { if (axis == 0) { return glm::vec3(v.x, cs * v.y - sn * v.z, sn * v.y + cs * v.z); } if (axis == 1) { return glm::vec3( cs * v.x + sn * v.z, v.y, -sn * v.x + cs * v.z); } return glm::vec3(cs * v.x - sn * v.y, sn * v.x + cs * v.y, v.z); }; for (auto& v : wom.vertices) { v.position = rot(v.position); v.normal = rot(v.normal); } for (auto& b : wom.bones) { b.pivot = rot(b.pivot); } // Recompute bounds from rotated vertices (axis-aligned // bbox can only grow under rotation, so reuse the loop). wom.boundMin = glm::vec3(1e30f); wom.boundMax = glm::vec3(-1e30f); for (const auto& v : wom.vertices) { wom.boundMin = glm::min(wom.boundMin, v.position); wom.boundMax = glm::max(wom.boundMax, v.position); } wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "rotate-mesh: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Rotated %s.wom by %g° around %s\n", womBase.c_str(), degrees, axisStr.c_str()); std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); return 0; } else if (std::strcmp(argv[i], "--center-mesh") == 0 && i + 1 < argc) { // Translate the mesh so the bounds center lands at the // origin. Convenience for "this mesh's pivot is in some // weird corner — make it center-pivoted." Doesn't change // shape, just shifts. std::string womBase = argv[++i]; if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "center-mesh: %s.wom does not exist\n", womBase.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "center-mesh: failed to load %s.wom\n", womBase.c_str()); return 1; } glm::vec3 center = (wom.boundMin + wom.boundMax) * 0.5f; for (auto& v : wom.vertices) v.position -= center; for (auto& b : wom.bones) b.pivot -= center; wom.boundMin -= center; wom.boundMax -= center; // Radius is preserved (pure translation). if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "center-mesh: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Centered %s.wom (shifted by %g, %g, %g)\n", womBase.c_str(), -center.x, -center.y, -center.z); std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); return 0; } else if (std::strcmp(argv[i], "--flip-mesh-normals") == 0 && i + 1 < argc) { // Invert every vertex normal. Use case: an OBJ imported // with flipped winding renders inside-out — flipping the // normals makes shading correct without re-winding the // index buffer (which would also need batch-aware care). // Also useful for skybox-like meshes where the "outside" // texture should appear when looking from inside. std::string womBase = argv[++i]; if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "flip-mesh-normals: %s.wom does not exist\n", womBase.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "flip-mesh-normals: failed to load %s.wom\n", womBase.c_str()); return 1; } for (auto& v : wom.vertices) v.normal = -v.normal; if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "flip-mesh-normals: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Flipped normals on %s.wom (%zu vertices)\n", womBase.c_str(), wom.vertices.size()); return 0; } else if (std::strcmp(argv[i], "--mirror-mesh") == 0 && i + 2 < argc) { // Mirror every vertex + normal across the chosen axis. // Negating just one position component reverses face // winding (the triangle's signed area flips), so we // also swap the second and third index of every triangle // to keep front-faces facing forward and lighting // correct. Bone pivots mirror too. // // Useful for "I have a left arm, mirror it for the right // arm" content reuse. The output is byte-stable // independent of execution order. std::string womBase = argv[++i]; std::string axisStr = argv[++i]; std::transform(axisStr.begin(), axisStr.end(), axisStr.begin(), [](unsigned char c) { return std::tolower(c); }); int axis = -1; if (axisStr == "x") axis = 0; else if (axisStr == "y") axis = 1; else if (axisStr == "z") axis = 2; else { std::fprintf(stderr, "mirror-mesh: axis must be x, y, or z (got '%s')\n", axisStr.c_str()); return 1; } if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "mirror-mesh: %s.wom does not exist\n", womBase.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "mirror-mesh: failed to load %s.wom\n", womBase.c_str()); return 1; } for (auto& v : wom.vertices) { v.position[axis] = -v.position[axis]; v.normal[axis] = -v.normal[axis]; } for (auto& b : wom.bones) { b.pivot[axis] = -b.pivot[axis]; } // Flip winding: swap idx[1] and idx[2] of every triangle. // Indices are stored as a flat list of triangle triples. for (size_t k = 0; k + 2 < wom.indices.size(); k += 3) { std::swap(wom.indices[k + 1], wom.indices[k + 2]); } // Bounds: the mirrored extent on this axis is just the // negation of the previous extent — recompute from // vertices to be safe. wom.boundMin = glm::vec3(1e30f); wom.boundMax = glm::vec3(-1e30f); for (const auto& v : wom.vertices) { wom.boundMin = glm::min(wom.boundMin, v.position); wom.boundMax = glm::max(wom.boundMax, v.position); } wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "mirror-mesh: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Mirrored %s.wom across %s axis\n", womBase.c_str(), axisStr.c_str()); std::printf(" vertices touched : %zu\n", wom.vertices.size()); std::printf(" triangles flipped: %zu\n", wom.indices.size() / 3); std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); return 0; } else if (std::strcmp(argv[i], "--smooth-mesh-normals") == 0 && i + 1 < argc) { // Recompute per-vertex normals as the area-weighted // average of incident face normals. Useful when: // - Imported geometry has no normals (--import-obj // leaves them zero or face-flat). // - Custom transforms have desynced normals from the // positions (e.g., user post-processed the WOM // externally). // - Faceted-by-construction meshes (cube, stairs) need // a smooth re-shade for stylistic reasons. // // The cross-product magnitude is twice the triangle area, // which weights large faces more — bigger triangles // contribute more to the local surface direction. std::string womBase = argv[++i]; if (womBase.size() >= 4 && womBase.substr(womBase.size() - 4) == ".wom") { womBase = womBase.substr(0, womBase.size() - 4); } if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { std::fprintf(stderr, "smooth-mesh-normals: %s.wom does not exist\n", womBase.c_str()); return 1; } auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); if (!wom.isValid()) { std::fprintf(stderr, "smooth-mesh-normals: failed to load %s.wom\n", womBase.c_str()); return 1; } // Reset vertex normals to zero so the accumulator sums // cleanly. for (auto& v : wom.vertices) v.normal = glm::vec3(0); for (size_t k = 0; k + 2 < wom.indices.size(); k += 3) { uint32_t i0 = wom.indices[k]; uint32_t i1 = wom.indices[k + 1]; uint32_t i2 = wom.indices[k + 2]; if (i0 >= wom.vertices.size() || i1 >= wom.vertices.size() || i2 >= wom.vertices.size()) continue; glm::vec3 p0 = wom.vertices[i0].position; glm::vec3 p1 = wom.vertices[i1].position; glm::vec3 p2 = wom.vertices[i2].position; // Cross product magnitude == 2 * triangle area, used // as the weight. glm::vec3 faceN = glm::cross(p1 - p0, p2 - p0); wom.vertices[i0].normal += faceN; wom.vertices[i1].normal += faceN; wom.vertices[i2].normal += faceN; } int normalized = 0, degenerate = 0; for (auto& v : wom.vertices) { float len = glm::length(v.normal); if (len > 1e-6f) { v.normal /= len; normalized++; } else { // Vertex unreferenced or sum cancelled — fall // back to "up" rather than leaving zero so the // shader doesn't get a dark NaN spot. v.normal = glm::vec3(0, 1, 0); degenerate++; } } if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { std::fprintf(stderr, "smooth-mesh-normals: failed to save %s.wom\n", womBase.c_str()); return 1; } std::printf("Smoothed normals on %s.wom\n", womBase.c_str()); std::printf(" vertices touched : %zu\n", wom.vertices.size()); std::printf(" triangles read : %zu\n", wom.indices.size() / 3); std::printf(" normalized : %d\n", normalized); if (degenerate > 0) { std::printf(" degenerate : %d (set to (0,1,0))\n", degenerate); } return 0; } else if (std::strcmp(argv[i], "--merge-meshes") == 0 && i + 3 < argc) { // Combine two WOMs into one. The second mesh's indices // are offset by the first mesh's vertex count, and its // batches are appended with their indexStart shifted by // the first mesh's index count and their textureIndex // shifted by the first mesh's texture-slot count. // // Bones/animations are NOT merged — that requires // skeleton retargeting which is beyond a simple // concatenation. If either input has bones, the merged // output is treated as static (bones cleared, weights // reset to identity-on-bone-0) so renderers don't read // mismatched indices. std::string aBase = argv[++i]; std::string bBase = argv[++i]; std::string outBase = argv[++i]; auto stripExt = [](std::string p) { if (p.size() >= 4 && p.substr(p.size() - 4) == ".wom") { return p.substr(0, p.size() - 4); } return p; }; aBase = stripExt(aBase); bBase = stripExt(bBase); outBase = stripExt(outBase); if (!wowee::pipeline::WoweeModelLoader::exists(aBase)) { std::fprintf(stderr, "merge-meshes: %s.wom does not exist\n", aBase.c_str()); return 1; } if (!wowee::pipeline::WoweeModelLoader::exists(bBase)) { std::fprintf(stderr, "merge-meshes: %s.wom does not exist\n", bBase.c_str()); return 1; } auto a = wowee::pipeline::WoweeModelLoader::load(aBase); auto b = wowee::pipeline::WoweeModelLoader::load(bBase); if (!a.isValid() || !b.isValid()) { std::fprintf(stderr, "merge-meshes: failed to load one of the inputs\n"); return 1; } wowee::pipeline::WoweeModel out; out.name = std::filesystem::path(outBase).stem().string(); out.version = 3; out.vertices = a.vertices; out.vertices.insert(out.vertices.end(), b.vertices.begin(), b.vertices.end()); out.indices = a.indices; uint32_t indexOffset = static_cast<uint32_t>(a.vertices.size()); for (uint32_t idx : b.indices) { out.indices.push_back(idx + indexOffset); } out.texturePaths = a.texturePaths; uint32_t textureOffset = static_cast<uint32_t>(a.texturePaths.size()); for (const auto& t : b.texturePaths) { out.texturePaths.push_back(t); } // Promote single-batch / no-batch inputs into proper // batches so the merged output is well-formed v3. auto ensureBatch = [](const wowee::pipeline::WoweeModel& m) { std::vector<wowee::pipeline::WoweeModel::Batch> bs = m.batches; if (bs.empty()) { wowee::pipeline::WoweeModel::Batch only; only.indexStart = 0; only.indexCount = static_cast<uint32_t>(m.indices.size()); only.textureIndex = 0; only.blendMode = 0; only.flags = 0; bs.push_back(only); } return bs; }; auto aBatches = ensureBatch(a); auto bBatches = ensureBatch(b); for (const auto& bt : aBatches) out.batches.push_back(bt); uint32_t indexStartOffset = static_cast<uint32_t>(a.indices.size()); for (auto bt : bBatches) { bt.indexStart += indexStartOffset; bt.textureIndex += textureOffset; out.batches.push_back(bt); } // Static-only output (see header comment). for (auto& v : out.vertices) { v.boneWeights[0] = 255; v.boneWeights[1] = 0; v.boneWeights[2] = 0; v.boneWeights[3] = 0; v.boneIndices[0] = 0; v.boneIndices[1] = 0; v.boneIndices[2] = 0; v.boneIndices[3] = 0; } // Bounds: union of inputs. out.boundMin = glm::min(a.boundMin, b.boundMin); out.boundMax = glm::max(a.boundMax, b.boundMax); out.boundRadius = glm::length(out.boundMax - out.boundMin) * 0.5f; std::filesystem::path outPath(outBase); std::filesystem::create_directories(outPath.parent_path()); if (!wowee::pipeline::WoweeModelLoader::save(out, outBase)) { std::fprintf(stderr, "merge-meshes: failed to save %s.wom\n", outBase.c_str()); return 1; } std::printf("Merged %s.wom + %s.wom -> %s.wom\n", aBase.c_str(), bBase.c_str(), outBase.c_str()); std::printf(" vertices : %zu = %zu + %zu\n", out.vertices.size(), a.vertices.size(), b.vertices.size()); std::printf(" indices : %zu = %zu + %zu\n", out.indices.size(), a.indices.size(), b.indices.size()); std::printf(" batches : %zu = %zu + %zu\n", out.batches.size(), aBatches.size(), bBatches.size()); std::printf(" textures : %zu = %zu + %zu\n", out.texturePaths.size(), a.texturePaths.size(), b.texturePaths.size()); std::printf(" bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", out.boundMin.x, out.boundMin.y, out.boundMin.z, out.boundMax.x, out.boundMax.y, out.boundMax.z); 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], "--info-data-tree") == 0 && i + 1 < argc) { // Non-destructive companion to --migrate-data-tree. Walks // <srcDir> recursively, counts files per format pair // (proprietary vs open replacement), and reports per-pair // counts plus an overall "migration share" — the fraction // of source files that already have an open sidecar // present. // // Designed to drop into CI dashboards: a 100% share // means every proprietary asset has a deterministic open // counterpart on disk and you can drop the originals. std::string srcDir = argv[++i]; bool jsonOut = (i + 1 < argc && std::strcmp(argv[i + 1], "--json") == 0); if (jsonOut) i++; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "info-data-tree: %s is not a directory\n", srcDir.c_str()); return 1; } // Each pair: proprietary extension + open extension. The // open file is considered a "sidecar" when it sits next // to the proprietary file with the same stem. struct Pair { const char* prop; // ".m2" const char* open; // ".wom" int propCount = 0; int sidecarCount = 0; // .wom next to a .m2 int orphanOpenCount = 0; // .wom with no matching .m2 }; std::vector<Pair> pairs = { {".m2", ".wom"}, {".wmo", ".wob"}, {".blp", ".png"}, {".dbc", ".json"}, }; // First pass: collect filenames by extension. Use a set // of (parent, stem) for the sidecar lookup so the test is // O(log n) per file rather than O(n). std::map<std::string, std::set<std::pair<std::string, std::string>>> byExt; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); byExt[ext].insert({e.path().parent_path().string(), e.path().stem().string()}); } for (auto& p : pairs) { const auto& propSet = byExt[p.prop]; const auto& openSet = byExt[p.open]; p.propCount = static_cast<int>(propSet.size()); for (const auto& key : openSet) { if (propSet.count(key)) p.sidecarCount++; else p.orphanOpenCount++; } } int totalProp = 0, totalSidecar = 0, totalOrphanOpen = 0; for (const auto& p : pairs) { totalProp += p.propCount; totalSidecar += p.sidecarCount; totalOrphanOpen += p.orphanOpenCount; } double overallShare = totalProp > 0 ? 100.0 * totalSidecar / totalProp : 100.0; if (jsonOut) { nlohmann::json j; j["srcDir"] = srcDir; j["totalProprietary"] = totalProp; j["totalSidecars"] = totalSidecar; j["totalOrphanOpen"] = totalOrphanOpen; j["migrationShare"] = overallShare; nlohmann::json arr = nlohmann::json::array(); for (const auto& p : pairs) { double share = p.propCount > 0 ? 100.0 * p.sidecarCount / p.propCount : 100.0; arr.push_back({{"proprietary", p.prop}, {"open", p.open}, {"propCount", p.propCount}, {"sidecarCount", p.sidecarCount}, {"orphanOpenCount", p.orphanOpenCount}, {"share", share}}); } j["pairs"] = arr; std::printf("%s\n", j.dump(2).c_str()); return 0; } std::printf("info-data-tree: %s\n", srcDir.c_str()); std::printf(" total proprietary : %d\n", totalProp); std::printf(" total sidecars : %d (open files matched to a proprietary)\n", totalSidecar); std::printf(" orphan open files : %d (no matching proprietary — already-stripped)\n", totalOrphanOpen); std::printf(" migration share : %.1f%% (sidecars / proprietary)\n", overallShare); std::printf("\n pair prop open-side orphan share\n"); for (const auto& p : pairs) { double share = p.propCount > 0 ? 100.0 * p.sidecarCount / p.propCount : 100.0; char label[32]; std::snprintf(label, sizeof(label), "%-4s → %-5s", p.prop, p.open); std::printf(" %-14s %5d %9d %6d %5.1f%%\n", label, p.propCount, p.sidecarCount, p.orphanOpenCount, share); } return 0; } else if (std::strcmp(argv[i], "--strip-data-tree") == 0 && i + 1 < argc) { // Destructive cleanup. Walks <srcDir>, finds every // proprietary file (.m2/.wmo/.blp/.dbc) that already has // a matching open sidecar at the same (parent, stem), // and deletes the proprietary file. Sidecar match uses // case-insensitive extension comparison. // // Honors --dry-run for safe previews. Mirrors the // --strip-zone convention (defaults to actually delete). // // Recommended workflow: --info-data-tree to see the // share, --migrate-data-tree to fill in missing sidecars, // --strip-data-tree --dry-run to confirm the kill list, // then --strip-data-tree to apply. std::string srcDir = 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(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "strip-data-tree: %s is not a directory\n", srcDir.c_str()); return 1; } // Build the (parent, stem) set of every open file first. // The proprietary→open ext map serves both as the strip // target list and as the per-pair routing table. static const std::vector<std::pair<std::string, std::string>> kPairs = { {".m2", ".wom"}, {".wmo", ".wob"}, {".blp", ".png"}, {".dbc", ".json"}, }; std::map<std::string, std::set<std::pair<std::string, std::string>>> openSets; // open ext -> set of (parent, stem) std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); for (const auto& [_, openExt] : kPairs) { if (ext == openExt) { openSets[openExt].insert( {e.path().parent_path().string(), e.path().stem().string()}); break; } } } // Walk again, this time deleting (or previewing) each // proprietary file whose key appears in its pair's open // set. int removed = 0, failed = 0; uint64_t freedBytes = 0; std::map<std::string, int> perExtRemoved; for (const auto& [propExt, openExt] : kPairs) { const auto& openSet = openSets[openExt]; if (openSet.empty()) continue; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); if (ext != propExt) continue; std::pair<std::string, std::string> key{ e.path().parent_path().string(), e.path().stem().string()}; if (!openSet.count(key)) continue; // no sidecar — keep uint64_t sz = e.file_size(ec); if (ec) sz = 0; if (dryRun) { std::printf(" would remove: %s (%llu bytes)\n", e.path().c_str(), static_cast<unsigned long long>(sz)); removed++; perExtRemoved[propExt]++; freedBytes += sz; } else { if (fs::remove(e.path(), ec)) { std::printf(" removed: %s (%llu bytes)\n", e.path().c_str(), static_cast<unsigned long long>(sz)); removed++; perExtRemoved[propExt]++; freedBytes += sz; } else { std::fprintf(stderr, " WARN: failed to remove %s (%s)\n", e.path().c_str(), ec.message().c_str()); failed++; } } } } std::printf("\nstrip-data-tree: %s%s\n", srcDir.c_str(), dryRun ? " (dry-run)" : ""); std::printf(" %s : %d file(s)\n", dryRun ? "would remove" : "removed ", removed); std::printf(" freed : %.1f KB\n", freedBytes / 1024.0); if (!perExtRemoved.empty()) { std::printf("\n Per-extension:\n"); for (const auto& [ext, count] : perExtRemoved) { std::printf(" %-5s : %d\n", ext.c_str(), count); } } if (failed > 0) { std::printf("\n FAILED : %d (see stderr)\n", failed); } if (dryRun && removed > 0) { std::printf("\n re-run without --dry-run to apply\n"); } return failed == 0 ? 0 : 1; } else if (std::strcmp(argv[i], "--audit-data-tree") == 0 && i + 1 < argc) { // Non-destructive CI gate. Walks <srcDir> and exits 1 if // any proprietary file (.m2/.wmo/.blp/.dbc) lacks a // matching open sidecar at the same (parent, stem). The // pre-strip safety check: don't run --strip-data-tree // until this returns exit 0. // // Lists missing sidecars (capped at 50) so the user can // re-run --migrate-data-tree to fill them in. std::string srcDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(srcDir) || !fs::is_directory(srcDir)) { std::fprintf(stderr, "audit-data-tree: %s is not a directory\n", srcDir.c_str()); return 1; } static const std::vector<std::pair<std::string, std::string>> kPairs = { {".m2", ".wom"}, {".wmo", ".wob"}, {".blp", ".png"}, {".dbc", ".json"}, }; // Build (parent, stem) sets per open ext for fast lookup. std::map<std::string, std::set<std::pair<std::string, std::string>>> openSets; std::map<std::string, std::vector<std::string>> propByExt; std::error_code ec; for (const auto& e : fs::recursive_directory_iterator(srcDir, ec)) { if (!e.is_regular_file()) continue; std::string ext = e.path().extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); bool isOpen = false; for (const auto& [propExt, openExt] : kPairs) { if (ext == openExt) { openSets[openExt].insert( {e.path().parent_path().string(), e.path().stem().string()}); isOpen = true; break; } } if (isOpen) continue; for (const auto& [propExt, _] : kPairs) { if (ext == propExt) { propByExt[propExt].push_back(e.path().string()); break; } } } // Check each proprietary file for its sidecar. int totalProp = 0, totalMissing = 0; std::vector<std::string> missing; std::map<std::string, int> missingPerExt; for (const auto& [propExt, openExt] : kPairs) { const auto& openSet = openSets[openExt]; for (const auto& fullPath : propByExt[propExt]) { totalProp++; fs::path p(fullPath); std::pair<std::string, std::string> key{ p.parent_path().string(), p.stem().string()}; if (openSet.count(key)) continue; totalMissing++; missingPerExt[propExt]++; missing.push_back(fullPath); } } std::sort(missing.begin(), missing.end()); std::printf("audit-data-tree: %s\n", srcDir.c_str()); std::printf(" proprietary files : %d\n", totalProp); std::printf(" missing sidecars : %d\n", totalMissing); if (totalMissing == 0) { if (totalProp > 0) { std::printf("\n PASSED — every proprietary file has an open sidecar\n"); } else { std::printf("\n PASSED — no proprietary files present\n"); } return 0; } std::printf("\n FAILED — re-run --migrate-data-tree to fill the gaps\n"); std::printf("\n Per-extension missing:\n"); for (const auto& [ext, count] : missingPerExt) { std::printf(" %-5s : %d\n", ext.c_str(), count); } std::printf("\n Missing sidecars (sorted):\n"); size_t shown = 0; for (const auto& m : missing) { if (shown >= 50) { std::printf(" ... and %zu more\n", missing.size() - shown); break; } std::printf(" - %s\n", m.c_str()); shown++; } return 1; } 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], "--pack-wcp") == 0 && i + 1 < argc) { // Pack a zone directory into a .wcp archive. // Usage: --pack-wcp <zoneDirOrName> [destPath] // If <zoneDirOrName> looks like a path (contains '/' or starts // with '.'), use it directly; otherwise resolve under // custom_zones/ then output/ (matching the discovery search // order). std::string nameOrDir = argv[++i]; std::string destPath; if (i + 1 < argc && argv[i + 1][0] != '-') { destPath = argv[++i]; } namespace fs = std::filesystem; std::string outputDir, mapName; if (nameOrDir.find('/') != std::string::npos || nameOrDir[0] == '.') { fs::path p = fs::absolute(nameOrDir); outputDir = p.parent_path().string(); mapName = p.filename().string(); } else { mapName = nameOrDir; if (fs::exists("custom_zones/" + mapName)) outputDir = "custom_zones"; else if (fs::exists("output/" + mapName)) outputDir = "output"; else { std::fprintf(stderr, "--pack-wcp: zone '%s' not found in custom_zones/ or output/\n", mapName.c_str()); return 1; } } if (destPath.empty()) destPath = mapName + ".wcp"; wowee::editor::ContentPackInfo info; info.name = mapName; info.format = "wcp-1.0"; if (!wowee::editor::ContentPacker::packZone(outputDir, mapName, destPath, info)) { std::fprintf(stderr, "WCP pack failed for %s/%s\n", outputDir.c_str(), mapName.c_str()); return 1; } std::printf("WCP packed: %s\n", destPath.c_str()); return 0; } else if (std::strcmp(argv[i], "--unpack-wcp") == 0 && i + 1 < argc) { std::string wcpPath = argv[++i]; std::string destDir = "custom_zones"; if (i + 1 < argc && argv[i + 1][0] != '-') { destDir = argv[++i]; } if (!wowee::editor::ContentPacker::unpackZone(wcpPath, destDir)) { std::fprintf(stderr, "WCP unpack failed: %s\n", wcpPath.c_str()); return 1; } std::printf("WCP unpacked to: %s\n", destDir.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; 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; 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; 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; 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; 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) { printUsage(argv[0]); return 0; } } // Batch convert mode: --convert <m2path> converts M2 to WOM for (int i = 1; i < argc; i++) { if (std::strcmp(argv[i], "--convert-m2") == 0 && i + 1 < argc) { std::string m2Path = argv[++i]; std::printf("Converting M2→WOM: %s\n", m2Path.c_str()); if (dataPath.empty()) dataPath = "Data"; wowee::pipeline::AssetManager am; if (am.initialize(dataPath)) { auto wom = wowee::pipeline::WoweeModelLoader::fromM2(m2Path, &am); if (wom.isValid()) { std::string outPath = m2Path; auto dot = outPath.rfind('.'); if (dot != std::string::npos) outPath = outPath.substr(0, dot); wowee::pipeline::WoweeModelLoader::save(wom, "output/models/" + outPath); std::printf("OK: output/models/%s.wom (v%u, %zu verts, %zu bones, %zu batches)\n", outPath.c_str(), wom.version, wom.vertices.size(), wom.bones.size(), wom.batches.size()); } else { std::fprintf(stderr, "FAILED: %s\n", m2Path.c_str()); am.shutdown(); return 1; } am.shutdown(); } else { std::fprintf(stderr, "FAILED: cannot initialize asset manager\n"); return 1; } return 0; } } // Batch convert mode: --convert-wmo converts WMO to WOB for (int i = 1; i < argc; i++) { if (std::strcmp(argv[i], "--convert-wmo") == 0 && i + 1 < argc) { std::string wmoPath = argv[++i]; std::printf("Converting WMO→WOB: %s\n", wmoPath.c_str()); if (dataPath.empty()) dataPath = "Data"; wowee::pipeline::AssetManager am; if (am.initialize(dataPath)) { auto wmoData = am.readFile(wmoPath); if (!wmoData.empty()) { auto wmoModel = wowee::pipeline::WMOLoader::load(wmoData); if (wmoModel.nGroups > 0) { std::string wmoBase = wmoPath; if (wmoBase.size() > 4) wmoBase = wmoBase.substr(0, wmoBase.size() - 4); for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { char suffix[16]; snprintf(suffix, sizeof(suffix), "_%03u.wmo", gi); auto gd = am.readFile(wmoBase + suffix); if (!gd.empty()) wowee::pipeline::WMOLoader::loadGroup(gd, wmoModel, gi); } } auto wob = wowee::pipeline::WoweeBuildingLoader::fromWMO(wmoModel, wmoPath); if (wob.isValid()) { std::string outPath = wmoPath; auto dot = outPath.rfind('.'); if (dot != std::string::npos) outPath = outPath.substr(0, dot); wowee::pipeline::WoweeBuildingLoader::save(wob, "output/buildings/" + outPath); std::printf("OK: output/buildings/%s.wob (%zu groups)\n", outPath.c_str(), wob.groups.size()); } else { std::fprintf(stderr, "FAILED: %s\n", wmoPath.c_str()); am.shutdown(); return 1; } } else { std::fprintf(stderr, "FAILED: file not found: %s\n", wmoPath.c_str()); am.shutdown(); return 1; } am.shutdown(); } else { std::fprintf(stderr, "FAILED: cannot initialize asset manager\n"); return 1; } return 0; } if (std::strcmp(argv[i], "--convert-dbc-json") == 0 && i + 1 < argc) { // Standalone DBC -> JSON sidecar conversion. Mirrors what // asset_extract --emit-open does for one file at a time, so // designers don't have to re-run a full extraction just to // refresh one DBC sidecar. std::string dbcPath = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') { outPath = argv[++i]; } if (outPath.empty()) { outPath = dbcPath; if (outPath.size() >= 4 && outPath.substr(outPath.size() - 4) == ".dbc") { outPath = outPath.substr(0, outPath.size() - 4); } outPath += ".json"; } std::ifstream in(dbcPath, std::ios::binary); if (!in) { std::fprintf(stderr, "convert-dbc-json: cannot open %s\n", dbcPath.c_str()); return 1; } std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); wowee::pipeline::DBCFile dbc; if (!dbc.load(bytes)) { std::fprintf(stderr, "convert-dbc-json: failed to parse %s\n", dbcPath.c_str()); return 1; } // Same JSON schema asset_extract emits, so the editor's runtime // overlay loader picks the file up without changes. nlohmann::json j; j["format"] = "wowee-dbc-json-1.0"; j["source"] = std::filesystem::path(dbcPath).filename().string(); j["recordCount"] = dbc.getRecordCount(); j["fieldCount"] = dbc.getFieldCount(); nlohmann::json records = nlohmann::json::array(); for (uint32_t r = 0; r < dbc.getRecordCount(); ++r) { nlohmann::json row = nlohmann::json::array(); for (uint32_t f = 0; f < dbc.getFieldCount(); ++f) { // Same heuristic as open_format_emitter::emitJsonFromDbc: // prefer string > float > uint32 based on what the // bytes plausibly are. Round-trips through loadJSON. uint32_t val = dbc.getUInt32(r, f); std::string s = dbc.getString(r, f); if (!s.empty() && s[0] != '\0' && s.size() < 200) { row.push_back(s); } else { float fv = dbc.getFloat(r, f); if (val != 0 && fv != 0.0f && fv > -1e10f && fv < 1e10f && static_cast<uint32_t>(fv) != val) { row.push_back(fv); } else { row.push_back(val); } } } records.push_back(std::move(row)); } j["records"] = std::move(records); std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "convert-dbc-json: cannot write %s\n", outPath.c_str()); return 1; } out << j.dump(2) << "\n"; std::printf("Converted %s -> %s\n", dbcPath.c_str(), outPath.c_str()); std::printf(" %u records x %u fields\n", dbc.getRecordCount(), dbc.getFieldCount()); return 0; } if (std::strcmp(argv[i], "--convert-json-dbc") == 0 && i + 1 < argc) { // Reverse direction — JSON sidecar back to binary DBC. Useful // for shipping edited content to private servers (AzerothCore / // TrinityCore) which only consume binary DBC. The output is // byte-compatible with the original Blizzard format. std::string jsonPath = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') { outPath = argv[++i]; } if (outPath.empty()) { outPath = jsonPath; if (outPath.size() >= 5 && outPath.substr(outPath.size() - 5) == ".json") { outPath = outPath.substr(0, outPath.size() - 5); } outPath += ".dbc"; } std::ifstream in(jsonPath); if (!in) { std::fprintf(stderr, "convert-json-dbc: cannot open %s\n", jsonPath.c_str()); return 1; } nlohmann::json doc; try { in >> doc; } catch (const std::exception& e) { std::fprintf(stderr, "convert-json-dbc: bad JSON in %s (%s)\n", jsonPath.c_str(), e.what()); return 1; } uint32_t fieldCount = doc.value("fieldCount", 0u); if (!doc.contains("records") || !doc["records"].is_array()) { std::fprintf(stderr, "convert-json-dbc: missing 'records' array in %s\n", jsonPath.c_str()); return 1; } const auto& records = doc["records"]; uint32_t recordCount = static_cast<uint32_t>(records.size()); if (fieldCount == 0 && recordCount > 0 && records[0].is_array()) { // Tolerate JSON files that drop fieldCount — derive from row. fieldCount = static_cast<uint32_t>(records[0].size()); } if (fieldCount == 0) { std::fprintf(stderr, "convert-json-dbc: cannot determine fieldCount in %s\n", jsonPath.c_str()); return 1; } uint32_t recordSize = fieldCount * 4; // Build records + string block. Strings are deduped: identical // strings reuse the same offset in the block. The first byte // of the block is always '\0' so offset=0 means empty string, // matching Blizzard's convention. std::vector<uint8_t> recordBytes(recordCount * recordSize, 0); std::vector<uint8_t> stringBlock; stringBlock.push_back(0); // leading NUL — empty-string offset std::unordered_map<std::string, uint32_t> stringOffsets; stringOffsets[""] = 0; auto internString = [&](const std::string& s) -> uint32_t { if (s.empty()) return 0; auto it = stringOffsets.find(s); if (it != stringOffsets.end()) return it->second; uint32_t off = static_cast<uint32_t>(stringBlock.size()); for (char c : s) stringBlock.push_back(static_cast<uint8_t>(c)); stringBlock.push_back(0); stringOffsets[s] = off; return off; }; int convertErrors = 0; for (uint32_t r = 0; r < recordCount; ++r) { const auto& row = records[r]; if (!row.is_array() || row.size() != fieldCount) { convertErrors++; continue; } uint8_t* dst = recordBytes.data() + r * recordSize; for (uint32_t f = 0; f < fieldCount; ++f) { uint32_t val = 0; const auto& cell = row[f]; if (cell.is_string()) { val = internString(cell.get<std::string>()); } else if (cell.is_number_float()) { float fv = cell.get<float>(); std::memcpy(&val, &fv, 4); } else if (cell.is_number_unsigned()) { val = cell.get<uint32_t>(); } else if (cell.is_number_integer()) { // Negative ints reinterpret as uint32 (DBC has no // separate signed type; the consumer interprets). int32_t sv = cell.get<int32_t>(); std::memcpy(&val, &sv, 4); } else if (cell.is_boolean()) { val = cell.get<bool>() ? 1u : 0u; } else if (cell.is_null()) { val = 0; } else { convertErrors++; } // Little-endian write — DBC is always LE per Blizzard // format spec, regardless of host architecture. dst[f * 4 + 0] = val & 0xFF; dst[f * 4 + 1] = (val >> 8) & 0xFF; dst[f * 4 + 2] = (val >> 16) & 0xFF; dst[f * 4 + 3] = (val >> 24) & 0xFF; } } // Header: WDBC magic + 4 uint32s (recordCount, fieldCount, // recordSize, stringBlockSize). std::ofstream out(outPath, std::ios::binary); if (!out) { std::fprintf(stderr, "convert-json-dbc: cannot write %s\n", outPath.c_str()); return 1; } uint32_t header[5] = { 0x43424457u, // 'WDBC' little-endian recordCount, fieldCount, recordSize, static_cast<uint32_t>(stringBlock.size()) }; out.write(reinterpret_cast<const char*>(header), sizeof(header)); out.write(reinterpret_cast<const char*>(recordBytes.data()), recordBytes.size()); out.write(reinterpret_cast<const char*>(stringBlock.data()), stringBlock.size()); out.close(); std::printf("Converted %s -> %s\n", jsonPath.c_str(), outPath.c_str()); std::printf(" %u records x %u fields, %zu-byte string block\n", recordCount, fieldCount, stringBlock.size()); if (convertErrors > 0) { std::printf(" warning: %d cell(s) had unrecognized types\n", convertErrors); } return 0; } if (std::strcmp(argv[i], "--convert-blp-png") == 0 && i + 1 < argc) { // Standalone BLP -> PNG conversion. Same code path as // asset_extract --emit-open's per-file walker, but for one // texture without re-running a full extraction. std::string blpPath = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') { outPath = argv[++i]; } if (outPath.empty()) { outPath = blpPath; if (outPath.size() >= 4 && outPath.substr(outPath.size() - 4) == ".blp") { outPath = outPath.substr(0, outPath.size() - 4); } outPath += ".png"; } std::ifstream in(blpPath, std::ios::binary); if (!in) { std::fprintf(stderr, "convert-blp-png: cannot open %s\n", blpPath.c_str()); return 1; } std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); auto img = wowee::pipeline::BLPLoader::load(bytes); if (!img.isValid()) { std::fprintf(stderr, "convert-blp-png: failed to decode %s\n", blpPath.c_str()); return 1; } // Same dimension/buffer-size guards as the asset_extract // emitter so we never feed stbi_write_png an invalid buffer. const size_t expected = static_cast<size_t>(img.width) * img.height * 4; if (img.width <= 0 || img.height <= 0 || img.width > 8192 || img.height > 8192 || img.data.size() < expected) { std::fprintf(stderr, "convert-blp-png: invalid dimensions or data (%dx%d, %zu bytes)\n", img.width, img.height, img.data.size()); return 1; } // Ensure output directory exists; fs::create_directories with // an empty path is a no-op so we don't need to special-case // 'png in cwd'. std::filesystem::create_directories( std::filesystem::path(outPath).parent_path()); int rc = stbi_write_png(outPath.c_str(), img.width, img.height, 4, img.data.data(), img.width * 4); if (!rc) { std::fprintf(stderr, "convert-blp-png: stbi_write_png failed for %s\n", outPath.c_str()); return 1; } std::printf("Converted %s -> %s\n", blpPath.c_str(), outPath.c_str()); std::printf(" %dx%d, %zu bytes (RGBA8)\n", img.width, img.height, img.data.size()); return 0; } if (std::strcmp(argv[i], "--migrate-wom") == 0 && i + 1 < argc) { // Upgrade an older WOM (v1=static, v2=animated) to WOM3 by // adding a default single-batch entry that covers the whole // mesh. WOM3 is a strict superset; tooling that consumes // batches (--info-batches, --export-glb per-primitive split, // material-aware renderers) becomes useful on previously- // batchless content. The save() function picks WOM3 magic // automatically once batches.size() > 0. std::string base = argv[++i]; std::string outBase; if (i + 1 < argc && argv[i + 1][0] != '-') outBase = argv[++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, "WOM not found: %s.wom\n", base.c_str()); return 1; } if (outBase.empty()) outBase = base; auto wom = wowee::pipeline::WoweeModelLoader::load(base); if (!wom.isValid()) { std::fprintf(stderr, "migrate-wom: %s.wom has no geometry\n", base.c_str()); return 1; } int oldVersion = wom.version; int batchesAdded = 0; if (wom.batches.empty()) { // Single batch covering the entire index range with the // first texture (or 0 if no textures exist). Opaque // blend mode + no flags — safe defaults that match how // the renderer was treating the whole mesh implicitly. wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = wom.texturePaths.empty() ? 0 : 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); batchesAdded = 1; } // version field is recomputed inside save() based on // hasBatches/hasAnimation, so we don't need to set it here. if (!wowee::pipeline::WoweeModelLoader::save(wom, outBase)) { std::fprintf(stderr, "migrate-wom: failed to write %s.wom\n", outBase.c_str()); return 1; } // Re-load to verify the new version flag landed correctly. auto check = wowee::pipeline::WoweeModelLoader::load(outBase); std::printf("Migrated %s.wom -> %s.wom\n", base.c_str(), outBase.c_str()); std::printf(" version: %d -> %u batches: %zu -> %zu (added %d)\n", oldVersion, check.version, size_t(0), check.batches.size(), batchesAdded); if (batchesAdded == 0) { std::printf(" (already had batches; no schema change)\n"); } return 0; } if (std::strcmp(argv[i], "--migrate-zone") == 0 && i + 1 < argc) { // Batch-runs --migrate-wom in-place on every .wom under // a zone directory. Idempotent (already-migrated files // become no-ops). Useful when wowee_editor adds a new // WOM3-only feature and you want to upgrade legacy zones // in one shot. std::string zoneDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(zoneDir) || !fs::is_directory(zoneDir)) { std::fprintf(stderr, "migrate-zone: %s is not a directory\n", zoneDir.c_str()); return 1; } int scanned = 0, upgraded = 0, alreadyV3 = 0, failed = 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 != ".wom") continue; scanned++; std::string base = e.path().string(); if (base.size() >= 4) base = base.substr(0, base.size() - 4); auto wom = wowee::pipeline::WoweeModelLoader::load(base); if (!wom.isValid()) { failed++; continue; } if (!wom.batches.empty()) { alreadyV3++; continue; } wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); if (wowee::pipeline::WoweeModelLoader::save(wom, base)) { upgraded++; std::printf(" upgraded: %s.wom\n", base.c_str()); } else { failed++; std::fprintf(stderr, " FAILED: %s.wom\n", base.c_str()); } } std::printf("\nmigrate-zone: %s\n", zoneDir.c_str()); std::printf(" scanned : %d WOM file(s)\n", scanned); std::printf(" upgraded : %d (added single-batch entry)\n", upgraded); std::printf(" already v3: %d (no change needed)\n", alreadyV3); if (failed > 0) { std::printf(" FAILED : %d (see stderr)\n", failed); } return failed == 0 ? 0 : 1; } if (std::strcmp(argv[i], "--migrate-project") == 0 && i + 1 < argc) { // Project-level wrapper around --migrate-zone. Walks every // zone in <projectDir> and upgrades legacy WOMs in-place. // Idempotent — already-migrated files become no-ops, safe to // run repeatedly. std::string projectDir = argv[++i]; namespace fs = std::filesystem; if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) { std::fprintf(stderr, "migrate-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()); int totalScanned = 0, totalUpgraded = 0, totalAlreadyV3 = 0, totalFailed = 0; // Per-zone breakdown for the summary table. struct ZRow { std::string name; int scanned, upgraded, alreadyV3, failed; }; std::vector<ZRow> rows; for (const auto& zoneDir : zones) { ZRow r{fs::path(zoneDir).filename().string(), 0, 0, 0, 0}; 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; r.scanned++; std::string base = e.path().string(); if (base.size() >= 4) base = base.substr(0, base.size() - 4); auto wom = wowee::pipeline::WoweeModelLoader::load(base); if (!wom.isValid()) { r.failed++; continue; } if (!wom.batches.empty()) { r.alreadyV3++; continue; } wowee::pipeline::WoweeModel::Batch b; b.indexStart = 0; b.indexCount = static_cast<uint32_t>(wom.indices.size()); b.textureIndex = 0; b.blendMode = 0; b.flags = 0; wom.batches.push_back(b); if (wowee::pipeline::WoweeModelLoader::save(wom, base)) { r.upgraded++; } else { r.failed++; } } totalScanned += r.scanned; totalUpgraded += r.upgraded; totalAlreadyV3 += r.alreadyV3; totalFailed += r.failed; rows.push_back(r); } std::printf("migrate-project: %s\n", projectDir.c_str()); std::printf(" zones : %zu\n", zones.size()); std::printf(" totals : %d scanned, %d upgraded, %d already-v3, %d failed\n", totalScanned, totalUpgraded, totalAlreadyV3, totalFailed); if (!rows.empty()) { std::printf("\n zone scan upgrade v3 failed\n"); for (const auto& r : rows) { std::printf(" %-26s %4d %5d %3d %5d\n", r.name.substr(0, 26).c_str(), r.scanned, r.upgraded, r.alreadyV3, r.failed); } } return totalFailed == 0 ? 0 : 1; } if (std::strcmp(argv[i], "--migrate-jsondbc") == 0 && i + 1 < argc) { // Auto-fix common schema problems in JSON DBC sidecars so they // pass --validate-jsondbc cleanly. Designed for upgrading // sidecars produced by older asset_extract versions or from // third-party tools that omit fields the runtime now expects: // - missing 'format' tag → add 'wowee-dbc-json-1.0' // - missing 'source' field → derive from filename // - missing 'fieldCount' → infer from first row // - recordCount mismatch → recompute from actual records[] // Wrong-width rows are not silently fixed (data loss risk); // they're surfaced as warnings so the user can decide. std::string path = argv[++i]; std::string outPath; if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; if (outPath.empty()) outPath = path; // in-place std::ifstream in(path); if (!in) { std::fprintf(stderr, "migrate-jsondbc: cannot open %s\n", path.c_str()); return 1; } nlohmann::json doc; try { in >> doc; } catch (const std::exception& e) { std::fprintf(stderr, "migrate-jsondbc: bad JSON in %s (%s)\n", path.c_str(), e.what()); return 1; } in.close(); if (!doc.is_object()) { std::fprintf(stderr, "migrate-jsondbc: top-level value is not an object\n"); return 1; } int fixes = 0; if (!doc.contains("format") || !doc["format"].is_string()) { doc["format"] = "wowee-dbc-json-1.0"; fixes++; std::printf(" added: format = 'wowee-dbc-json-1.0'\n"); } else if (doc["format"] != "wowee-dbc-json-1.0") { std::printf(" retained existing format: '%s' (not changed)\n", doc["format"].get<std::string>().c_str()); } if (!doc.contains("source") || !doc["source"].is_string() || doc["source"].get<std::string>().empty()) { // Derive from input path's stem + .dbc — best-effort // matching the convention asset_extract uses. std::string stem = std::filesystem::path(path).stem().string(); doc["source"] = stem + ".dbc"; fixes++; std::printf(" added: source = '%s'\n", doc["source"].get<std::string>().c_str()); } // recordCount + fieldCount are non-negotiable for re-import. if (!doc.contains("records") || !doc["records"].is_array()) { std::fprintf(stderr, "migrate-jsondbc: 'records' missing or not an array — cannot fix\n"); return 1; } const auto& records = doc["records"]; uint32_t actualCount = static_cast<uint32_t>(records.size()); uint32_t headerCount = doc.value("recordCount", 0u); if (headerCount != actualCount) { doc["recordCount"] = actualCount; fixes++; std::printf(" fixed: recordCount %u -> %u (matches actual)\n", headerCount, actualCount); } // Infer fieldCount from first row if missing. if (!doc.contains("fieldCount") || !doc["fieldCount"].is_number_integer()) { if (!records.empty() && records[0].is_array()) { uint32_t inferred = static_cast<uint32_t>(records[0].size()); doc["fieldCount"] = inferred; fixes++; std::printf(" inferred: fieldCount = %u (from first row)\n", inferred); } } // Surface wrong-width rows as warnings (no auto-fix). uint32_t fc = doc.value("fieldCount", 0u); int badRows = 0; for (size_t r = 0; r < records.size(); ++r) { if (records[r].is_array() && records[r].size() != fc) { if (++badRows <= 3) { std::printf(" WARN: row %zu has %zu cells, expected %u\n", r, records[r].size(), fc); } } } if (badRows > 3) { std::printf(" WARN: ... and %d more wrong-width rows\n", badRows - 3); } std::ofstream out(outPath); if (!out) { std::fprintf(stderr, "migrate-jsondbc: cannot write %s\n", outPath.c_str()); return 1; } out << doc.dump(2) << "\n"; out.close(); std::printf("Migrated %s -> %s\n", path.c_str(), outPath.c_str()); std::printf(" fixes applied: %d\n", fixes); if (badRows > 0) { std::printf(" warnings : %d wrong-width rows (NOT auto-fixed)\n", badRows); } 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; }