mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-08 10:03:51 +00:00
Estimated bytes-per-category breakdown for a single WOM. Numbers are based on the in-memory struct sizes (vertex 40B, index 4B, bone 22B, batch 16B, anim keyframe 44B, plus texture path strings) — 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. Surfaces actionable tips when a category dominates: animations > vertices → suggest --strip-mesh --anims with the saved KB; bones non-trivial → suggest --bones for static placement; vertices dominate → suggest a lower-poly variant. Verified on procedural cube: 960B vertices (85%) + 144B indices (13%) + 16B batches + 8B textures, total 1128 estimated vs 1184 on-disk (~5% framing overhead). Tip correctly fires for vertex-dominated mesh. Brings command count to 231.
20921 lines
1 MiB
20921 lines
1 MiB
#include "editor_app.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 <filesystem>
|
||
#include <fstream>
|
||
#include <sstream>
|
||
#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 <string>
|
||
#include <cstdio>
|
||
#include <cstring>
|
||
#include <unordered_map>
|
||
#include <unordered_set>
|
||
#include <map>
|
||
#include <set>
|
||
#include <cctype>
|
||
#include <cstdio>
|
||
#include <chrono>
|
||
#include <functional>
|
||
#include <memory>
|
||
#include <algorithm>
|
||
#include <nlohmann/json.hpp>
|
||
#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<const uint8_t*>(chunk),
|
||
static_cast<size_t>(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<std::string> validateWomErrors(
|
||
const wowee::pipeline::WoweeModel& wom) {
|
||
std::vector<std::string> 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<int16_t>(wom.bones.size())) {
|
||
errors.push_back("bone " + std::to_string(b) +
|
||
" parent=" + std::to_string(p) +
|
||
" out of range");
|
||
} else if (p >= static_cast<int16_t>(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<std::string> validateWobErrors(
|
||
const wowee::pipeline::WoweeBuilding& bld) {
|
||
std::vector<std::string> 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<int>(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<std::string> validateWocErrors(
|
||
const wowee::pipeline::WoweeCollision& woc) {
|
||
std::vector<std::string> 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<std::string> validateWhmErrors(
|
||
const wowee::pipeline::ADTTerrain& terrain) {
|
||
std::vector<std::string> 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 <path> [options]\n\n", argv0);
|
||
std::printf("Options:\n");
|
||
std::printf(" --data <path> Path to extracted WoW data (manifest.json)\n");
|
||
std::printf(" --adt <map> <x> <y> Load an ADT tile on startup\n");
|
||
std::printf(" --convert-m2 <path> Convert M2 model to WOM open format (no GUI)\n");
|
||
std::printf(" --convert-m2-batch <srcDir>\n");
|
||
std::printf(" Bulk M2→WOM conversion across every .m2 in <srcDir> (per-file pass/fail summary)\n");
|
||
std::printf(" --convert-wmo <path> Convert WMO building to WOB open format (no GUI)\n");
|
||
std::printf(" --convert-wmo-batch <srcDir>\n");
|
||
std::printf(" Bulk WMO→WOB conversion across every .wmo in <srcDir> (skips _NNN group files)\n");
|
||
std::printf(" --convert-dbc-batch <srcDir>\n");
|
||
std::printf(" Bulk DBC→JSON conversion across every .dbc in <srcDir> (sidecars next to source)\n");
|
||
std::printf(" --migrate-data-tree <srcDir>\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 <srcDir> [--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 <srcDir> [--dry-run]\n");
|
||
std::printf(" Delete proprietary files (.m2/.wmo/.blp/.dbc) that already have an open sidecar\n");
|
||
std::printf(" --audit-data-tree <srcDir>\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 <srcDir> [--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 <srcDir> [N]\n");
|
||
std::printf(" Top-N largest proprietary files (.m2/.wmo/.blp/.dbc) for migration prioritization\n");
|
||
std::printf(" --export-data-tree-md <srcDir> [out.md]\n");
|
||
std::printf(" Markdown migration-progress report (per-pair table, share %%, recommended next steps)\n");
|
||
std::printf(" --gen-texture <out.png> <colorHex|pattern> [W H]\n");
|
||
std::printf(" Synthesize a placeholder texture (solid hex color or 'checker'/'grid'); default 256x256\n");
|
||
std::printf(" --gen-texture-gradient <out.png> <fromHex> <toHex> [vertical|horizontal] [W H]\n");
|
||
std::printf(" Synthesize a linear gradient PNG (default vertical, 256x256)\n");
|
||
std::printf(" --gen-texture-noise <out.png> [seed] [W H]\n");
|
||
std::printf(" Synthesize a smooth value-noise PNG (deterministic from seed; default 256x256)\n");
|
||
std::printf(" --gen-texture-radial <out.png> <centerHex> <edgeHex> [W H]\n");
|
||
std::printf(" Synthesize a radial gradient PNG (center→edge, smooth distance-based blend)\n");
|
||
std::printf(" --gen-texture-stripes <out.png> <colorAHex> <colorBHex> [stripePx] [diagonal|horizontal|vertical] [W H]\n");
|
||
std::printf(" Synthesize a two-color stripe pattern (default 16px diagonal, 256x256)\n");
|
||
std::printf(" --add-texture-to-zone <zoneDir> <png-path> [renameTo]\n");
|
||
std::printf(" Copy an existing PNG into <zoneDir> (optionally renaming it on the way in)\n");
|
||
std::printf(" --gen-mesh <wom-base> <cube|plane|sphere|cylinder|torus|cone> [size]\n");
|
||
std::printf(" Synthesize a procedural WOM primitive with proper normals, UVs, and bounds\n");
|
||
std::printf(" --gen-mesh-textured <wom-base> <cube|plane|sphere|cylinder|torus|cone> <colorHex|pattern> [size]\n");
|
||
std::printf(" Compose a procedural mesh + matching PNG texture wired into the WOM's batch\n");
|
||
std::printf(" --gen-mesh-stairs <wom-base> <steps> [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-from-heightmap <wom-base> <heightmap.png> [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 <wom-base> <out.png> <W> <H>\n");
|
||
std::printf(" Extract a grayscale heightmap PNG from a row-major W×H heightmap mesh\n");
|
||
std::printf(" --add-texture-to-mesh <wom-base> <png-path> [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 <wom-base> <factor>\n");
|
||
std::printf(" Uniformly scale every vertex and bounds by <factor> (factor > 0)\n");
|
||
std::printf(" --translate-mesh <wom-base> <dx> <dy> <dz>\n");
|
||
std::printf(" Offset every vertex and bounds by (dx, dy, dz)\n");
|
||
std::printf(" --strip-mesh <wom-base> [--bones] [--anims] [--all]\n");
|
||
std::printf(" Drop bones / animations from a WOM in place (smaller file, static-only use)\n");
|
||
std::printf(" --rotate-mesh <wom-base> <x|y|z> <degrees>\n");
|
||
std::printf(" Rotate every vertex + normal around the chosen axis by <degrees>\n");
|
||
std::printf(" --center-mesh <wom-base>\n");
|
||
std::printf(" Translate so the bounds center lands at origin (no scale/rotation change)\n");
|
||
std::printf(" --flip-mesh-normals <wom-base>\n");
|
||
std::printf(" Invert every vertex normal (use for inside-out meshes or two-sided pre-flip)\n");
|
||
std::printf(" --mirror-mesh <wom-base> <x|y|z>\n");
|
||
std::printf(" Mirror every vertex + normal across the chosen axis (also flips winding)\n");
|
||
std::printf(" --smooth-mesh-normals <wom-base>\n");
|
||
std::printf(" Recompute per-vertex normals as area-weighted averages of incident face normals\n");
|
||
std::printf(" --merge-meshes <a-base> <b-base> <out-base>\n");
|
||
std::printf(" Combine two WOMs into one (vertex/index buffers concatenated, batches preserved)\n");
|
||
std::printf(" --add-item <zoneDir> <name> [id] [quality] [displayId] [itemLevel]\n");
|
||
std::printf(" Append one item entry to <zoneDir>/items.json (auto-creates the file)\n");
|
||
std::printf(" --list-items <zoneDir> [--json]\n");
|
||
std::printf(" Print every item in <zoneDir>/items.json with quality colors and key fields\n");
|
||
std::printf(" --export-zone-items-md <zoneDir> [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 <projectDir> [out.md]\n");
|
||
std::printf(" Project-wide items markdown: per-zone sections, project quality histogram\n");
|
||
std::printf(" --export-project-items-csv <projectDir> [out.csv]\n");
|
||
std::printf(" Single CSV with every item across every zone (zone column added for grouping)\n");
|
||
std::printf(" --info-item <zoneDir> <id|index> [--json]\n");
|
||
std::printf(" Detail view for one item (lookup by id, or by index if prefixed with '#')\n");
|
||
std::printf(" --set-item <zoneDir> <id|#index> [--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 <zoneDir> <index>\n");
|
||
std::printf(" Remove item at given 0-based index from <zoneDir>/items.json\n");
|
||
std::printf(" --copy-zone-items <fromZoneDir> <toZoneDir> [--merge]\n");
|
||
std::printf(" Copy items from one zone to another (default replaces; --merge appends with re-id)\n");
|
||
std::printf(" --clone-item <zoneDir> <index> [newName]\n");
|
||
std::printf(" Duplicate the item at index, assign next free id (and optional name override)\n");
|
||
std::printf(" --validate-items <zoneDir>\n");
|
||
std::printf(" Schema check on items.json: duplicate ids, quality range, required fields\n");
|
||
std::printf(" --validate-project-items <projectDir>\n");
|
||
std::printf(" Run --validate-items across every zone (per-zone PASS/FAIL + aggregate)\n");
|
||
std::printf(" --info-project-items <projectDir> [--json]\n");
|
||
std::printf(" Aggregate item counts and quality histogram across every zone in a project\n");
|
||
std::printf(" --convert-dbc-json <dbc-path> [out.json]\n");
|
||
std::printf(" Convert one DBC file to wowee JSON sidecar format\n");
|
||
std::printf(" --convert-json-dbc <json-path> [out.dbc]\n");
|
||
std::printf(" Convert a wowee JSON DBC back to binary DBC for private-server compat\n");
|
||
std::printf(" --convert-blp-png <blp-path> [out.png]\n");
|
||
std::printf(" Convert one BLP texture to PNG sidecar\n");
|
||
std::printf(" --convert-blp-batch <srcDir>\n");
|
||
std::printf(" Bulk BLP→PNG conversion across every .blp in <srcDir> (sidecars next to source)\n");
|
||
std::printf(" --migrate-wom <wom-base> [out-base]\n");
|
||
std::printf(" Upgrade an older WOM (v1/v2) to WOM3 with a default single-batch entry\n");
|
||
std::printf(" --migrate-zone <zoneDir>\n");
|
||
std::printf(" Run --migrate-wom in-place on every WOM under <zoneDir>\n");
|
||
std::printf(" --migrate-project <projectDir>\n");
|
||
std::printf(" Run --migrate-zone across every zone in <projectDir>\n");
|
||
std::printf(" --migrate-jsondbc <path> [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 <projectDir> [--json]\n");
|
||
std::printf(" Aggregate counts across every zone in <projectDir>\n");
|
||
std::printf(" --info-tilemap <projectDir> [--json]\n");
|
||
std::printf(" ASCII-render the 64x64 WoW ADT grid showing tile claims by zone\n");
|
||
std::printf(" --list-project-orphans <projectDir> [--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 <projectDir> [--dry-run]\n");
|
||
std::printf(" Delete the orphan .wom/.wob files surfaced by --list-project-orphans\n");
|
||
std::printf(" --list-zone-deps <zoneDir> [--json]\n");
|
||
std::printf(" List external M2/WMO model paths a zone references (objects + WOB doodads)\n");
|
||
std::printf(" --export-zone-deps-md <zoneDir> [out.md]\n");
|
||
std::printf(" Markdown dep table for a zone (with on-disk presence column)\n");
|
||
std::printf(" --export-zone-spawn-png <zoneDir> [out.png]\n");
|
||
std::printf(" Top-down PNG of creature + object spawn positions (per-tile-bounded)\n");
|
||
std::printf(" --check-zone-refs <zoneDir> [--json]\n");
|
||
std::printf(" Verify every referenced model/quest NPC actually exists; exit 1 on missing refs\n");
|
||
std::printf(" --check-project-refs <projectDir> [--json]\n");
|
||
std::printf(" Run --check-zone-refs across every zone in <projectDir>\n");
|
||
std::printf(" --check-zone-content <zoneDir> [--json]\n");
|
||
std::printf(" Sanity-check creature/object/quest fields for plausible values\n");
|
||
std::printf(" --check-project-content <projectDir> [--json]\n");
|
||
std::printf(" Run --check-zone-content across every zone in <projectDir>\n");
|
||
std::printf(" --for-each-zone <projectDir> -- <cmd...>\n");
|
||
std::printf(" Run <cmd...> for every zone in <projectDir>; '{}' in cmd is replaced with the zone path\n");
|
||
std::printf(" --for-each-tile <zoneDir> -- <cmd...>\n");
|
||
std::printf(" Run <cmd...> for every tile in <zoneDir>; '{}' replaced with the tile-base path\n");
|
||
std::printf(" --scaffold-zone <name> [tx ty] Create a blank zone in custom_zones/<name>/ and exit\n");
|
||
std::printf(" --mvp-zone <name> [tx ty]\n");
|
||
std::printf(" Scaffold + add a creature + object + quest (with objective+reward) for quick demos\n");
|
||
std::printf(" --add-tile <zoneDir> <tx> <ty> [baseHeight]\n");
|
||
std::printf(" Add a new ADT tile to an existing zone (extends the manifest's tiles list)\n");
|
||
std::printf(" --remove-tile <zoneDir> <tx> <ty>\n");
|
||
std::printf(" Remove a tile from a zone (drops manifest entry + deletes WHM/WOT/WOC files)\n");
|
||
std::printf(" --list-tiles <zoneDir> [--json]\n");
|
||
std::printf(" List every tile in a zone manifest with on-disk file presence\n");
|
||
std::printf(" --add-creature <zoneDir> <name> <x> <y> <z> [displayId] [level]\n");
|
||
std::printf(" Append one creature spawn to <zoneDir>/creatures.json and exit\n");
|
||
std::printf(" --add-object <zoneDir> <m2|wmo> <gamePath> <x> <y> <z> [scale]\n");
|
||
std::printf(" Append one object placement to <zoneDir>/objects.json and exit\n");
|
||
std::printf(" --add-quest <zoneDir> <title> [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(" --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-textures <projectDir> [--json]\n");
|
||
std::printf(" Aggregate texture refs across every WOM in a project (deduped, with zone breakdown)\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 <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-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-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-textures",
|
||
"--list-project-textures",
|
||
"--info-zone-models-total", "--info-project-models-total",
|
||
"--list-zone-meshes", "--list-project-meshes", "--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-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-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-texture-gradient",
|
||
"--gen-mesh-from-heightmap", "--export-mesh-heightmap",
|
||
"--scale-mesh", "--translate-mesh", "--strip-mesh",
|
||
"--gen-texture-noise", "--rotate-mesh",
|
||
"--center-mesh", "--flip-mesh-normals", "--mirror-mesh",
|
||
"--smooth-mesh-normals",
|
||
"--merge-meshes",
|
||
"--gen-texture-radial", "--gen-texture-stripes",
|
||
"--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",
|
||
"--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++) {
|
||
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-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-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") == 0 && i + 1 < argc) {
|
||
// Project-wide companion to --list-zone-meshes. Walks
|
||
// every zone in <projectDir>, collects every .wom across
|
||
// all zones, 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.
|
||
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 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-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</title>\n"
|
||
" <script type=\"module\" "
|
||
"src=\"https://unpkg.com/@google/model-viewer@^4.0.0/dist/model-viewer.min.js\">"
|
||
"</script>\n"
|
||
" <style>\n"
|
||
" body { margin:0; font-family: sans-serif; background:#1a1a1a; color:#eee; }\n"
|
||
" header { padding:12px 20px; background:#2a2a2a; border-bottom:1px solid #444; }\n"
|
||
" h1 { margin:0; font-size:18px; font-weight:500; }\n"
|
||
" .meta { color:#aaa; font-size:13px; margin-top:4px; }\n"
|
||
" model-viewer { width:100vw; height:calc(100vh - 60px); background:#1a1a1a; }\n"
|
||
" .footer { position:fixed; bottom:8px; right:12px; color:#666; font-size:11px; }\n"
|
||
" </style>\n"
|
||
"</head>\n"
|
||
"<body>\n"
|
||
" <header>\n"
|
||
" <h1>" << title << "</h1>\n"
|
||
" <div class=\"meta\">Map: <code>" << zm.mapName
|
||
<< "</code> · Tiles: " << zm.tiles.size()
|
||
<< " · MapId: " << zm.mapId << "</div>\n"
|
||
" </header>\n"
|
||
" <model-viewer\n"
|
||
" src=\"" << glbHref << "\"\n"
|
||
" alt=\"" << title << " terrain\"\n"
|
||
" camera-controls\n"
|
||
" auto-rotate\n"
|
||
" rotation-per-second=\"15deg\"\n"
|
||
" shadow-intensity=\"1\"\n"
|
||
" exposure=\"1.2\"\n"
|
||
" environment-image=\"neutral\">\n"
|
||
" </model-viewer>\n"
|
||
" <div class=\"footer\">Generated by wowee_editor --export-zone-html</div>\n"
|
||
"</body>\n"
|
||
"</html>\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<ZoneEntry> 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<int>(zm.tiles.size());
|
||
wowee::editor::NpcSpawner sp;
|
||
if (sp.loadFromFile((entry.path() / "creatures.json").string())) {
|
||
ze.creatures = static_cast<int>(sp.spawnCount());
|
||
}
|
||
wowee::editor::ObjectPlacer op;
|
||
if (op.loadFromFile((entry.path() / "objects.json").string())) {
|
||
ze.objects = static_cast<int>(op.getObjects().size());
|
||
}
|
||
wowee::editor::QuestEditor qe;
|
||
if (qe.loadFromFile((entry.path() / "quests.json").string())) {
|
||
ze.quests = static_cast<int>(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 << "<!doctype html>\n"
|
||
"<html lang=\"en\">\n"
|
||
"<head>\n"
|
||
" <meta charset=\"utf-8\">\n"
|
||
" <title>Wowee Project — Zone Index</title>\n"
|
||
" <style>\n"
|
||
" body { margin:0; font-family: sans-serif; background:#1a1a1a; color:#eee; padding:20px; }\n"
|
||
" h1 { margin:0 0 8px; font-size:22px; }\n"
|
||
" .count { color:#aaa; font-size:14px; margin-bottom:24px; }\n"
|
||
" .zones { display:grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap:16px; }\n"
|
||
" .zone { background:#2a2a2a; border:1px solid #444; border-radius:6px; padding:14px; }\n"
|
||
" .zone h3 { margin:0 0 6px; font-size:16px; }\n"
|
||
" .zone .stats { color:#aaa; font-size:13px; }\n"
|
||
" .zone a { color:#7af; text-decoration:none; font-size:13px; display:inline-block; margin-top:8px; }\n"
|
||
" .zone a:hover { text-decoration:underline; }\n"
|
||
" .zone .nolink { color:#666; font-style:italic; font-size:13px; margin-top:8px; }\n"
|
||
" .footer { margin-top:30px; color:#666; font-size:11px; }\n"
|
||
" </style>\n"
|
||
"</head>\n"
|
||
"<body>\n"
|
||
" <h1>Wowee Project — Zone Index</h1>\n"
|
||
" <div class=\"count\">" << entries.size() << " zone(s) found in <code>"
|
||
<< projectDir << "</code></div>\n"
|
||
" <div class=\"zones\">\n";
|
||
for (const auto& z : entries) {
|
||
out << " <div class=\"zone\">\n"
|
||
" <h3>" << z.name << "</h3>\n"
|
||
" <div class=\"stats\">"
|
||
<< 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") << "</div>\n";
|
||
if (z.htmlExists) {
|
||
out << " <a href=\"" << z.htmlRel << "\">Open viewer →</a>\n";
|
||
} else if (z.glbExists) {
|
||
out << " <div class=\"nolink\">No HTML viewer (run --export-zone-html)</div>\n";
|
||
} else {
|
||
out << " <div class=\"nolink\">No .glb (run --bake-zone-glb)</div>\n";
|
||
}
|
||
out << " </div>\n";
|
||
}
|
||
out << " </div>\n"
|
||
" <div class=\"footer\">Generated by wowee_editor --export-project-html</div>\n"
|
||
"</body>\n"
|
||
"</html>\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<Row> 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<int>(zm.tiles.size());
|
||
wowee::editor::NpcSpawner sp;
|
||
if (sp.loadFromFile((entry.path() / "creatures.json").string())) {
|
||
r.creatures = static_cast<int>(sp.spawnCount());
|
||
}
|
||
wowee::editor::ObjectPlacer op;
|
||
if (op.loadFromFile((entry.path() / "objects.json").string())) {
|
||
r.objects = static_cast<int>(op.getObjects().size());
|
||
}
|
||
wowee::editor::QuestEditor qe;
|
||
if (qe.loadFromFile((entry.path() / "quests.json").string())) {
|
||
r.quests = static_cast<int>(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<uint32_t> 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=\"<missing> [" << 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<std::pair<std::string, std::vector<std::string>>> failures;
|
||
auto recordFailure = [&](const std::string& path,
|
||
const std::vector<std::string>& 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 <projectDir>
|
||
// 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<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());
|
||
// Per-zone pass/fail with file-level breakdown.
|
||
struct ZoneResult { std::string name; int totalFiles, failedFiles, totalErrors; };
|
||
std::vector<ZoneResult> 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<std::string> 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<int>(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 <projectDir> 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<std::string> propExt = {
|
||
".m2", ".skin", ".wmo", ".blp", ".dbc",
|
||
};
|
||
std::map<std::string, int> byExt;
|
||
std::vector<std::string> 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:
|
||
// 1. validate-project (per-format integrity)
|
||
// 2. validate-project-open-only (no proprietary leaks)
|
||
// 3. check-project-refs (every model/NPC ref resolves)
|
||
// 4. check-project-content (sane field values)
|
||
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<Step> steps = {
|
||
{"format validation ", "--validate-project", 0},
|
||
{"open-only release gate ", "--validate-project-open-only", 0},
|
||
{"reference integrity ", "--check-project-refs", 0},
|
||
{"content field sanity ", "--check-project-content", 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-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<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());
|
||
// 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<Timing> 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<double, std::milli>(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<std::string> zones;
|
||
for (const auto& entry : fs::directory_iterator(projectDir)) {
|
||
if (!entry.is_directory()) continue;
|
||
if (!fs::exists(entry.path() / "zone.json")) continue;
|
||
zones.push_back(entry.path().string());
|
||
}
|
||
std::sort(zones.begin(), zones.end());
|
||
struct Timing {
|
||
std::string name;
|
||
int tiles;
|
||
double loadMs;
|
||
int chunks;
|
||
};
|
||
std::vector<Timing> timings;
|
||
double totalMs = 0;
|
||
for (const auto& zoneDir : zones) {
|
||
wowee::editor::ZoneManifest zm;
|
||
if (!zm.load(zoneDir + "/zone.json")) continue;
|
||
Timing t{fs::path(zoneDir).filename().string(), 0, 0.0, 0};
|
||
auto t0 = std::chrono::steady_clock::now();
|
||
for (const auto& [tx, ty] : zm.tiles) {
|
||
std::string base = zoneDir + "/" + zm.mapName + "_" +
|
||
std::to_string(tx) + "_" + std::to_string(ty);
|
||
if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) continue;
|
||
wowee::pipeline::ADTTerrain terrain;
|
||
wowee::pipeline::WoweeTerrainLoader::load(base, terrain);
|
||
t.tiles++;
|
||
for (const auto& chunk : terrain.chunks) {
|
||
if (chunk.heightMap.isLoaded()) t.chunks++;
|
||
}
|
||
}
|
||
auto t1 = std::chrono::steady_clock::now();
|
||
t.loadMs = std::chrono::duration<double, std::milli>(t1 - t0).count();
|
||
totalMs += t.loadMs;
|
||
timings.push_back(t);
|
||
}
|
||
double avgMs = !timings.empty() ? totalMs / timings.size() : 0.0;
|
||
double minMs = 1e30, maxMs = 0;
|
||
std::string slowest;
|
||
for (const auto& t : timings) {
|
||
if (t.loadMs < minMs) minMs = t.loadMs;
|
||
if (t.loadMs > maxMs) { maxMs = t.loadMs; slowest = t.name; }
|
||
}
|
||
if (timings.empty()) { minMs = 0; maxMs = 0; }
|
||
if (jsonOut) {
|
||
nlohmann::json j;
|
||
j["projectDir"] = projectDir;
|
||
j["totalMs"] = totalMs;
|
||
j["zoneCount"] = timings.size();
|
||
j["avgMs"] = avgMs;
|
||
j["minMs"] = minMs;
|
||
j["maxMs"] = maxMs;
|
||
j["slowestZone"] = slowest;
|
||
nlohmann::json arr = nlohmann::json::array();
|
||
for (const auto& t : timings) {
|
||
arr.push_back({{"zone", t.name},
|
||
{"loadMs", t.loadMs},
|
||
{"tiles", t.tiles},
|
||
{"chunks", t.chunks}});
|
||
}
|
||
j["perZone"] = arr;
|
||
std::printf("%s\n", j.dump(2).c_str());
|
||
return 0;
|
||
}
|
||
std::printf("Bench bake (load-only): %s\n", projectDir.c_str());
|
||
std::printf(" zones : %zu\n", timings.size());
|
||
std::printf(" total : %.2f ms (terrain load)\n", totalMs);
|
||
std::printf(" per zone : avg=%.2f min=%.2f max=%.2f ms\n",
|
||
avgMs, minMs, maxMs);
|
||
if (!slowest.empty()) {
|
||
std::printf(" slowest : %s (%.2f ms)\n", slowest.c_str(), maxMs);
|
||
}
|
||
std::printf("\n Per-zone:\n");
|
||
std::printf(" zone ms tiles chunks ms/tile\n");
|
||
for (const auto& t : timings) {
|
||
double mspt = t.tiles > 0 ? t.loadMs / t.tiles : 0.0;
|
||
std::printf(" %-26s %7.2f %5d %5d %6.2f\n",
|
||
t.name.substr(0, 26).c_str(),
|
||
t.loadMs, t.tiles, t.chunks, mspt);
|
||
}
|
||
return 0;
|
||
} else if ((std::strcmp(argv[i], "--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<uint8_t> bytes((std::istreambuf_iterator<char>(in)),
|
||
std::istreambuf_iterator<char>());
|
||
std::vector<std::string> 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<uint8_t> 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<int>(gj["meshes"].size());
|
||
for (const auto& m : gj["meshes"]) {
|
||
if (m.contains("primitives") && m["primitives"].is_array()) {
|
||
primitiveCount += static_cast<int>(m["primitives"].size());
|
||
}
|
||
}
|
||
}
|
||
if (gj.contains("accessors") && gj["accessors"].is_array()) {
|
||
accessorCount = static_cast<int>(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<int>(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<int>(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<int>(gj["buffers"].size());
|
||
}
|
||
} catch (const std::exception& e) {
|
||
errors.push_back(std::string("JSON parse error: ") + e.what());
|
||
}
|
||
}
|
||
int errorCount = static_cast<int>(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<uint8_t> bytes((std::istreambuf_iterator<char>(in)),
|
||
std::istreambuf_iterator<char>());
|
||
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<int>(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<unsigned long long>(bl));
|
||
}
|
||
}
|
||
// BufferViews
|
||
int nBV = (gj.contains("bufferViews") && gj["bufferViews"].is_array())
|
||
? static_cast<int>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int>());
|
||
}
|
||
}
|
||
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<uint8_t> bytes((std::istreambuf_iterator<char>(in)),
|
||
std::istreambuf_iterator<char>());
|
||
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<BV> 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<int>(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<std::string, uint64_t> bytesByPurpose;
|
||
if (gj.contains("accessors") && gj["accessors"].is_array() &&
|
||
gj.contains("meshes") && gj["meshes"].is_array()) {
|
||
std::set<int> 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<int>();
|
||
if (seenAccessors.count(ai)) continue;
|
||
seenAccessors.insert(ai);
|
||
if (ai < 0 || ai >= static_cast<int>(gj["accessors"].size())) continue;
|
||
const auto& acc = gj["accessors"][ai];
|
||
int bv = acc.value("bufferView", -1);
|
||
if (bv < 0 || bv >= static_cast<int>(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<int>();
|
||
if (seenAccessors.count(ai)) continue;
|
||
seenAccessors.insert(ai);
|
||
if (ai < 0 || ai >= static_cast<int>(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<unsigned long long>(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<unsigned long long>(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<uint8_t> bytes((std::istreambuf_iterator<char>(in)),
|
||
std::istreambuf_iterator<char>());
|
||
// 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<std::string> 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<int> 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<int>());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const auto& accessors = gj["accessors"];
|
||
const auto& bufferViews = gj["bufferViews"];
|
||
for (int ai : posAccIndices) {
|
||
if (ai < 0 || ai >= static_cast<int>(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<int>(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<std::string> 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<uint8_t> bytes((std::istreambuf_iterator<char>(in)),
|
||
std::istreambuf_iterator<char>());
|
||
std::vector<std::string> 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<const char*>(&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<uint8_t> bytes((std::istreambuf_iterator<char>(in)),
|
||
std::istreambuf_iterator<char>());
|
||
std::vector<std::string> 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<std::string> 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<std::string>();
|
||
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<uint32_t>();
|
||
}
|
||
if (!doc.contains("fieldCount") ||
|
||
!doc["fieldCount"].is_number_integer()) {
|
||
errors.push_back("'fieldCount' missing or not an integer");
|
||
} else {
|
||
fieldCount = doc["fieldCount"].get<uint32_t>();
|
||
}
|
||
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<uint32_t>(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<int>(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<uint32_t>(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<uint32_t>(wom.vertices.size());
|
||
const uint32_t iCount = static_cast<uint32_t>(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<uint8_t> 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<uint32_t>(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<uint32_t>(jsonStr.size());
|
||
uint32_t binLen = binSize;
|
||
uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen;
|
||
std::ofstream out(outPath, std::ios::binary);
|
||
if (!out) {
|
||
std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
// Header: magic, version, total length (all little-endian uint32)
|
||
uint32_t magic = 0x46546C67; // 'glTF'
|
||
uint32_t version = 2;
|
||
out.write(reinterpret_cast<const char*>(&magic), 4);
|
||
out.write(reinterpret_cast<const char*>(&version), 4);
|
||
out.write(reinterpret_cast<const char*>(&totalLen), 4);
|
||
// JSON chunk header + payload
|
||
uint32_t jsonChunkType = 0x4E4F534A; // 'JSON'
|
||
out.write(reinterpret_cast<const char*>(&jsonLen), 4);
|
||
out.write(reinterpret_cast<const char*>(&jsonChunkType), 4);
|
||
out.write(jsonStr.data(), jsonLen);
|
||
// BIN chunk header + payload
|
||
uint32_t binChunkType = 0x004E4942; // 'BIN\0'
|
||
out.write(reinterpret_cast<const char*>(&binLen), 4);
|
||
out.write(reinterpret_cast<const char*>(&binChunkType), 4);
|
||
out.write(reinterpret_cast<const char*>(bin.data()), binLen);
|
||
out.close();
|
||
std::printf("Exported %s.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<std::string, uint32_t> 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<uint32_t>(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<glm::vec3> 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<uint32_t> groupVertOff(bld.groups.size(), 0);
|
||
std::vector<uint32_t> groupIdxOff(bld.groups.size(), 0);
|
||
for (size_t g = 0; g < bld.groups.size(); ++g) {
|
||
groupVertOff[g] = totalV;
|
||
groupIdxOff[g] = totalI;
|
||
totalV += static_cast<uint32_t>(bld.groups[g].vertices.size());
|
||
totalI += static_cast<uint32_t>(bld.groups[g].indices.size());
|
||
}
|
||
if (totalV == 0 || totalI == 0) {
|
||
std::fprintf(stderr, "WOB has no vertex data\n");
|
||
return 1;
|
||
}
|
||
const uint32_t posOff = 0;
|
||
const uint32_t nrmOff = posOff + totalV * 12;
|
||
const uint32_t uvOff = nrmOff + totalV * 12;
|
||
const uint32_t idxOff = uvOff + totalV * 8;
|
||
const uint32_t binSize = idxOff + totalI * 4;
|
||
std::vector<uint8_t> bin(binSize);
|
||
// Pack per-group geometry into the global pool. Indices get
|
||
// offset by the group's starting vertex index so they
|
||
// continue to reference the right vertices in the merged pool.
|
||
uint32_t vCursor = 0, iCursor = 0;
|
||
glm::vec3 bMin{1e30f}, bMax{-1e30f};
|
||
for (size_t g = 0; g < bld.groups.size(); ++g) {
|
||
const auto& grp = bld.groups[g];
|
||
for (const auto& v : grp.vertices) {
|
||
std::memcpy(&bin[posOff + vCursor * 12 + 0], &v.position.x, 4);
|
||
std::memcpy(&bin[posOff + vCursor * 12 + 4], &v.position.y, 4);
|
||
std::memcpy(&bin[posOff + vCursor * 12 + 8], &v.position.z, 4);
|
||
std::memcpy(&bin[nrmOff + vCursor * 12 + 0], &v.normal.x, 4);
|
||
std::memcpy(&bin[nrmOff + vCursor * 12 + 4], &v.normal.y, 4);
|
||
std::memcpy(&bin[nrmOff + vCursor * 12 + 8], &v.normal.z, 4);
|
||
std::memcpy(&bin[uvOff + vCursor * 8 + 0], &v.texCoord.x, 4);
|
||
std::memcpy(&bin[uvOff + vCursor * 8 + 4], &v.texCoord.y, 4);
|
||
bMin = glm::min(bMin, v.position);
|
||
bMax = glm::max(bMax, v.position);
|
||
vCursor++;
|
||
}
|
||
// Offset indices by group's vertex base so merged pool
|
||
// indexing still works. uint32 indices, written LE.
|
||
for (uint32_t idx : grp.indices) {
|
||
uint32_t off = idx + groupVertOff[g];
|
||
std::memcpy(&bin[idxOff + iCursor * 4], &off, 4);
|
||
iCursor++;
|
||
}
|
||
}
|
||
// Build glTF JSON.
|
||
nlohmann::json gj;
|
||
gj["asset"] = {{"version", "2.0"},
|
||
{"generator", "wowee_editor --export-wob-glb"}};
|
||
gj["scene"] = 0;
|
||
gj["scenes"] = nlohmann::json::array({nlohmann::json{{"nodes", {0}}}});
|
||
gj["nodes"] = nlohmann::json::array({nlohmann::json{
|
||
{"name", bld.name.empty() ? "WoweeBuilding" : bld.name},
|
||
{"mesh", 0}
|
||
}});
|
||
gj["buffers"] = nlohmann::json::array({nlohmann::json{
|
||
{"byteLength", binSize}
|
||
}});
|
||
nlohmann::json bufferViews = nlohmann::json::array();
|
||
bufferViews.push_back({{"buffer", 0}, {"byteOffset", posOff},
|
||
{"byteLength", totalV * 12}, {"target", 34962}});
|
||
bufferViews.push_back({{"buffer", 0}, {"byteOffset", nrmOff},
|
||
{"byteLength", totalV * 12}, {"target", 34962}});
|
||
bufferViews.push_back({{"buffer", 0}, {"byteOffset", uvOff},
|
||
{"byteLength", totalV * 8}, {"target", 34962}});
|
||
bufferViews.push_back({{"buffer", 0}, {"byteOffset", idxOff},
|
||
{"byteLength", totalI * 4}, {"target", 34963}});
|
||
gj["bufferViews"] = bufferViews;
|
||
nlohmann::json accessors = nlohmann::json::array();
|
||
accessors.push_back({
|
||
{"bufferView", 0}, {"componentType", 5126},
|
||
{"count", totalV}, {"type", "VEC3"},
|
||
{"min", {bMin.x, bMin.y, bMin.z}},
|
||
{"max", {bMax.x, bMax.y, bMax.z}}
|
||
});
|
||
accessors.push_back({{"bufferView", 1}, {"componentType", 5126},
|
||
{"count", totalV}, {"type", "VEC3"}});
|
||
accessors.push_back({{"bufferView", 2}, {"componentType", 5126},
|
||
{"count", totalV}, {"type", "VEC2"}});
|
||
// Per-group primitives — each gets its own indices accessor
|
||
// sliced from the shared index bufferView via byteOffset.
|
||
nlohmann::json primitives = nlohmann::json::array();
|
||
for (size_t g = 0; g < bld.groups.size(); ++g) {
|
||
uint32_t accIdx = static_cast<uint32_t>(accessors.size());
|
||
accessors.push_back({
|
||
{"bufferView", 3},
|
||
{"byteOffset", groupIdxOff[g] * 4},
|
||
{"componentType", 5125},
|
||
{"count", bld.groups[g].indices.size()},
|
||
{"type", "SCALAR"}
|
||
});
|
||
primitives.push_back({
|
||
{"attributes", {{"POSITION", 0}, {"NORMAL", 1}, {"TEXCOORD_0", 2}}},
|
||
{"indices", accIdx},
|
||
{"mode", 4}
|
||
});
|
||
}
|
||
gj["accessors"] = accessors;
|
||
gj["meshes"] = nlohmann::json::array({nlohmann::json{
|
||
{"primitives", primitives}
|
||
}});
|
||
std::string jsonStr = gj.dump();
|
||
while (jsonStr.size() % 4 != 0) jsonStr += ' ';
|
||
uint32_t jsonLen = static_cast<uint32_t>(jsonStr.size());
|
||
uint32_t binLen = binSize;
|
||
uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen;
|
||
std::ofstream out(outPath, std::ios::binary);
|
||
if (!out) {
|
||
std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
uint32_t magic = 0x46546C67;
|
||
uint32_t version = 2;
|
||
out.write(reinterpret_cast<const char*>(&magic), 4);
|
||
out.write(reinterpret_cast<const char*>(&version), 4);
|
||
out.write(reinterpret_cast<const char*>(&totalLen), 4);
|
||
uint32_t jsonChunkType = 0x4E4F534A;
|
||
out.write(reinterpret_cast<const char*>(&jsonLen), 4);
|
||
out.write(reinterpret_cast<const char*>(&jsonChunkType), 4);
|
||
out.write(jsonStr.data(), jsonLen);
|
||
uint32_t binChunkType = 0x004E4942;
|
||
out.write(reinterpret_cast<const char*>(&binLen), 4);
|
||
out.write(reinterpret_cast<const char*>(&binChunkType), 4);
|
||
out.write(reinterpret_cast<const char*>(bin.data()), binLen);
|
||
out.close();
|
||
std::printf("Exported %s.wob -> %s\n", base.c_str(), outPath.c_str());
|
||
std::printf(" %zu groups -> %zu primitives, %u verts, %u tris, %u-byte BIN\n",
|
||
bld.groups.size(), primitives.size(),
|
||
totalV, totalI / 3, binLen);
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--export-whm-glb") == 0 && i + 1 < argc) {
|
||
// glTF 2.0 binary export for WHM/WOT terrain. Mirrors
|
||
// --export-whm-obj's mesh layout (9x9 outer grid per chunk
|
||
// → 8x8 quads → 2 tris each), but ships as a single .glb
|
||
// viewable in any modern web 3D tool. Per-chunk primitives
|
||
// so designers can hide individual chunks in three.js.
|
||
std::string base = argv[++i];
|
||
std::string outPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
outPath = argv[++i];
|
||
}
|
||
for (const char* ext : {".wot", ".whm"}) {
|
||
if (base.size() >= 4 && base.substr(base.size() - 4) == ext) {
|
||
base = base.substr(0, base.size() - 4);
|
||
break;
|
||
}
|
||
}
|
||
if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) {
|
||
std::fprintf(stderr, "WHM/WOT not found: %s.{whm,wot}\n", base.c_str());
|
||
return 1;
|
||
}
|
||
if (outPath.empty()) outPath = base + ".glb";
|
||
wowee::pipeline::ADTTerrain terrain;
|
||
wowee::pipeline::WoweeTerrainLoader::load(base, terrain);
|
||
// Same coord constants as --export-whm-obj so the .glb and
|
||
// .obj of the same source align spatially.
|
||
constexpr float kTileSize = 533.33333f;
|
||
constexpr float kChunkSize = kTileSize / 16.0f;
|
||
constexpr float kVertSpacing = kChunkSize / 8.0f;
|
||
// Walk the 16x16 chunk grid, build per-chunk vertex + index
|
||
// arrays. Hole bits respected (cave-entrance quads dropped).
|
||
struct ChunkMesh { uint32_t vertOff, vertCount, idxOff, idxCount; };
|
||
std::vector<ChunkMesh> chunkMeshes;
|
||
std::vector<glm::vec3> positions; // packed sequentially
|
||
std::vector<uint32_t> indices;
|
||
int loadedChunks = 0;
|
||
glm::vec3 bMin{1e30f}, bMax{-1e30f};
|
||
for (int cx = 0; cx < 16; ++cx) {
|
||
for (int cy = 0; cy < 16; ++cy) {
|
||
const auto& chunk = terrain.getChunk(cx, cy);
|
||
if (!chunk.heightMap.isLoaded()) continue;
|
||
loadedChunks++;
|
||
ChunkMesh cm{};
|
||
cm.vertOff = static_cast<uint32_t>(positions.size());
|
||
cm.idxOff = static_cast<uint32_t>(indices.size());
|
||
float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize;
|
||
float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize;
|
||
// 9x9 outer verts (skip 8x8 inner fan-center verts).
|
||
for (int row = 0; row < 9; ++row) {
|
||
for (int col = 0; col < 9; ++col) {
|
||
glm::vec3 p{
|
||
chunkBaseX - row * kVertSpacing,
|
||
chunkBaseY - col * kVertSpacing,
|
||
chunk.position[2] + chunk.heightMap.heights[row * 17 + col]
|
||
};
|
||
positions.push_back(p);
|
||
bMin = glm::min(bMin, p);
|
||
bMax = glm::max(bMax, p);
|
||
}
|
||
}
|
||
cm.vertCount = 81;
|
||
bool isHoleChunk = (chunk.holes != 0);
|
||
auto idx = [&](int r, int c) { return cm.vertOff + r * 9 + c; };
|
||
for (int row = 0; row < 8; ++row) {
|
||
for (int col = 0; col < 8; ++col) {
|
||
if (isHoleChunk) {
|
||
int hx = col / 2, hy = row / 2;
|
||
if (chunk.holes & (1 << (hy * 4 + hx))) continue;
|
||
}
|
||
indices.push_back(idx(row, col));
|
||
indices.push_back(idx(row, col + 1));
|
||
indices.push_back(idx(row + 1, col + 1));
|
||
indices.push_back(idx(row, col));
|
||
indices.push_back(idx(row + 1, col + 1));
|
||
indices.push_back(idx(row + 1, col));
|
||
}
|
||
}
|
||
cm.idxCount = static_cast<uint32_t>(indices.size()) - cm.idxOff;
|
||
chunkMeshes.push_back(cm);
|
||
}
|
||
}
|
||
if (loadedChunks == 0) {
|
||
std::fprintf(stderr, "WHM has no loaded chunks\n");
|
||
return 1;
|
||
}
|
||
// Synthesize normals as +Z (terrain is Z-up). Real per-vertex
|
||
// normals would need a smoothing pass across chunk boundaries
|
||
// — skip for v1, viewers can compute their own from positions.
|
||
const uint32_t totalV = static_cast<uint32_t>(positions.size());
|
||
const uint32_t totalI = static_cast<uint32_t>(indices.size());
|
||
const uint32_t posOff = 0;
|
||
const uint32_t nrmOff = posOff + totalV * 12;
|
||
const uint32_t idxOff = nrmOff + totalV * 12;
|
||
const uint32_t binSize = idxOff + totalI * 4;
|
||
std::vector<uint8_t> bin(binSize);
|
||
for (uint32_t v = 0; v < totalV; ++v) {
|
||
std::memcpy(&bin[posOff + v * 12 + 0], &positions[v].x, 4);
|
||
std::memcpy(&bin[posOff + v * 12 + 4], &positions[v].y, 4);
|
||
std::memcpy(&bin[posOff + v * 12 + 8], &positions[v].z, 4);
|
||
float nx = 0, ny = 0, nz = 1;
|
||
std::memcpy(&bin[nrmOff + v * 12 + 0], &nx, 4);
|
||
std::memcpy(&bin[nrmOff + v * 12 + 4], &ny, 4);
|
||
std::memcpy(&bin[nrmOff + v * 12 + 8], &nz, 4);
|
||
}
|
||
std::memcpy(&bin[idxOff], indices.data(), totalI * 4);
|
||
// Build glTF JSON.
|
||
nlohmann::json gj;
|
||
gj["asset"] = {{"version", "2.0"},
|
||
{"generator", "wowee_editor --export-whm-glb"}};
|
||
gj["scene"] = 0;
|
||
gj["scenes"] = nlohmann::json::array({nlohmann::json{{"nodes", {0}}}});
|
||
std::string nodeName = "WoweeTerrain_" + std::to_string(terrain.coord.x) +
|
||
"_" + std::to_string(terrain.coord.y);
|
||
gj["nodes"] = nlohmann::json::array({nlohmann::json{
|
||
{"name", nodeName}, {"mesh", 0}
|
||
}});
|
||
gj["buffers"] = nlohmann::json::array({nlohmann::json{
|
||
{"byteLength", binSize}
|
||
}});
|
||
nlohmann::json bufferViews = nlohmann::json::array();
|
||
bufferViews.push_back({{"buffer", 0}, {"byteOffset", posOff},
|
||
{"byteLength", totalV * 12}, {"target", 34962}});
|
||
bufferViews.push_back({{"buffer", 0}, {"byteOffset", nrmOff},
|
||
{"byteLength", totalV * 12}, {"target", 34962}});
|
||
bufferViews.push_back({{"buffer", 0}, {"byteOffset", idxOff},
|
||
{"byteLength", totalI * 4}, {"target", 34963}});
|
||
gj["bufferViews"] = bufferViews;
|
||
nlohmann::json accessors = nlohmann::json::array();
|
||
accessors.push_back({
|
||
{"bufferView", 0}, {"componentType", 5126},
|
||
{"count", totalV}, {"type", "VEC3"},
|
||
{"min", {bMin.x, bMin.y, bMin.z}},
|
||
{"max", {bMax.x, bMax.y, bMax.z}}
|
||
});
|
||
accessors.push_back({{"bufferView", 1}, {"componentType", 5126},
|
||
{"count", totalV}, {"type", "VEC3"}});
|
||
// Per-chunk primitive — sliced from shared index bufferView.
|
||
nlohmann::json primitives = nlohmann::json::array();
|
||
for (const auto& cm : chunkMeshes) {
|
||
if (cm.idxCount == 0) continue; // all-hole chunk
|
||
uint32_t accIdx = static_cast<uint32_t>(accessors.size());
|
||
accessors.push_back({
|
||
{"bufferView", 2},
|
||
{"byteOffset", cm.idxOff * 4},
|
||
{"componentType", 5125},
|
||
{"count", cm.idxCount},
|
||
{"type", "SCALAR"}
|
||
});
|
||
primitives.push_back({
|
||
{"attributes", {{"POSITION", 0}, {"NORMAL", 1}}},
|
||
{"indices", accIdx},
|
||
{"mode", 4}
|
||
});
|
||
}
|
||
gj["accessors"] = accessors;
|
||
gj["meshes"] = nlohmann::json::array({nlohmann::json{
|
||
{"primitives", primitives}
|
||
}});
|
||
std::string jsonStr = gj.dump();
|
||
while (jsonStr.size() % 4 != 0) jsonStr += ' ';
|
||
uint32_t jsonLen = static_cast<uint32_t>(jsonStr.size());
|
||
uint32_t binLen = binSize;
|
||
uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen;
|
||
std::ofstream out(outPath, std::ios::binary);
|
||
if (!out) {
|
||
std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
uint32_t magic = 0x46546C67, version = 2;
|
||
out.write(reinterpret_cast<const char*>(&magic), 4);
|
||
out.write(reinterpret_cast<const char*>(&version), 4);
|
||
out.write(reinterpret_cast<const char*>(&totalLen), 4);
|
||
uint32_t jsonChunkType = 0x4E4F534A;
|
||
out.write(reinterpret_cast<const char*>(&jsonLen), 4);
|
||
out.write(reinterpret_cast<const char*>(&jsonChunkType), 4);
|
||
out.write(jsonStr.data(), jsonLen);
|
||
uint32_t binChunkType = 0x004E4942;
|
||
out.write(reinterpret_cast<const char*>(&binLen), 4);
|
||
out.write(reinterpret_cast<const char*>(&binChunkType), 4);
|
||
out.write(reinterpret_cast<const char*>(bin.data()), binLen);
|
||
out.close();
|
||
std::printf("Exported %s.whm -> %s\n", base.c_str(), outPath.c_str());
|
||
std::printf(" %d chunks loaded, %u verts, %u tris, %zu primitives, %u-byte BIN\n",
|
||
loadedChunks, totalV, totalI / 3, primitives.size(), binLen);
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--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<TileMesh> tileMeshes;
|
||
std::vector<glm::vec3> positions;
|
||
std::vector<uint32_t> 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<uint32_t>(positions.size());
|
||
tm.idxOff = static_cast<uint32_t>(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<uint32_t>(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<uint32_t>(positions.size()) - tm.vertOff;
|
||
tm.idxCount = static_cast<uint32_t>(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<uint32_t>(positions.size());
|
||
const uint32_t totalI = static_cast<uint32_t>(indices.size());
|
||
const uint32_t posOff = 0;
|
||
const uint32_t nrmOff = posOff + totalV * 12;
|
||
const uint32_t idxOff = nrmOff + totalV * 12;
|
||
const uint32_t binSize = idxOff + totalI * 4;
|
||
std::vector<uint8_t> bin(binSize);
|
||
for (uint32_t v = 0; v < totalV; ++v) {
|
||
std::memcpy(&bin[posOff + v * 12 + 0], &positions[v].x, 4);
|
||
std::memcpy(&bin[posOff + v * 12 + 4], &positions[v].y, 4);
|
||
std::memcpy(&bin[posOff + v * 12 + 8], &positions[v].z, 4);
|
||
float nx = 0, ny = 0, nz = 1;
|
||
std::memcpy(&bin[nrmOff + v * 12 + 0], &nx, 4);
|
||
std::memcpy(&bin[nrmOff + v * 12 + 4], &ny, 4);
|
||
std::memcpy(&bin[nrmOff + v * 12 + 8], &nz, 4);
|
||
}
|
||
std::memcpy(&bin[idxOff], indices.data(), totalI * 4);
|
||
// Build glTF JSON. 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<uint32_t>(accessors.size());
|
||
accessors.push_back({
|
||
{"bufferView", 2},
|
||
{"byteOffset", tm.idxOff * 4},
|
||
{"componentType", 5125},
|
||
{"count", tm.idxCount},
|
||
{"type", "SCALAR"}
|
||
});
|
||
uint32_t meshIdx = static_cast<uint32_t>(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<uint32_t>(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<uint32_t>(jsonStr.size());
|
||
uint32_t binLen = binSize;
|
||
uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen;
|
||
std::ofstream out(outPath, std::ios::binary);
|
||
if (!out) {
|
||
std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
uint32_t magic = 0x46546C67, version = 2;
|
||
out.write(reinterpret_cast<const char*>(&magic), 4);
|
||
out.write(reinterpret_cast<const char*>(&version), 4);
|
||
out.write(reinterpret_cast<const char*>(&totalLen), 4);
|
||
uint32_t jsonChunkType = 0x4E4F534A;
|
||
out.write(reinterpret_cast<const char*>(&jsonLen), 4);
|
||
out.write(reinterpret_cast<const char*>(&jsonChunkType), 4);
|
||
out.write(jsonStr.data(), jsonLen);
|
||
uint32_t binChunkType = 0x004E4942;
|
||
out.write(reinterpret_cast<const char*>(&binLen), 4);
|
||
out.write(reinterpret_cast<const char*>(&binChunkType), 4);
|
||
out.write(reinterpret_cast<const char*>(bin.data()), binLen);
|
||
out.close();
|
||
std::printf("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<unsigned long long>(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<uint32_t> faceI0, faceI1, faceI2; // local indices
|
||
};
|
||
std::vector<TileMeta> 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<uint32_t>(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<unsigned long long>(totalFaces));
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--bake-project-obj") == 0 && i + 1 < argc) {
|
||
// Project-level OBJ bake: every zone in <projectDir> 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<std::string> 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<uint32_t> faceI0, faceI1, faceI2;
|
||
};
|
||
std::vector<Pending> 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<uint32_t>(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<unsigned long long>(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<std::string> 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<glm::vec3> verts;
|
||
std::vector<uint32_t> indices;
|
||
};
|
||
std::vector<ZonePool> 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<uint32_t>(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<unsigned long long>(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<uint32_t>(zp.verts.size());
|
||
totalI += static_cast<uint32_t>(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<uint8_t> 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<ZoneSlice> slices;
|
||
for (const auto& zp : zones) {
|
||
ZoneSlice s{zp.name, vCursor, static_cast<uint32_t>(zp.verts.size()),
|
||
iCursor, static_cast<uint32_t>(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<uint32_t>(accessors.size());
|
||
accessors.push_back({{"bufferView", 2},
|
||
{"byteOffset", s.iOff * 4},
|
||
{"componentType", 5125},
|
||
{"count", s.iCnt}, {"type", "SCALAR"}});
|
||
uint32_t meshIdx = static_cast<uint32_t>(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<uint32_t>(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<uint32_t>(jsonStr.size());
|
||
uint32_t binLen = binSize;
|
||
uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen;
|
||
std::ofstream out(outPath, std::ios::binary);
|
||
if (!out) {
|
||
std::fprintf(stderr, "%s: cannot write %s\n", cmdName, outPath.c_str());
|
||
return 1;
|
||
}
|
||
uint32_t magic = 0x46546C67, version = 2;
|
||
out.write(reinterpret_cast<const char*>(&magic), 4);
|
||
out.write(reinterpret_cast<const char*>(&version), 4);
|
||
out.write(reinterpret_cast<const char*>(&totalLen), 4);
|
||
uint32_t jt = 0x4E4F534A;
|
||
out.write(reinterpret_cast<const char*>(&jsonLen), 4);
|
||
out.write(reinterpret_cast<const char*>(&jt), 4);
|
||
out.write(jsonStr.data(), jsonLen);
|
||
uint32_t bt = 0x004E4942;
|
||
out.write(reinterpret_cast<const char*>(&binLen), 4);
|
||
out.write(reinterpret_cast<const char*>(&bt), 4);
|
||
out.write(reinterpret_cast<const char*>(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<uint32_t>(grp.vertices.size());
|
||
}
|
||
// Doodad placements as a separate informational block — emit
|
||
// each as a comment line so OBJ stays valid but the data is
|
||
// recoverable for tools that want to re-create the placements.
|
||
if (!bld.doodads.empty()) {
|
||
obj << "\n# Doodad placements (model, position, rotation, scale):\n";
|
||
for (const auto& d : bld.doodads) {
|
||
obj << "# doodad " << d.modelPath
|
||
<< " pos " << d.position.x << "," << d.position.y << "," << d.position.z
|
||
<< " rot " << d.rotation.x << "," << d.rotation.y << "," << d.rotation.z
|
||
<< " scale " << d.scale << "\n";
|
||
}
|
||
}
|
||
obj.close();
|
||
std::printf("Exported %s.wob -> %s\n", base.c_str(), outPath.c_str());
|
||
std::printf(" %zu groups, %zu verts, %zu tris, %zu doodad placements\n",
|
||
bld.groups.size(), totalV, totalI / 3,
|
||
bld.doodads.size());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--import-wob-obj") == 0 && i + 1 < argc) {
|
||
// Round-trip companion to --export-wob-obj. Each OBJ 'g' block
|
||
// becomes one WoweeBuilding::Group; geometry under that group
|
||
// is deduped into the group's local vertex array. Faces
|
||
// before any 'g' directive land in a default 'imported' group.
|
||
// Doodad placements written as # comment lines by --export-wob-obj
|
||
// ARE recognized and re-instanced into bld.doodads.
|
||
std::string objPath = argv[++i];
|
||
std::string wobBase;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
wobBase = argv[++i];
|
||
}
|
||
if (!std::filesystem::exists(objPath)) {
|
||
std::fprintf(stderr, "OBJ not found: %s\n", objPath.c_str());
|
||
return 1;
|
||
}
|
||
if (wobBase.empty()) {
|
||
wobBase = objPath;
|
||
if (wobBase.size() >= 4 &&
|
||
wobBase.substr(wobBase.size() - 4) == ".obj") {
|
||
wobBase = wobBase.substr(0, wobBase.size() - 4);
|
||
}
|
||
}
|
||
std::ifstream in(objPath);
|
||
if (!in) {
|
||
std::fprintf(stderr, "Failed to open OBJ: %s\n", objPath.c_str());
|
||
return 1;
|
||
}
|
||
// Global pools (OBJ vertex/uv/normal indices reference these
|
||
// across all groups).
|
||
std::vector<glm::vec3> positions;
|
||
std::vector<glm::vec2> texcoords;
|
||
std::vector<glm::vec3> normals;
|
||
wowee::pipeline::WoweeBuilding bld;
|
||
// Active group bookkeeping: dedupe table is per-group since
|
||
// each WOB group has its own local vertex buffer.
|
||
std::string activeGroup = "imported";
|
||
std::unordered_map<std::string, uint32_t> groupDedupe;
|
||
int activeGroupIdx = -1;
|
||
int badFaces = 0;
|
||
int triangulatedNgons = 0;
|
||
std::string objectName;
|
||
auto ensureActiveGroup = [&]() {
|
||
if (activeGroupIdx >= 0) return;
|
||
wowee::pipeline::WoweeBuilding::Group g;
|
||
g.name = activeGroup;
|
||
if (g.name.size() >= 8 &&
|
||
g.name.substr(g.name.size() - 8) == "_outdoor") {
|
||
g.name = g.name.substr(0, g.name.size() - 8);
|
||
g.isOutdoor = true;
|
||
}
|
||
bld.groups.push_back(g);
|
||
activeGroupIdx = static_cast<int>(bld.groups.size()) - 1;
|
||
groupDedupe.clear();
|
||
};
|
||
auto resolveCorner = [&](const std::string& token) -> int {
|
||
int v = 0, t = 0, n = 0;
|
||
{
|
||
const char* p = token.c_str();
|
||
char* endp = nullptr;
|
||
v = std::strtol(p, &endp, 10);
|
||
if (*endp == '/') {
|
||
++endp;
|
||
if (*endp != '/') t = std::strtol(endp, &endp, 10);
|
||
if (*endp == '/') {
|
||
++endp;
|
||
n = std::strtol(endp, &endp, 10);
|
||
}
|
||
}
|
||
}
|
||
auto absIdx = [](int idx, size_t pool) {
|
||
if (idx < 0) return static_cast<int>(pool) + idx;
|
||
return idx - 1;
|
||
};
|
||
int vi = absIdx(v, positions.size());
|
||
int ti = (t == 0) ? -1 : absIdx(t, texcoords.size());
|
||
int ni = (n == 0) ? -1 : absIdx(n, normals.size());
|
||
if (vi < 0 || vi >= static_cast<int>(positions.size())) return -1;
|
||
ensureActiveGroup();
|
||
std::string key = std::to_string(vi) + "/" +
|
||
std::to_string(ti) + "/" +
|
||
std::to_string(ni);
|
||
auto it = groupDedupe.find(key);
|
||
if (it != groupDedupe.end()) return static_cast<int>(it->second);
|
||
wowee::pipeline::WoweeBuilding::Vertex vert;
|
||
vert.position = positions[vi];
|
||
if (ti >= 0 && ti < static_cast<int>(texcoords.size())) {
|
||
vert.texCoord = texcoords[ti];
|
||
// Reverse the V-flip from --export-wob-obj.
|
||
vert.texCoord.y = 1.0f - vert.texCoord.y;
|
||
} else {
|
||
vert.texCoord = {0, 0};
|
||
}
|
||
if (ni >= 0 && ni < static_cast<int>(normals.size())) {
|
||
vert.normal = normals[ni];
|
||
} else {
|
||
vert.normal = {0, 0, 1};
|
||
}
|
||
vert.color = {1, 1, 1, 1};
|
||
auto& grp = bld.groups[activeGroupIdx];
|
||
uint32_t newIdx = static_cast<uint32_t>(grp.vertices.size());
|
||
grp.vertices.push_back(vert);
|
||
groupDedupe[key] = newIdx;
|
||
return static_cast<int>(newIdx);
|
||
};
|
||
std::string line;
|
||
while (std::getline(in, line)) {
|
||
while (!line.empty() && (line.back() == '\r' || line.back() == ' '))
|
||
line.pop_back();
|
||
if (line.empty()) continue;
|
||
// Recognize doodad placement comment lines emitted by
|
||
// --export-wob-obj so the round-trip preserves them.
|
||
if (line[0] == '#') {
|
||
if (line.find("# doodad ") == 0) {
|
||
std::istringstream ss(line);
|
||
std::string hash, doodadKw, modelPath, posKw, posStr,
|
||
rotKw, rotStr, scaleKw;
|
||
float scale = 1.0f;
|
||
ss >> hash >> doodadKw >> modelPath
|
||
>> posKw >> posStr
|
||
>> rotKw >> rotStr
|
||
>> scaleKw >> scale;
|
||
auto parse3 = [](const std::string& s, glm::vec3& out) {
|
||
int got = std::sscanf(s.c_str(), "%f,%f,%f",
|
||
&out.x, &out.y, &out.z);
|
||
return got == 3;
|
||
};
|
||
wowee::pipeline::WoweeBuilding::DoodadPlacement d;
|
||
d.modelPath = modelPath;
|
||
if (parse3(posStr, d.position) &&
|
||
parse3(rotStr, d.rotation) &&
|
||
std::isfinite(scale) && scale > 0.0f) {
|
||
d.scale = scale;
|
||
bld.doodads.push_back(d);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
std::istringstream ss(line);
|
||
std::string tag;
|
||
ss >> tag;
|
||
if (tag == "v") {
|
||
glm::vec3 p; ss >> p.x >> p.y >> p.z;
|
||
positions.push_back(p);
|
||
} else if (tag == "vt") {
|
||
glm::vec2 t; ss >> t.x >> t.y;
|
||
texcoords.push_back(t);
|
||
} else if (tag == "vn") {
|
||
glm::vec3 n; ss >> n.x >> n.y >> n.z;
|
||
normals.push_back(n);
|
||
} else if (tag == "o") {
|
||
if (objectName.empty()) ss >> objectName;
|
||
} else if (tag == "g") {
|
||
// New group — flush dedupe table so the next batch of
|
||
// verts is local to this group.
|
||
std::string name;
|
||
ss >> name;
|
||
activeGroup = name.empty() ? "group" : name;
|
||
activeGroupIdx = -1;
|
||
groupDedupe.clear();
|
||
} else if (tag == "f") {
|
||
std::vector<std::string> corners;
|
||
std::string c;
|
||
while (ss >> c) corners.push_back(c);
|
||
if (corners.size() < 3) { badFaces++; continue; }
|
||
std::vector<int> resolved;
|
||
resolved.reserve(corners.size());
|
||
bool ok = true;
|
||
for (const auto& cc : corners) {
|
||
int idx = resolveCorner(cc);
|
||
if (idx < 0) { ok = false; break; }
|
||
resolved.push_back(idx);
|
||
}
|
||
if (!ok) { badFaces++; continue; }
|
||
if (resolved.size() > 3) triangulatedNgons++;
|
||
auto& grp = bld.groups[activeGroupIdx];
|
||
for (size_t k = 1; k + 1 < resolved.size(); ++k) {
|
||
grp.indices.push_back(static_cast<uint32_t>(resolved[0]));
|
||
grp.indices.push_back(static_cast<uint32_t>(resolved[k]));
|
||
grp.indices.push_back(static_cast<uint32_t>(resolved[k + 1]));
|
||
}
|
||
}
|
||
// mtllib/usemtl/s lines silently skipped.
|
||
}
|
||
// Compute per-group bounds + global building bound.
|
||
if (bld.groups.empty()) {
|
||
std::fprintf(stderr, "import-wob-obj: no geometry found in %s\n",
|
||
objPath.c_str());
|
||
return 1;
|
||
}
|
||
glm::vec3 bMin{1e30f}, bMax{-1e30f};
|
||
for (auto& grp : bld.groups) {
|
||
if (grp.vertices.empty()) continue;
|
||
grp.boundMin = grp.vertices[0].position;
|
||
grp.boundMax = grp.boundMin;
|
||
for (const auto& v : grp.vertices) {
|
||
grp.boundMin = glm::min(grp.boundMin, v.position);
|
||
grp.boundMax = glm::max(grp.boundMax, v.position);
|
||
}
|
||
bMin = glm::min(bMin, grp.boundMin);
|
||
bMax = glm::max(bMax, grp.boundMax);
|
||
}
|
||
glm::vec3 center = (bMin + bMax) * 0.5f;
|
||
float r2 = 0;
|
||
for (const auto& grp : bld.groups) {
|
||
for (const auto& v : grp.vertices) {
|
||
glm::vec3 d = v.position - center;
|
||
r2 = std::max(r2, glm::dot(d, d));
|
||
}
|
||
}
|
||
bld.boundRadius = std::sqrt(r2);
|
||
bld.name = objectName.empty()
|
||
? std::filesystem::path(objPath).stem().string()
|
||
: objectName;
|
||
if (!wowee::pipeline::WoweeBuildingLoader::save(bld, wobBase)) {
|
||
std::fprintf(stderr, "import-wob-obj: failed to write %s.wob\n",
|
||
wobBase.c_str());
|
||
return 1;
|
||
}
|
||
size_t totalV = 0, totalI = 0;
|
||
for (const auto& g : bld.groups) {
|
||
totalV += g.vertices.size();
|
||
totalI += g.indices.size();
|
||
}
|
||
std::printf("Imported %s -> %s.wob\n", objPath.c_str(), wobBase.c_str());
|
||
std::printf(" %zu groups, %zu verts, %zu tris, %zu doodad placements\n",
|
||
bld.groups.size(), totalV, totalI / 3, bld.doodads.size());
|
||
if (triangulatedNgons > 0) {
|
||
std::printf(" fan-triangulated %d n-gon(s)\n", triangulatedNgons);
|
||
}
|
||
if (badFaces > 0) {
|
||
std::printf(" warning: skipped %d malformed face(s)\n", badFaces);
|
||
}
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--export-woc-obj") == 0 && i + 1 < argc) {
|
||
// Visualize a WOC collision mesh in any 3D tool. Each
|
||
// walkability class becomes its own OBJ group (walkable /
|
||
// steep / water / indoor) so designers can hide categories
|
||
// independently in Blender to debug 'why can the player
|
||
// walk here?' or 'why can't they walk there?'.
|
||
std::string path = argv[++i];
|
||
std::string outPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
outPath = argv[++i];
|
||
}
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "WOC not found: %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
if (outPath.empty()) {
|
||
outPath = path;
|
||
if (outPath.size() >= 4 &&
|
||
outPath.substr(outPath.size() - 4) == ".woc") {
|
||
outPath = outPath.substr(0, outPath.size() - 4);
|
||
}
|
||
outPath += ".obj";
|
||
}
|
||
auto woc = wowee::pipeline::WoweeCollisionBuilder::load(path);
|
||
if (!woc.isValid()) {
|
||
std::fprintf(stderr, "WOC has no triangles: %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::ofstream obj(outPath);
|
||
if (!obj) {
|
||
std::fprintf(stderr, "Failed to open output file: %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
// Bucket triangles by flag combination so the OBJ can split
|
||
// them into named groups. Flag bits: walkable=0x01, water=0x02,
|
||
// steep=0x04, indoor=0x08 (per WoweeCollision::Triangle).
|
||
// Triangles can have multiple flags set so a per-flag group
|
||
// would over-count; instead we bucket by exact flag value.
|
||
std::unordered_map<uint8_t, std::vector<size_t>> byFlag;
|
||
for (size_t t = 0; t < woc.triangles.size(); ++t) {
|
||
byFlag[woc.triangles[t].flags].push_back(t);
|
||
}
|
||
obj << "# Wavefront OBJ generated by wowee_editor --export-woc-obj\n";
|
||
obj << "# Source: " << path << "\n";
|
||
obj << "# Triangles: " << woc.triangles.size()
|
||
<< " (walkable=" << woc.walkableCount()
|
||
<< " steep=" << woc.steepCount() << ")\n";
|
||
obj << "# Tile: (" << woc.tileX << ", " << woc.tileY << ")\n\n";
|
||
obj << "o WoweeCollision\n";
|
||
// Emit ALL vertices first (3 per triangle, no dedupe — the
|
||
// collision mesh has triangle-soup topology where shared
|
||
// verts often have different flags, so deduping would
|
||
// actually merge categories).
|
||
for (const auto& tri : woc.triangles) {
|
||
obj << "v " << tri.v0.x << " " << tri.v0.y << " " << tri.v0.z << "\n";
|
||
obj << "v " << tri.v1.x << " " << tri.v1.y << " " << tri.v1.z << "\n";
|
||
obj << "v " << tri.v2.x << " " << tri.v2.y << " " << tri.v2.z << "\n";
|
||
}
|
||
// Emit faces grouped by flag class. OBJ index of triangle t
|
||
// vertex k is (t * 3 + k + 1) — 1-based, three verts per tri.
|
||
auto flagName = [](uint8_t f) {
|
||
if (f == 0) return std::string("nonwalkable");
|
||
std::string s;
|
||
if (f & 0x01) s += "walkable";
|
||
if (f & 0x02) { if (!s.empty()) s += "_"; s += "water"; }
|
||
if (f & 0x04) { if (!s.empty()) s += "_"; s += "steep"; }
|
||
if (f & 0x08) { if (!s.empty()) s += "_"; s += "indoor"; }
|
||
if (s.empty()) s = "flag" + std::to_string(int(f));
|
||
return s;
|
||
};
|
||
for (const auto& [flag, tris] : byFlag) {
|
||
obj << "g " << flagName(flag) << "\n";
|
||
for (size_t t : tris) {
|
||
uint32_t base = static_cast<uint32_t>(t * 3 + 1);
|
||
obj << "f " << base << " " << (base + 1) << " " << (base + 2) << "\n";
|
||
}
|
||
}
|
||
obj.close();
|
||
std::printf("Exported %s -> %s\n", path.c_str(), outPath.c_str());
|
||
std::printf(" %zu triangles in %zu flag class(es), tile (%u, %u)\n",
|
||
woc.triangles.size(), byFlag.size(), woc.tileX, woc.tileY);
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--export-whm-obj") == 0 && i + 1 < argc) {
|
||
// Convert a WHM/WOT terrain pair to OBJ for visualization in
|
||
// Blender / MeshLab. Emits the 9x9 outer vertex grid per
|
||
// chunk (skipping the 8x8 inner verts the engine uses for
|
||
// 4-tri fans) — that's the canonical 'heightmap as mesh'
|
||
// view, 256 chunks × 81 verts = 20736 verts, 32768 tris.
|
||
// Geometry mirrors WoweeCollisionBuilder's outer-grid layout
|
||
// exactly so the OBJ aligns with the corresponding WOC.
|
||
std::string base = argv[++i];
|
||
std::string outPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
outPath = argv[++i];
|
||
}
|
||
for (const char* ext : {".wot", ".whm"}) {
|
||
if (base.size() >= 4 && base.substr(base.size() - 4) == ext) {
|
||
base = base.substr(0, base.size() - 4);
|
||
break;
|
||
}
|
||
}
|
||
if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) {
|
||
std::fprintf(stderr, "WHM/WOT not found: %s.{whm,wot}\n", base.c_str());
|
||
return 1;
|
||
}
|
||
if (outPath.empty()) outPath = base + ".obj";
|
||
wowee::pipeline::ADTTerrain terrain;
|
||
wowee::pipeline::WoweeTerrainLoader::load(base, terrain);
|
||
std::ofstream obj(outPath);
|
||
if (!obj) {
|
||
std::fprintf(stderr, "Failed to open output file: %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
// Tile + chunk constants — must match WoweeCollisionBuilder so
|
||
// exports of the same source align in space when overlaid.
|
||
constexpr float kTileSize = 533.33333f;
|
||
constexpr float kChunkSize = kTileSize / 16.0f;
|
||
constexpr float kVertSpacing = kChunkSize / 8.0f;
|
||
obj << "# Wavefront OBJ generated by wowee_editor --export-whm-obj\n";
|
||
obj << "# Source: " << base << ".whm\n";
|
||
obj << "# Tile coord: (" << terrain.coord.x << ", " << terrain.coord.y << ")\n";
|
||
obj << "# Layout: 9x9 outer vertex grid per chunk, 8x8 quads -> 2 tris each\n\n";
|
||
obj << "o WoweeTerrain_" << terrain.coord.x << "_" << terrain.coord.y << "\n";
|
||
int loadedChunks = 0;
|
||
uint32_t vertOffset = 0;
|
||
for (int cx = 0; cx < 16; ++cx) {
|
||
for (int cy = 0; cy < 16; ++cy) {
|
||
const auto& chunk = terrain.getChunk(cx, cy);
|
||
if (!chunk.heightMap.isLoaded()) continue;
|
||
loadedChunks++;
|
||
// Same XY origin formula as collision builder so
|
||
// overlaid OBJ exports line up exactly.
|
||
float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize;
|
||
float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize;
|
||
// Emit 9x9 outer verts. Layout: heights[row*17 + col]
|
||
// for col in [0,8] (the inner 8 verts at col 9..16
|
||
// are skipped — they're the quad-center verts).
|
||
for (int row = 0; row < 9; ++row) {
|
||
for (int col = 0; col < 9; ++col) {
|
||
float x = chunkBaseX - row * kVertSpacing;
|
||
float y = chunkBaseY - col * kVertSpacing;
|
||
float z = chunk.position[2] +
|
||
chunk.heightMap.heights[row * 17 + col];
|
||
obj << "v " << x << " " << y << " " << z << "\n";
|
||
}
|
||
}
|
||
// Per-vertex UV: just the row/col in 0..1 — Blender
|
||
// can use this to slap a checker texture for scale.
|
||
for (int row = 0; row < 9; ++row) {
|
||
for (int col = 0; col < 9; ++col) {
|
||
obj << "vt " << (col / 8.0f) << " "
|
||
<< (row / 8.0f) << "\n";
|
||
}
|
||
}
|
||
// 8x8 quads — two tris each, respecting hole bits so
|
||
// cave-entrance quads correctly disappear from the mesh.
|
||
bool isHoleChunk = (chunk.holes != 0);
|
||
obj << "g chunk_" << cx << "_" << cy << "\n";
|
||
auto idx = [&](int r, int c) {
|
||
return vertOffset + r * 9 + c + 1; // 1-based
|
||
};
|
||
for (int row = 0; row < 8; ++row) {
|
||
for (int col = 0; col < 8; ++col) {
|
||
if (isHoleChunk) {
|
||
int hx = col / 2, hy = row / 2;
|
||
if (chunk.holes & (1 << (hy * 4 + hx))) continue;
|
||
}
|
||
uint32_t i00 = idx(row, col);
|
||
uint32_t i10 = idx(row, col + 1);
|
||
uint32_t i01 = idx(row + 1, col);
|
||
uint32_t i11 = idx(row + 1, col + 1);
|
||
obj << "f " << i00 << "/" << i00 << " "
|
||
<< i10 << "/" << i10 << " "
|
||
<< i11 << "/" << i11 << "\n";
|
||
obj << "f " << i00 << "/" << i00 << " "
|
||
<< i11 << "/" << i11 << " "
|
||
<< i01 << "/" << i01 << "\n";
|
||
}
|
||
}
|
||
vertOffset += 81; // 9x9 verts per chunk
|
||
}
|
||
}
|
||
obj.close();
|
||
// Estimated tri count: chunks × 128 (8x8 quads × 2 tris).
|
||
// Holes reduce this but counting exactly would mean walking
|
||
// the bitmask again — the rough estimate is the user-visible
|
||
// useful number anyway.
|
||
std::printf("Exported %s.whm -> %s\n", base.c_str(), outPath.c_str());
|
||
std::printf(" %d chunks loaded, ~%d verts, ~%d tris\n",
|
||
loadedChunks, loadedChunks * 81, loadedChunks * 128);
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--import-obj") == 0 && i + 1 < argc) {
|
||
// Convert a Wavefront OBJ back into WOM. Round-trips with
|
||
// --export-obj for the geometry/UV/normal data; bones,
|
||
// animations, and material flags are not in OBJ and stay
|
||
// empty (the resulting WOM is WOM1, static-only). The intent
|
||
// is "edit a static prop in Blender, ship it".
|
||
std::string objPath = argv[++i];
|
||
std::string womBase;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
womBase = argv[++i];
|
||
}
|
||
if (!std::filesystem::exists(objPath)) {
|
||
std::fprintf(stderr, "OBJ not found: %s\n", objPath.c_str());
|
||
return 1;
|
||
}
|
||
if (womBase.empty()) {
|
||
womBase = objPath;
|
||
if (womBase.size() >= 4 &&
|
||
womBase.substr(womBase.size() - 4) == ".obj") {
|
||
womBase = womBase.substr(0, womBase.size() - 4);
|
||
}
|
||
}
|
||
std::ifstream in(objPath);
|
||
if (!in) {
|
||
std::fprintf(stderr, "Failed to open OBJ: %s\n", objPath.c_str());
|
||
return 1;
|
||
}
|
||
// Pools — OBJ stores positions/UVs/normals in independent
|
||
// arrays and references them by index in face lines, so we
|
||
// collect each pool first then expand into WOM vertices on
|
||
// the fly (one WOM vertex per (vIdx, vtIdx, vnIdx) triple
|
||
// since WOM has interleaved vertex data, not pooled).
|
||
std::vector<glm::vec3> positions;
|
||
std::vector<glm::vec2> texcoords;
|
||
std::vector<glm::vec3> normals;
|
||
wowee::pipeline::WoweeModel wom;
|
||
wom.version = 1;
|
||
std::unordered_map<std::string, uint32_t> dedupe;
|
||
int badFaces = 0;
|
||
int triangulatedNgons = 0;
|
||
std::string objectName;
|
||
std::string line;
|
||
// Convert a single OBJ vertex token like "3/4/5" or "3//5" or
|
||
// "3/4" or "3" into a WOM vertex index, deduping identical
|
||
// (pos, uv, normal) triples to keep the buffer compact.
|
||
auto resolveCorner = [&](const std::string& token) -> int {
|
||
int v = 0, t = 0, n = 0;
|
||
{
|
||
const char* p = token.c_str();
|
||
char* endp = nullptr;
|
||
v = std::strtol(p, &endp, 10);
|
||
if (*endp == '/') {
|
||
++endp;
|
||
if (*endp != '/') {
|
||
t = std::strtol(endp, &endp, 10);
|
||
}
|
||
if (*endp == '/') {
|
||
++endp;
|
||
n = std::strtol(endp, &endp, 10);
|
||
}
|
||
}
|
||
}
|
||
// Translate negative (relative) indices to absolute.
|
||
auto absIdx = [](int idx, size_t poolSize) -> int {
|
||
if (idx < 0) return static_cast<int>(poolSize) + idx;
|
||
return idx - 1; // OBJ is 1-based
|
||
};
|
||
int vi = absIdx(v, positions.size());
|
||
int ti = (t == 0) ? -1 : absIdx(t, texcoords.size());
|
||
int ni = (n == 0) ? -1 : absIdx(n, normals.size());
|
||
if (vi < 0 || vi >= static_cast<int>(positions.size())) return -1;
|
||
std::string key = std::to_string(vi) + "/" +
|
||
std::to_string(ti) + "/" +
|
||
std::to_string(ni);
|
||
auto it = dedupe.find(key);
|
||
if (it != dedupe.end()) return static_cast<int>(it->second);
|
||
wowee::pipeline::WoweeModel::Vertex vert;
|
||
vert.position = positions[vi];
|
||
if (ti >= 0 && ti < static_cast<int>(texcoords.size())) {
|
||
vert.texCoord = texcoords[ti];
|
||
// Reverse the V-flip from --export-obj so a round-trip
|
||
// returns the original UVs unchanged.
|
||
vert.texCoord.y = 1.0f - vert.texCoord.y;
|
||
} else {
|
||
vert.texCoord = {0, 0};
|
||
}
|
||
if (ni >= 0 && ni < static_cast<int>(normals.size())) {
|
||
vert.normal = normals[ni];
|
||
} else {
|
||
vert.normal = {0, 0, 1};
|
||
}
|
||
uint32_t newIdx = static_cast<uint32_t>(wom.vertices.size());
|
||
wom.vertices.push_back(vert);
|
||
dedupe[key] = newIdx;
|
||
return static_cast<int>(newIdx);
|
||
};
|
||
while (std::getline(in, line)) {
|
||
// Strip CR for CRLF files.
|
||
while (!line.empty() && (line.back() == '\r' || line.back() == ' '))
|
||
line.pop_back();
|
||
if (line.empty() || line[0] == '#') continue;
|
||
std::istringstream ss(line);
|
||
std::string tag;
|
||
ss >> tag;
|
||
if (tag == "v") {
|
||
glm::vec3 p; ss >> p.x >> p.y >> p.z;
|
||
positions.push_back(p);
|
||
} else if (tag == "vt") {
|
||
glm::vec2 t; ss >> t.x >> t.y;
|
||
texcoords.push_back(t);
|
||
} else if (tag == "vn") {
|
||
glm::vec3 n; ss >> n.x >> n.y >> n.z;
|
||
normals.push_back(n);
|
||
} else if (tag == "o") {
|
||
if (objectName.empty()) ss >> objectName;
|
||
} else if (tag == "f") {
|
||
std::vector<std::string> corners;
|
||
std::string c;
|
||
while (ss >> c) corners.push_back(c);
|
||
if (corners.size() < 3) { badFaces++; continue; }
|
||
std::vector<int> resolved;
|
||
resolved.reserve(corners.size());
|
||
bool ok = true;
|
||
for (const auto& cc : corners) {
|
||
int idx = resolveCorner(cc);
|
||
if (idx < 0) { ok = false; break; }
|
||
resolved.push_back(idx);
|
||
}
|
||
if (!ok) { badFaces++; continue; }
|
||
// Fan-triangulate (works for triangles, quads, and
|
||
// n-gons; assumes the polygon is convex which is the
|
||
// common case from DCC exporters).
|
||
if (resolved.size() > 3) triangulatedNgons++;
|
||
for (size_t k = 1; k + 1 < resolved.size(); ++k) {
|
||
wom.indices.push_back(static_cast<uint32_t>(resolved[0]));
|
||
wom.indices.push_back(static_cast<uint32_t>(resolved[k]));
|
||
wom.indices.push_back(static_cast<uint32_t>(resolved[k + 1]));
|
||
}
|
||
}
|
||
// mtllib/usemtl/g/s lines are silently skipped — material
|
||
// info doesn't survive the round-trip but groups would
|
||
// (left as future work; current import keeps it simple).
|
||
}
|
||
if (wom.vertices.empty() || wom.indices.empty()) {
|
||
std::fprintf(stderr, "import-obj: no geometry found in %s\n",
|
||
objPath.c_str());
|
||
return 1;
|
||
}
|
||
wom.name = objectName.empty()
|
||
? std::filesystem::path(objPath).stem().string()
|
||
: objectName;
|
||
// Compute bounds from positions — the renderer culls by these
|
||
// so wrong values cause the model to disappear at distance.
|
||
wom.boundMin = wom.vertices[0].position;
|
||
wom.boundMax = wom.boundMin;
|
||
for (const auto& v : wom.vertices) {
|
||
wom.boundMin = glm::min(wom.boundMin, v.position);
|
||
wom.boundMax = glm::max(wom.boundMax, v.position);
|
||
}
|
||
glm::vec3 center = (wom.boundMin + wom.boundMax) * 0.5f;
|
||
float r2 = 0;
|
||
for (const auto& v : wom.vertices) {
|
||
glm::vec3 d = v.position - center;
|
||
r2 = std::max(r2, glm::dot(d, d));
|
||
}
|
||
wom.boundRadius = std::sqrt(r2);
|
||
if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) {
|
||
std::fprintf(stderr, "import-obj: failed to write %s.wom\n",
|
||
womBase.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Imported %s -> %s.wom\n", objPath.c_str(), womBase.c_str());
|
||
std::printf(" %zu verts, %zu tris, bounds [%.2f, %.2f, %.2f] - [%.2f, %.2f, %.2f]\n",
|
||
wom.vertices.size(), wom.indices.size() / 3,
|
||
wom.boundMin.x, wom.boundMin.y, wom.boundMin.z,
|
||
wom.boundMax.x, wom.boundMax.y, wom.boundMax.z);
|
||
if (triangulatedNgons > 0) {
|
||
std::printf(" fan-triangulated %d n-gon(s)\n", triangulatedNgons);
|
||
}
|
||
if (badFaces > 0) {
|
||
std::printf(" warning: skipped %d malformed face(s)\n", badFaces);
|
||
}
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--export-png") == 0 && i + 1 < argc) {
|
||
// Render heightmap, normal-map, and zone-map PNG previews for a
|
||
// terrain. Useful for portfolio screenshots, ground-truth map
|
||
// comparison, and quick visual validation without launching GUI.
|
||
std::string base = argv[++i];
|
||
for (const char* ext : {".wot", ".whm"}) {
|
||
if (base.size() >= 4 && base.substr(base.size() - 4) == ext) {
|
||
base = base.substr(0, base.size() - 4);
|
||
break;
|
||
}
|
||
}
|
||
if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) {
|
||
std::fprintf(stderr, "WOT/WHM not found at base: %s\n", base.c_str());
|
||
return 1;
|
||
}
|
||
wowee::pipeline::ADTTerrain terrain;
|
||
if (!wowee::pipeline::WoweeTerrainLoader::load(base, terrain)) {
|
||
std::fprintf(stderr, "Failed to load terrain: %s\n", base.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::WoweeTerrain::exportHeightmapPreview(terrain, base + "_heightmap.png");
|
||
wowee::editor::WoweeTerrain::exportNormalMap(terrain, base + "_normals.png");
|
||
wowee::editor::WoweeTerrain::exportZoneMap(terrain, base + "_zone.png", 512);
|
||
std::printf("Exported PNGs: %s_{heightmap,normals,zone}.png\n", base.c_str());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--fix-zone") == 0 && i + 1 < argc) {
|
||
// Re-parse + re-save every JSON/binary file in a zone to apply
|
||
// the editor's load-time scrubs and save-time caps. Useful when
|
||
// an old zone was created before recent hardening — running
|
||
// this once cleans up NaN/oversize fields without touching
|
||
// the editor GUI.
|
||
std::string zoneDir = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(zoneDir)) {
|
||
std::fprintf(stderr, "fix-zone: %s does not exist\n", zoneDir.c_str());
|
||
return 1;
|
||
}
|
||
int touched = 0;
|
||
// zone.json
|
||
{
|
||
wowee::editor::ZoneManifest m;
|
||
std::string p = zoneDir + "/zone.json";
|
||
if (fs::exists(p) && m.load(p) && m.save(p)) touched++;
|
||
}
|
||
// creatures.json
|
||
{
|
||
wowee::editor::NpcSpawner sp;
|
||
std::string p = zoneDir + "/creatures.json";
|
||
if (fs::exists(p) && sp.loadFromFile(p) && sp.saveToFile(p)) touched++;
|
||
}
|
||
// objects.json
|
||
{
|
||
wowee::editor::ObjectPlacer op;
|
||
std::string p = zoneDir + "/objects.json";
|
||
if (fs::exists(p) && op.loadFromFile(p) && op.saveToFile(p)) touched++;
|
||
}
|
||
// quests.json
|
||
{
|
||
wowee::editor::QuestEditor qe;
|
||
std::string p = zoneDir + "/quests.json";
|
||
if (fs::exists(p) && qe.loadFromFile(p) && qe.saveToFile(p)) touched++;
|
||
}
|
||
// WHM/WOT pairs and WoB files would need full pipeline access;
|
||
// skip them — the editor opens them on next zone load anyway,
|
||
// and the load-time scrubs run then.
|
||
std::printf("fix-zone: cleaned %d files in %s\n", touched, zoneDir.c_str());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--regen-collision") == 0 && i + 1 < argc) {
|
||
// Find all WHM/WOT pairs under a zone dir and rebuild WOC for each.
|
||
// Useful after sculpting changes when you want to re-derive
|
||
// collision in batch instead of one tile at a time.
|
||
std::string zoneDir = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(zoneDir)) {
|
||
std::fprintf(stderr, "regen-collision: %s does not exist\n",
|
||
zoneDir.c_str());
|
||
return 1;
|
||
}
|
||
int rebuilt = 0, failed = 0;
|
||
for (auto& entry : fs::recursive_directory_iterator(zoneDir)) {
|
||
if (!entry.is_regular_file()) continue;
|
||
if (entry.path().extension() != ".whm") continue;
|
||
std::string base = entry.path().string();
|
||
base = base.substr(0, base.size() - 4); // strip .whm
|
||
wowee::pipeline::ADTTerrain terrain;
|
||
if (!wowee::pipeline::WoweeTerrainLoader::load(base, terrain)) {
|
||
std::fprintf(stderr, " FAILED to load: %s\n", base.c_str());
|
||
failed++;
|
||
continue;
|
||
}
|
||
auto col = wowee::pipeline::WoweeCollisionBuilder::fromTerrain(terrain);
|
||
std::string outPath = base + ".woc";
|
||
if (wowee::pipeline::WoweeCollisionBuilder::save(col, outPath)) {
|
||
std::printf(" WOC rebuilt: %s (%zu triangles)\n",
|
||
outPath.c_str(), col.triangles.size());
|
||
rebuilt++;
|
||
} else {
|
||
std::fprintf(stderr, " FAILED to save: %s\n", outPath.c_str());
|
||
failed++;
|
||
}
|
||
}
|
||
std::printf("regen-collision: %d rebuilt, %d failed\n", rebuilt, failed);
|
||
return failed > 0 ? 1 : 0;
|
||
} else if (std::strcmp(argv[i], "--build-woc") == 0 && i + 1 < argc) {
|
||
// Generate a WOC collision mesh from a WHM/WOT terrain pair.
|
||
// Uses terrain triangles only (no WMO overlays); useful as a
|
||
// first-pass collision build before the editor adds buildings.
|
||
std::string base = argv[++i];
|
||
for (const char* ext : {".wot", ".whm", ".woc"}) {
|
||
if (base.size() >= 4 && base.substr(base.size() - 4) == ext) {
|
||
base = base.substr(0, base.size() - 4);
|
||
break;
|
||
}
|
||
}
|
||
if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) {
|
||
std::fprintf(stderr, "WOT/WHM not found at base: %s\n", base.c_str());
|
||
return 1;
|
||
}
|
||
wowee::pipeline::ADTTerrain terrain;
|
||
if (!wowee::pipeline::WoweeTerrainLoader::load(base, terrain)) {
|
||
std::fprintf(stderr, "Failed to load terrain: %s\n", base.c_str());
|
||
return 1;
|
||
}
|
||
auto col = wowee::pipeline::WoweeCollisionBuilder::fromTerrain(terrain);
|
||
std::string outPath = base + ".woc";
|
||
if (!wowee::pipeline::WoweeCollisionBuilder::save(col, outPath)) {
|
||
std::fprintf(stderr, "WOC save failed: %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("WOC built: %s (%zu triangles, %zu walkable, %zu steep)\n",
|
||
outPath.c_str(),
|
||
col.triangles.size(), col.walkableCount(), col.steepCount());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--add-quest") == 0 && i + 2 < argc) {
|
||
// Append a single quest to a zone's quests.json.
|
||
// Args: <zoneDir> <title> [giverId] [turnInId] [xp] [level]
|
||
std::string zoneDir = argv[++i];
|
||
std::string title = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(zoneDir)) {
|
||
std::fprintf(stderr, "add-quest: zone '%s' does not exist\n",
|
||
zoneDir.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::Quest q;
|
||
q.title = title;
|
||
// Optional positional args after title. Each is read in order;
|
||
// an empty string or '-' stops consumption so users can omit
|
||
// later fields.
|
||
auto tryReadUint = [&](uint32_t& target) {
|
||
if (i + 1 >= argc || argv[i + 1][0] == '-') return false;
|
||
try {
|
||
target = static_cast<uint32_t>(std::stoul(argv[i + 1]));
|
||
++i;
|
||
return true;
|
||
} catch (...) { return false; }
|
||
};
|
||
tryReadUint(q.questGiverNpcId);
|
||
tryReadUint(q.turnInNpcId);
|
||
tryReadUint(q.reward.xp);
|
||
tryReadUint(q.requiredLevel);
|
||
wowee::editor::QuestEditor qe;
|
||
std::string path = zoneDir + "/quests.json";
|
||
if (fs::exists(path)) qe.loadFromFile(path);
|
||
qe.addQuest(q);
|
||
if (!qe.saveToFile(path)) {
|
||
std::fprintf(stderr, "add-quest: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Added quest '%s' to %s (now %zu total)\n",
|
||
title.c_str(), path.c_str(), qe.questCount());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--add-quest-objective") == 0 && i + 4 < argc) {
|
||
// Append a single objective to an existing quest. The quest
|
||
// must already exist (use --add-quest first); index is 0-based
|
||
// and matches --list-quests output.
|
||
std::string zoneDir = argv[++i];
|
||
std::string idxStr = argv[++i];
|
||
std::string typeStr = argv[++i];
|
||
std::string targetName = argv[++i];
|
||
std::string path = zoneDir + "/quests.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "add-quest-objective: %s not found — run --add-quest first\n",
|
||
path.c_str());
|
||
return 1;
|
||
}
|
||
int idx;
|
||
try { idx = std::stoi(idxStr); }
|
||
catch (...) {
|
||
std::fprintf(stderr, "add-quest-objective: bad questIdx '%s'\n", idxStr.c_str());
|
||
return 1;
|
||
}
|
||
using OT = wowee::editor::QuestObjectiveType;
|
||
OT type;
|
||
if (typeStr == "kill") type = OT::KillCreature;
|
||
else if (typeStr == "collect") type = OT::CollectItem;
|
||
else if (typeStr == "talk") type = OT::TalkToNPC;
|
||
else if (typeStr == "explore") type = OT::ExploreArea;
|
||
else if (typeStr == "escort") type = OT::EscortNPC;
|
||
else if (typeStr == "use") type = OT::UseObject;
|
||
else {
|
||
std::fprintf(stderr,
|
||
"add-quest-objective: type must be kill/collect/talk/explore/escort/use, got '%s'\n",
|
||
typeStr.c_str());
|
||
return 1;
|
||
}
|
||
uint32_t count = 1;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
try {
|
||
count = static_cast<uint32_t>(std::stoul(argv[++i]));
|
||
if (count == 0) count = 1;
|
||
} catch (...) {}
|
||
}
|
||
wowee::editor::QuestEditor qe;
|
||
if (!qe.loadFromFile(path)) {
|
||
std::fprintf(stderr, "add-quest-objective: failed to load %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
if (idx < 0 || idx >= static_cast<int>(qe.questCount())) {
|
||
std::fprintf(stderr,
|
||
"add-quest-objective: questIdx %d out of range [0, %zu)\n",
|
||
idx, qe.questCount());
|
||
return 1;
|
||
}
|
||
wowee::editor::QuestObjective obj;
|
||
obj.type = type;
|
||
obj.targetName = targetName;
|
||
obj.targetCount = count;
|
||
// Auto-generate a description from type+name+count so addons
|
||
// and tooltips have something useful by default. The user can
|
||
// edit quests.json directly if they want bespoke prose.
|
||
const char* verb = "complete";
|
||
switch (type) {
|
||
case OT::KillCreature: verb = "Slay"; break;
|
||
case OT::CollectItem: verb = "Collect"; break;
|
||
case OT::TalkToNPC: verb = "Talk to"; break;
|
||
case OT::ExploreArea: verb = "Explore"; break;
|
||
case OT::EscortNPC: verb = "Escort"; break;
|
||
case OT::UseObject: verb = "Use"; break;
|
||
}
|
||
obj.description = std::string(verb) + " " +
|
||
(count > 1 ? std::to_string(count) + " " : "") +
|
||
targetName;
|
||
// Quest is stored by value in the editor's vector; mutate via
|
||
// the non-const getter, which gives us a pointer we can write
|
||
// through.
|
||
wowee::editor::Quest* q = qe.getQuest(idx);
|
||
if (!q) {
|
||
std::fprintf(stderr, "add-quest-objective: getQuest(%d) returned null\n", idx);
|
||
return 1;
|
||
}
|
||
q->objectives.push_back(obj);
|
||
if (!qe.saveToFile(path)) {
|
||
std::fprintf(stderr, "add-quest-objective: failed to write %s\n",
|
||
path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Added objective '%s' to quest %d ('%s'), now %zu objective(s)\n",
|
||
obj.description.c_str(), idx, q->title.c_str(),
|
||
q->objectives.size());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--remove-quest-objective") == 0 && i + 3 < argc) {
|
||
// Symmetric counterpart to --add-quest-objective. Removes the
|
||
// objective at <objIdx> within quest <questIdx>. Pair with
|
||
// --info-quests / --list-quests to find the right indices.
|
||
std::string zoneDir = argv[++i];
|
||
std::string qIdxStr = argv[++i];
|
||
std::string oIdxStr = argv[++i];
|
||
std::string path = zoneDir + "/quests.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "remove-quest-objective: %s not found\n", path.c_str());
|
||
return 1;
|
||
}
|
||
int qIdx, oIdx;
|
||
try {
|
||
qIdx = std::stoi(qIdxStr);
|
||
oIdx = std::stoi(oIdxStr);
|
||
} catch (...) {
|
||
std::fprintf(stderr, "remove-quest-objective: bad index\n");
|
||
return 1;
|
||
}
|
||
wowee::editor::QuestEditor qe;
|
||
if (!qe.loadFromFile(path)) {
|
||
std::fprintf(stderr, "remove-quest-objective: failed to load %s\n",
|
||
path.c_str());
|
||
return 1;
|
||
}
|
||
if (qIdx < 0 || qIdx >= static_cast<int>(qe.questCount())) {
|
||
std::fprintf(stderr,
|
||
"remove-quest-objective: questIdx %d out of range [0, %zu)\n",
|
||
qIdx, qe.questCount());
|
||
return 1;
|
||
}
|
||
wowee::editor::Quest* q = qe.getQuest(qIdx);
|
||
if (!q) return 1;
|
||
if (oIdx < 0 || oIdx >= static_cast<int>(q->objectives.size())) {
|
||
std::fprintf(stderr,
|
||
"remove-quest-objective: objIdx %d out of range [0, %zu)\n",
|
||
oIdx, q->objectives.size());
|
||
return 1;
|
||
}
|
||
std::string removedDesc = q->objectives[oIdx].description;
|
||
q->objectives.erase(q->objectives.begin() + oIdx);
|
||
if (!qe.saveToFile(path)) {
|
||
std::fprintf(stderr, "remove-quest-objective: failed to write %s\n",
|
||
path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Removed objective '%s' (was index %d) from quest %d ('%s'), now %zu remaining\n",
|
||
removedDesc.c_str(), oIdx, qIdx, q->title.c_str(),
|
||
q->objectives.size());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--clone-quest") == 0 && i + 2 < argc) {
|
||
// Duplicate a quest. Useful for templating: create a base
|
||
// quest with objectives + rewards once, then clone N times
|
||
// for variants ('Slay Wolves', 'Slay Bears' with the same
|
||
// shape). Optional newTitle replaces the cloned copy's title;
|
||
// omit to get '<original> (copy)'.
|
||
std::string zoneDir = argv[++i];
|
||
std::string idxStr = argv[++i];
|
||
std::string newTitle;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
newTitle = argv[++i];
|
||
}
|
||
std::string path = zoneDir + "/quests.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "clone-quest: %s not found\n", path.c_str());
|
||
return 1;
|
||
}
|
||
int qIdx;
|
||
try { qIdx = std::stoi(idxStr); }
|
||
catch (...) {
|
||
std::fprintf(stderr, "clone-quest: bad questIdx '%s'\n", idxStr.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::QuestEditor qe;
|
||
if (!qe.loadFromFile(path)) {
|
||
std::fprintf(stderr, "clone-quest: failed to load %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
if (qIdx < 0 || qIdx >= static_cast<int>(qe.questCount())) {
|
||
std::fprintf(stderr,
|
||
"clone-quest: questIdx %d out of range [0, %zu)\n",
|
||
qIdx, qe.questCount());
|
||
return 1;
|
||
}
|
||
// Deep-copy by value via vector iteration; .objectives and
|
||
// .reward are STL containers so the copy is automatic.
|
||
wowee::editor::Quest clone = qe.getQuests()[qIdx];
|
||
// Reset id so the editor's auto-id sequence assigns a fresh
|
||
// one — addQuest does this internally if id==0.
|
||
clone.id = 0;
|
||
// Reset chain link too — copying a chained quest with the
|
||
// same nextQuestId would corrupt the chain semantics.
|
||
clone.nextQuestId = 0;
|
||
clone.title = newTitle.empty()
|
||
? (clone.title + " (copy)")
|
||
: newTitle;
|
||
qe.addQuest(clone);
|
||
if (!qe.saveToFile(path)) {
|
||
std::fprintf(stderr, "clone-quest: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Cloned quest %d -> '%s' (now %zu total)\n",
|
||
qIdx, clone.title.c_str(), qe.questCount());
|
||
std::printf(" carried %zu objective(s), %zu item reward(s), xp=%u\n",
|
||
clone.objectives.size(),
|
||
clone.reward.itemRewards.size(),
|
||
clone.reward.xp);
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--clone-creature") == 0 && i + 2 < argc) {
|
||
// Duplicate a creature spawn. Common workflow: design one
|
||
// 'patrol guard' archetype, then clone it across spawn points
|
||
// around a town. Preserves stats, faction, behavior, equipment;
|
||
// resets id and offsets position by 5 yards by default so the
|
||
// copy doesn't z-fight with the original.
|
||
std::string zoneDir = argv[++i];
|
||
std::string idxStr = argv[++i];
|
||
std::string newName;
|
||
float dx = 5.0f, dy = 0.0f, dz = 0.0f;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
newName = argv[++i];
|
||
}
|
||
// Optional 3-axis offset after newName.
|
||
if (i + 3 < argc && argv[i + 1][0] != '-') {
|
||
try {
|
||
dx = std::stof(argv[++i]);
|
||
dy = std::stof(argv[++i]);
|
||
dz = std::stof(argv[++i]);
|
||
} catch (...) {
|
||
std::fprintf(stderr, "clone-creature: bad offset coordinate\n");
|
||
return 1;
|
||
}
|
||
}
|
||
std::string path = zoneDir + "/creatures.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "clone-creature: %s not found\n", path.c_str());
|
||
return 1;
|
||
}
|
||
int idx;
|
||
try { idx = std::stoi(idxStr); }
|
||
catch (...) {
|
||
std::fprintf(stderr, "clone-creature: bad idx '%s'\n", idxStr.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::NpcSpawner sp;
|
||
if (!sp.loadFromFile(path)) {
|
||
std::fprintf(stderr, "clone-creature: failed to load %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
if (idx < 0 || idx >= static_cast<int>(sp.spawnCount())) {
|
||
std::fprintf(stderr,
|
||
"clone-creature: idx %d out of range [0, %zu)\n",
|
||
idx, sp.spawnCount());
|
||
return 1;
|
||
}
|
||
// Deep-copy by value; CreatureSpawn is POD-ish (vectors for
|
||
// patrol points copy automatically).
|
||
wowee::editor::CreatureSpawn clone = sp.getSpawns()[idx];
|
||
clone.id = 0; // addCreature auto-assigns a fresh id
|
||
clone.name = newName.empty()
|
||
? (clone.name + " (copy)")
|
||
: newName;
|
||
clone.position.x += dx;
|
||
clone.position.y += dy;
|
||
clone.position.z += dz;
|
||
// Patrol path is intentionally NOT offset — patrol points are
|
||
// typically authored as world-space waypoints, not relative to
|
||
// the spawn. Designers re-author the path if needed.
|
||
sp.getSpawns().push_back(clone);
|
||
if (!sp.saveToFile(path)) {
|
||
std::fprintf(stderr, "clone-creature: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Cloned creature %d -> '%s' at (%.1f, %.1f, %.1f) (now %zu total)\n",
|
||
idx, clone.name.c_str(),
|
||
clone.position.x, clone.position.y, clone.position.z,
|
||
sp.spawnCount());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--clone-object") == 0 && i + 2 < argc) {
|
||
// Symmetric to --clone-creature/--clone-quest. Common
|
||
// workflow: place one tree/lamp/barrel just right, then
|
||
// clone N copies along a path or around a square. Default
|
||
// 5-yard X offset prevents z-fighting; rotation/scale are
|
||
// preserved so a tilted object stays tilted.
|
||
std::string zoneDir = argv[++i];
|
||
std::string idxStr = argv[++i];
|
||
float dx = 5.0f, dy = 0.0f, dz = 0.0f;
|
||
if (i + 3 < argc && argv[i + 1][0] != '-') {
|
||
try {
|
||
dx = std::stof(argv[++i]);
|
||
dy = std::stof(argv[++i]);
|
||
dz = std::stof(argv[++i]);
|
||
} catch (...) {
|
||
std::fprintf(stderr, "clone-object: bad offset\n");
|
||
return 1;
|
||
}
|
||
}
|
||
std::string path = zoneDir + "/objects.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "clone-object: %s not found\n", path.c_str());
|
||
return 1;
|
||
}
|
||
int idx;
|
||
try { idx = std::stoi(idxStr); }
|
||
catch (...) {
|
||
std::fprintf(stderr, "clone-object: bad idx '%s'\n", idxStr.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::ObjectPlacer placer;
|
||
if (!placer.loadFromFile(path)) {
|
||
std::fprintf(stderr, "clone-object: failed to load %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
auto& objs = placer.getObjects();
|
||
if (idx < 0 || idx >= static_cast<int>(objs.size())) {
|
||
std::fprintf(stderr,
|
||
"clone-object: idx %d out of range [0, %zu)\n",
|
||
idx, objs.size());
|
||
return 1;
|
||
}
|
||
// Deep-copy by value. uniqueId is reset so the new object
|
||
// doesn't collide with the source's identifier in any
|
||
// downstream system that dedups by it.
|
||
wowee::editor::PlacedObject clone = objs[idx];
|
||
clone.uniqueId = 0;
|
||
clone.selected = false;
|
||
clone.position.x += dx;
|
||
clone.position.y += dy;
|
||
clone.position.z += dz;
|
||
objs.push_back(clone);
|
||
if (!placer.saveToFile(path)) {
|
||
std::fprintf(stderr, "clone-object: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Cloned object %d -> '%s' at (%.1f, %.1f, %.1f) (now %zu total)\n",
|
||
idx, clone.path.c_str(),
|
||
clone.position.x, clone.position.y, clone.position.z,
|
||
objs.size());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--add-quest-reward-item") == 0 && i + 3 < argc) {
|
||
// Append one or more item rewards to a quest. Multiple paths
|
||
// can be passed in a single invocation:
|
||
// --add-quest-reward-item zone 0 'Item:Sword' 'Item:Shield'
|
||
std::string zoneDir = argv[++i];
|
||
std::string idxStr = argv[++i];
|
||
std::string path = zoneDir + "/quests.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "add-quest-reward-item: %s not found\n", path.c_str());
|
||
return 1;
|
||
}
|
||
int idx;
|
||
try { idx = std::stoi(idxStr); }
|
||
catch (...) {
|
||
std::fprintf(stderr, "add-quest-reward-item: bad questIdx '%s'\n", idxStr.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::QuestEditor qe;
|
||
if (!qe.loadFromFile(path)) {
|
||
std::fprintf(stderr, "add-quest-reward-item: failed to load %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
if (idx < 0 || idx >= static_cast<int>(qe.questCount())) {
|
||
std::fprintf(stderr,
|
||
"add-quest-reward-item: questIdx %d out of range [0, %zu)\n",
|
||
idx, qe.questCount());
|
||
return 1;
|
||
}
|
||
wowee::editor::Quest* q = qe.getQuest(idx);
|
||
if (!q) return 1;
|
||
int added = 0;
|
||
// Greedy-consume any remaining args that don't start with '-'
|
||
// so the caller can batch-add a whole loot table in one shot.
|
||
while (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
q->reward.itemRewards.push_back(argv[++i]);
|
||
added++;
|
||
}
|
||
if (added == 0) {
|
||
std::fprintf(stderr, "add-quest-reward-item: need at least one itemPath\n");
|
||
return 1;
|
||
}
|
||
if (!qe.saveToFile(path)) {
|
||
std::fprintf(stderr, "add-quest-reward-item: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Added %d item reward(s) to quest %d ('%s'), now %zu total\n",
|
||
added, idx, q->title.c_str(), q->reward.itemRewards.size());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--set-quest-reward") == 0 && i + 2 < argc) {
|
||
// Update XP / coin reward fields on an existing quest. Each
|
||
// field is optional — only the ones explicitly passed are
|
||
// changed. This avoids the round-trip-and-clobber footgun of
|
||
// a "replace whole reward" command.
|
||
std::string zoneDir = argv[++i];
|
||
std::string idxStr = argv[++i];
|
||
std::string path = zoneDir + "/quests.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "set-quest-reward: %s not found\n", path.c_str());
|
||
return 1;
|
||
}
|
||
int idx;
|
||
try { idx = std::stoi(idxStr); }
|
||
catch (...) {
|
||
std::fprintf(stderr, "set-quest-reward: bad questIdx '%s'\n", idxStr.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::QuestEditor qe;
|
||
if (!qe.loadFromFile(path)) {
|
||
std::fprintf(stderr, "set-quest-reward: failed to load %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
if (idx < 0 || idx >= static_cast<int>(qe.questCount())) {
|
||
std::fprintf(stderr,
|
||
"set-quest-reward: questIdx %d out of range [0, %zu)\n",
|
||
idx, qe.questCount());
|
||
return 1;
|
||
}
|
||
wowee::editor::Quest* q = qe.getQuest(idx);
|
||
if (!q) return 1;
|
||
int changed = 0;
|
||
auto consumeUint = [&](const char* flag, uint32_t& target) {
|
||
if (i + 2 < argc && std::strcmp(argv[i + 1], flag) == 0) {
|
||
try {
|
||
target = static_cast<uint32_t>(std::stoul(argv[i + 2]));
|
||
i += 2;
|
||
changed++;
|
||
return true;
|
||
} catch (...) {
|
||
std::fprintf(stderr, "set-quest-reward: bad %s value '%s'\n",
|
||
flag, argv[i + 2]);
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
// Loop until no more recognised flags consume their value —
|
||
// order-independent, so callers can pass --gold then --xp.
|
||
bool any = true;
|
||
while (any) {
|
||
any = false;
|
||
if (consumeUint("--xp", q->reward.xp)) any = true;
|
||
if (consumeUint("--gold", q->reward.gold)) any = true;
|
||
if (consumeUint("--silver", q->reward.silver)) any = true;
|
||
if (consumeUint("--copper", q->reward.copper)) any = true;
|
||
}
|
||
if (changed == 0) {
|
||
std::fprintf(stderr,
|
||
"set-quest-reward: no fields changed — pass --xp / --gold / --silver / --copper\n");
|
||
return 1;
|
||
}
|
||
if (!qe.saveToFile(path)) {
|
||
std::fprintf(stderr, "set-quest-reward: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Updated %d field(s) on quest %d ('%s'): xp=%u gold=%u silver=%u copper=%u\n",
|
||
changed, idx, q->title.c_str(),
|
||
q->reward.xp, q->reward.gold,
|
||
q->reward.silver, q->reward.copper);
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--remove-creature") == 0 && i + 2 < argc) {
|
||
// Remove a creature spawn by 0-based index. Pair with
|
||
// --info-creatures (or your editor) to find the right index
|
||
// first; nothing identifies entries reliably across reloads.
|
||
std::string zoneDir = argv[++i];
|
||
std::string idxStr = argv[++i];
|
||
std::string path = zoneDir + "/creatures.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "remove-creature: %s not found\n", path.c_str());
|
||
return 1;
|
||
}
|
||
int idx;
|
||
try { idx = std::stoi(idxStr); }
|
||
catch (...) {
|
||
std::fprintf(stderr, "remove-creature: bad index '%s'\n", idxStr.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::NpcSpawner sp;
|
||
sp.loadFromFile(path);
|
||
if (idx < 0 || idx >= static_cast<int>(sp.spawnCount())) {
|
||
std::fprintf(stderr, "remove-creature: index %d out of range [0, %zu)\n",
|
||
idx, sp.spawnCount());
|
||
return 1;
|
||
}
|
||
std::string removedName = sp.getSpawns()[idx].name;
|
||
sp.removeCreature(idx);
|
||
if (!sp.saveToFile(path)) {
|
||
std::fprintf(stderr, "remove-creature: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Removed creature '%s' (was index %d) from %s (now %zu total)\n",
|
||
removedName.c_str(), idx, path.c_str(), sp.spawnCount());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--remove-object") == 0 && i + 2 < argc) {
|
||
std::string zoneDir = argv[++i];
|
||
std::string idxStr = argv[++i];
|
||
std::string path = zoneDir + "/objects.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "remove-object: %s not found\n", path.c_str());
|
||
return 1;
|
||
}
|
||
int idx;
|
||
try { idx = std::stoi(idxStr); }
|
||
catch (...) {
|
||
std::fprintf(stderr, "remove-object: bad index '%s'\n", idxStr.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::ObjectPlacer placer;
|
||
placer.loadFromFile(path);
|
||
auto& objs = placer.getObjects();
|
||
if (idx < 0 || idx >= static_cast<int>(objs.size())) {
|
||
std::fprintf(stderr, "remove-object: index %d out of range [0, %zu)\n",
|
||
idx, objs.size());
|
||
return 1;
|
||
}
|
||
std::string removedPath = objs[idx].path;
|
||
objs.erase(objs.begin() + idx);
|
||
if (!placer.saveToFile(path)) {
|
||
std::fprintf(stderr, "remove-object: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Removed object '%s' (was index %d) from %s (now %zu total)\n",
|
||
removedPath.c_str(), idx, path.c_str(), objs.size());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--remove-quest") == 0 && i + 2 < argc) {
|
||
std::string zoneDir = argv[++i];
|
||
std::string idxStr = argv[++i];
|
||
std::string path = zoneDir + "/quests.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr, "remove-quest: %s not found\n", path.c_str());
|
||
return 1;
|
||
}
|
||
int idx;
|
||
try { idx = std::stoi(idxStr); }
|
||
catch (...) {
|
||
std::fprintf(stderr, "remove-quest: bad index '%s'\n", idxStr.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::QuestEditor qe;
|
||
qe.loadFromFile(path);
|
||
if (idx < 0 || idx >= static_cast<int>(qe.questCount())) {
|
||
std::fprintf(stderr, "remove-quest: index %d out of range [0, %zu)\n",
|
||
idx, qe.questCount());
|
||
return 1;
|
||
}
|
||
std::string removedTitle = qe.getQuests()[idx].title;
|
||
qe.removeQuest(idx);
|
||
if (!qe.saveToFile(path)) {
|
||
std::fprintf(stderr, "remove-quest: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Removed quest '%s' (was index %d) from %s (now %zu total)\n",
|
||
removedTitle.c_str(), idx, path.c_str(), qe.questCount());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--add-object") == 0 && i + 5 < argc) {
|
||
// Append a single object placement to a zone's objects.json.
|
||
// Args: <zoneDir> <m2|wmo> <gamePath> <x> <y> <z> [scale]
|
||
std::string zoneDir = argv[++i];
|
||
std::string typeStr = argv[++i];
|
||
std::string gamePath = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(zoneDir)) {
|
||
std::fprintf(stderr, "add-object: zone '%s' does not exist\n",
|
||
zoneDir.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::PlaceableType ptype;
|
||
if (typeStr == "m2") ptype = wowee::editor::PlaceableType::M2;
|
||
else if (typeStr == "wmo") ptype = wowee::editor::PlaceableType::WMO;
|
||
else {
|
||
std::fprintf(stderr, "add-object: type must be 'm2' or 'wmo'\n");
|
||
return 1;
|
||
}
|
||
glm::vec3 pos;
|
||
try {
|
||
pos.x = std::stof(argv[++i]);
|
||
pos.y = std::stof(argv[++i]);
|
||
pos.z = std::stof(argv[++i]);
|
||
} catch (const std::exception& e) {
|
||
std::fprintf(stderr, "add-object: bad coordinate (%s)\n", e.what());
|
||
return 1;
|
||
}
|
||
wowee::editor::ObjectPlacer placer;
|
||
std::string path = zoneDir + "/objects.json";
|
||
if (fs::exists(path)) placer.loadFromFile(path);
|
||
placer.setActivePath(gamePath, ptype);
|
||
placer.placeObject(pos);
|
||
// Optional scale after coordinates.
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
try {
|
||
float scale = std::stof(argv[++i]);
|
||
if (std::isfinite(scale) && scale > 0.0f) {
|
||
// Set scale on the just-placed object (last in list).
|
||
placer.getObjects().back().scale = scale;
|
||
}
|
||
} catch (...) {}
|
||
}
|
||
if (!placer.saveToFile(path)) {
|
||
std::fprintf(stderr, "add-object: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Added %s '%s' to %s (now %zu total)\n",
|
||
typeStr.c_str(), gamePath.c_str(), path.c_str(),
|
||
placer.getObjects().size());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--add-creature") == 0 && i + 4 < argc) {
|
||
// Append a single creature spawn to a zone's creatures.json.
|
||
// Args: <zoneDir> <name> <x> <y> <z> [displayId] [level]
|
||
// Useful for batch-populating zones via shell script without
|
||
// launching the GUI placement tool.
|
||
std::string zoneDir = argv[++i];
|
||
std::string name = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(zoneDir)) {
|
||
std::fprintf(stderr, "add-creature: zone '%s' does not exist\n",
|
||
zoneDir.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::CreatureSpawn s;
|
||
s.name = name;
|
||
try {
|
||
s.position.x = std::stof(argv[++i]);
|
||
s.position.y = std::stof(argv[++i]);
|
||
s.position.z = std::stof(argv[++i]);
|
||
} catch (const std::exception& e) {
|
||
std::fprintf(stderr, "add-creature: bad coordinate (%s)\n", e.what());
|
||
return 1;
|
||
}
|
||
// Optional displayId (positional, after coordinates).
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
try {
|
||
s.displayId = static_cast<uint32_t>(std::stoul(argv[++i]));
|
||
} catch (...) { /* leave 0 → SQL exporter substitutes 11707 */ }
|
||
}
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
try {
|
||
s.level = static_cast<uint32_t>(std::stoul(argv[++i]));
|
||
} catch (...) { /* leave default 1 */ }
|
||
}
|
||
// Load existing spawns (if any), append, save.
|
||
wowee::editor::NpcSpawner spawner;
|
||
std::string path = zoneDir + "/creatures.json";
|
||
if (fs::exists(path)) spawner.loadFromFile(path);
|
||
spawner.placeCreature(s);
|
||
if (!spawner.saveToFile(path)) {
|
||
std::fprintf(stderr, "add-creature: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::printf("Added creature '%s' to %s (now %zu total)\n",
|
||
name.c_str(), path.c_str(), spawner.spawnCount());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--add-item") == 0 && i + 2 < argc) {
|
||
// Append one item entry to <zoneDir>/items.json. Inline
|
||
// JSON without a dedicated editor class — items.json is
|
||
// a simple {"items": [...]} array of records, and the
|
||
// schema is small enough that we don't need NpcSpawner-
|
||
// style infrastructure yet.
|
||
//
|
||
// Schema per item:
|
||
// id (uint32) — Item.dbc primary key (auto-increments
|
||
// from 1 if omitted)
|
||
// name (string)
|
||
// quality (uint8) — 0..6 (poor..artifact, default 1)
|
||
// displayId (uint32) — ItemDisplayInfo index (default 0)
|
||
// itemLevel (uint32) — default 1
|
||
// stackable (uint32) — max stack size (default 1)
|
||
std::string zoneDir = argv[++i];
|
||
std::string name = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(zoneDir)) {
|
||
std::fprintf(stderr,
|
||
"add-item: zone '%s' does not exist\n", zoneDir.c_str());
|
||
return 1;
|
||
}
|
||
uint32_t id = 0, displayId = 0, itemLevel = 1;
|
||
uint32_t quality = 1;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
try { id = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||
catch (...) {}
|
||
}
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
try { quality = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||
catch (...) {}
|
||
if (quality > 6) quality = 1;
|
||
}
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
try { displayId = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||
catch (...) {}
|
||
}
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
try { itemLevel = static_cast<uint32_t>(std::stoul(argv[++i])); }
|
||
catch (...) {}
|
||
}
|
||
std::string path = zoneDir + "/items.json";
|
||
nlohmann::json doc = nlohmann::json::object({{"items",
|
||
nlohmann::json::array()}});
|
||
if (fs::exists(path)) {
|
||
std::ifstream in(path);
|
||
try { in >> doc; } catch (...) {
|
||
std::fprintf(stderr,
|
||
"add-item: %s exists but is not valid JSON\n",
|
||
path.c_str());
|
||
return 1;
|
||
}
|
||
if (!doc.contains("items") || !doc["items"].is_array()) {
|
||
doc["items"] = nlohmann::json::array();
|
||
}
|
||
}
|
||
// Auto-assign id if user passed 0 / nothing — pick the
|
||
// smallest unused positive integer so the items.json
|
||
// numbering stays contiguous.
|
||
if (id == 0) {
|
||
std::set<uint32_t> used;
|
||
for (const auto& it : doc["items"]) {
|
||
if (it.contains("id") && it["id"].is_number_unsigned()) {
|
||
used.insert(it["id"].get<uint32_t>());
|
||
}
|
||
}
|
||
id = 1;
|
||
while (used.count(id)) ++id;
|
||
}
|
||
// Reject duplicate id so the user notices a collision.
|
||
for (const auto& it : doc["items"]) {
|
||
if (it.contains("id") && it["id"].is_number_unsigned() &&
|
||
it["id"].get<uint32_t>() == id) {
|
||
std::fprintf(stderr,
|
||
"add-item: id %u already in use in %s\n",
|
||
id, path.c_str());
|
||
return 1;
|
||
}
|
||
}
|
||
nlohmann::json item = {
|
||
{"id", id},
|
||
{"name", name},
|
||
{"quality", quality},
|
||
{"displayId", displayId},
|
||
{"itemLevel", itemLevel},
|
||
{"stackable", 1},
|
||
};
|
||
doc["items"].push_back(item);
|
||
std::ofstream out(path);
|
||
if (!out) {
|
||
std::fprintf(stderr,
|
||
"add-item: failed to write %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
out << doc.dump(2);
|
||
out.close();
|
||
static const char* qualityNames[] = {
|
||
"poor", "common", "uncommon", "rare", "epic",
|
||
"legendary", "artifact"
|
||
};
|
||
std::printf("Added item '%s' (id=%u, quality=%s, ilvl=%u) to %s (now %zu total)\n",
|
||
name.c_str(), id,
|
||
qualityNames[quality], itemLevel,
|
||
path.c_str(), doc["items"].size());
|
||
return 0;
|
||
} else if (std::strcmp(argv[i], "--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-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-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 {
|
||
std::fprintf(stderr,
|
||
"gen-mesh: shape must be cube, plane, sphere, cylinder, torus, or cone (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-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-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;
|
||
}
|