Kelsidavis-WoWee/tools/editor/main.cpp
Kelsi b6ce6b4fe9 feat(editor): add --info-m2 and --info-wmo proprietary inspectors
Round out the format-inspector lineup. The wowee open formats had
inspectors (--info-wom, --info-wob); these are the proprietary
counterparts that pair with --convert-m2 / --convert-wmo so users
can verify what the conversion preserves vs drops:

  wowee_editor --info-m2  Character/Human/Male/HumanMale.m2
  wowee_editor --info-wmo World/wmo/Stormwind/Stormwind.wmo

--info-m2 reports verts/tris, bones, sequences (animations),
batches, textures, materials, attachments, particles, ribbons,
collision tris, and bound radius. Auto-merges <base>00.skin if
present (WotLK+ M2s store geometry there) so vertex/index counts
match what gets rendered.

--info-wmo reports group count + portals + lights + doodads +
materials + textures + total verts/tris across loaded groups.
Auto-merges matching <base>_NNN.wmo group files; pre-resizes the
groups vector so loadGroup populates the right slots.

Verified against real WoW assets:
  nexusraid_skya.m2: v264, 20917 verts, 22940 tris, 44 bones,
    1 sequence, 44 batches, 28 textures, 42 materials.
  ed_zd_ziggurat.wmo: v17, 1 group (1 loaded), 8 materials, 7
    textures, 4609 verts, 3650 tris from the group file.

Bug caught during testing: initial snprintf used an 8-byte buffer
for '_NNN.wmo' (which is 8 chars + NUL = 9), silently truncating
to '_000.wm' and failing every group lookup. Bumped to 16 bytes
with a comment so the trap doesn't get re-stepped.
2026-05-06 12:51:40 -07:00

4909 lines
245 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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/asset_manager.hpp"
#include "pipeline/custom_zone_discovery.hpp"
#include "core/logger.hpp"
#include <string>
#include <cstdio>
#include <cstring>
#include <unordered_map>
#include <algorithm>
#include <nlohmann/json.hpp>
#include "stb_image_write.h"
// ─── 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.
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-wmo <path> Convert WMO building to WOB open format (no GUI)\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(" --list-zones [--json] List discovered custom zones and exit\n");
std::printf(" --scaffold-zone <name> [tx ty] Create a blank zone in custom_zones/<name>/ and exit\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(" --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(" --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(" --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(" --zone-summary <zoneDir> [--json]\n");
std::printf(" One-shot validate + creature/object/quest counts and exit\n");
std::printf(" --info <wom-base> [--json]\n");
std::printf(" Print WOM file metadata (version, counts) and exit\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-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-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-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(" --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-wcp <wcp-path> [--json]\n");
std::printf(" Print WCP archive metadata (name, files) and exit\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(" --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(" --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-wob", "--info-woc", "--info-wot",
"--info-creatures", "--info-objects", "--info-quests",
"--info-extract", "--list-missing-sidecars",
"--info-png", "--info-jsondbc", "--info-blp",
"--info-m2", "--info-wmo",
"--info-zone", "--info-wcp", "--list-wcp",
"--list-creatures", "--list-objects", "--list-quests",
"--list-quest-objectives", "--list-quest-rewards",
"--unpack-wcp", "--pack-wcp",
"--validate", "--validate-wom", "--validate-wob", "--validate-woc",
"--validate-whm", "--validate-all", "--zone-summary",
"--scaffold-zone", "--add-tile", "--remove-tile", "--list-tiles",
"--add-creature", "--add-object", "--add-quest",
"--add-quest-objective", "--add-quest-reward-item", "--set-quest-reward",
"--remove-quest-objective",
"--remove-creature", "--remove-object", "--remove-quest",
"--copy-zone", "--rename-zone",
"--build-woc", "--regen-collision", "--fix-zone",
"--export-png", "--export-obj", "--import-obj",
"--export-wob-obj", "--import-wob-obj",
"--export-woc-obj", "--export-whm-obj",
"--convert-m2", "--convert-wmo",
"--convert-dbc-json", "--convert-json-dbc", "--convert-blp-png",
};
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-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], "--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-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], "--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-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], "--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], "--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], "--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-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], "--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], "--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-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], "--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], "--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], "--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], "--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], "--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], "--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 (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;
}