feat(extract): emit WHM+WOT+WOC for ADT terrain tiles

Final piece of the open-format emit pipeline:
  --emit-terrain  foo.adt → foo.whm + foo.wot + foo.woc

With this, --emit-open now produces a fully open-format zone
alongside every Blizzard MPQ extraction:
  BLP  → PNG       (textures)
  DBC  → JSON      (data tables)
  M2   → WOM       (models, with skin merge)
  WMO  → WOB       (buildings, with group merge)
  ADT  → WHM/WOT   (terrain heights + metadata)
       → WOC       (collision mesh derived from heights)

Originals stay on disk and indexed by manifest.json so private
servers continue to load proprietary formats; wowee runtime/editor
read the open formats directly. One extraction now feeds both
audiences with no separate conversion pass.

Implementation:
- Inline WHM+WOT writer in open_format_emitter.cpp (mirrors the
  editor's WoweeTerrain::exportOpen but without the PNG-preview /
  normal-map deps so the extractor stays editor-independent).
- Tile coords (x,y) parsed from <map>_<x>_<y>.adt filename.
- Collision mesh derived via WoweeCollisionBuilder::fromTerrain
  (terrain triangles only — WMO collision overlays would need
  asset manager and aren't worth the extractor complexity).
This commit is contained in:
Kelsi 2026-05-06 10:36:14 -07:00
parent e6ace7cce5
commit d4c69a2b46
6 changed files with 182 additions and 4 deletions

View file

@ -1180,8 +1180,10 @@ if(STORMLIB_LIBRARY AND STORMLIB_INCLUDE_DIR)
src/pipeline/blp_loader.cpp
src/pipeline/m2_loader.cpp
src/pipeline/wmo_loader.cpp
src/pipeline/adt_loader.cpp
src/pipeline/wowee_model.cpp
src/pipeline/wowee_building.cpp
src/pipeline/wowee_collision.cpp
src/core/logger.cpp
)
target_include_directories(asset_extract PRIVATE

View file

@ -979,11 +979,12 @@ bool Extractor::run(const Options& opts) {
// Open-format emission: walk the extracted tree and write
// wowee-format side-files (PNG / JSON DBC) next to each .blp/.dbc.
// Originals are left untouched so private servers continue to work.
if (opts.emitPng || opts.emitJsonDbc || opts.emitWom || opts.emitWob) {
if (opts.emitPng || opts.emitJsonDbc || opts.emitWom || opts.emitWob ||
opts.emitTerrain) {
std::cout << "Emitting wowee open-format side-files...\n";
OpenFormatStats ofs;
emitOpenFormats(effectiveOutputDir, opts.emitPng, opts.emitJsonDbc,
opts.emitWom, opts.emitWob, ofs);
opts.emitWom, opts.emitWob, opts.emitTerrain, ofs);
if (opts.emitPng) {
std::cout << " PNG (BLP→PNG) : " << ofs.pngOk << " ok";
if (ofs.pngFail) std::cout << ", " << ofs.pngFail << " failed";
@ -1004,6 +1005,11 @@ bool Extractor::run(const Options& opts) {
if (ofs.wobFail) std::cout << ", " << ofs.wobFail << " failed";
std::cout << "\n";
}
if (opts.emitTerrain) {
std::cout << " WHM/WOT/WOC (ADT) : " << ofs.whmOk << " ok";
if (ofs.whmFail) std::cout << ", " << ofs.whmFail << " failed";
std::cout << "\n";
}
}
// Cache WoW.exe for Warden MEM_CHECK responses

View file

@ -36,6 +36,7 @@ public:
bool emitJsonDbc = false; // DBC → JSON side-files
bool emitWom = false; // M2 (+skin) → WOM side-files
bool emitWob = false; // WMO (+groups) → WOB side-files
bool emitTerrain = false; // ADT → WHM + WOT + WOC side-files
};
struct Stats {

View file

@ -28,7 +28,8 @@ static void printUsage(const char* prog) {
<< " --emit-json-dbc Emit foo.json next to every extracted foo.dbc\n"
<< " --emit-wom Emit foo.wom next to every extracted foo.m2 (+skin)\n"
<< " --emit-wob Emit foo.wob next to every extracted foo.wmo (+groups)\n"
<< " --emit-open Shortcut: enable every open-format emitter (png+json+wom+wob)\n"
<< " --emit-terrain Emit foo.whm + foo.wot + foo.woc next to every foo.adt\n"
<< " --emit-open Shortcut: enable every open-format emitter (png+json+wom+wob+terrain)\n"
<< " --verify CRC32 verify all extracted files\n"
<< " --threads <N> Number of extraction threads (default: auto)\n"
<< " --verbose Verbose output\n"
@ -65,12 +66,15 @@ int main(int argc, char** argv) {
opts.emitWom = true;
} else if (std::strcmp(argv[i], "--emit-wob") == 0) {
opts.emitWob = true;
} else if (std::strcmp(argv[i], "--emit-terrain") == 0) {
opts.emitTerrain = true;
} else if (std::strcmp(argv[i], "--emit-open") == 0) {
// Meta-flag: turn on every available open-format emitter.
opts.emitPng = true;
opts.emitJsonDbc = true;
opts.emitWom = true;
opts.emitWob = true;
opts.emitTerrain = true;
} else if (std::strcmp(argv[i], "--dbc-csv-out") == 0 && i + 1 < argc) {
opts.dbcCsvOutputDir = argv[++i];
} else if (std::strcmp(argv[i], "--listfile") == 0 && i + 1 < argc) {

View file

@ -3,8 +3,10 @@
#include "pipeline/dbc_loader.hpp"
#include "pipeline/wowee_model.hpp"
#include "pipeline/wowee_building.hpp"
#include "pipeline/wowee_collision.hpp"
#include "pipeline/m2_loader.hpp"
#include "pipeline/wmo_loader.hpp"
#include "pipeline/adt_loader.hpp"
#include <nlohmann/json.hpp>
@ -115,6 +117,151 @@ bool emitWomFromM2(const std::string& m2Path, const std::string& womBase) {
return pipeline::WoweeModelLoader::save(wom, womBase);
}
// Inline WHM+WOT writer. Mirrors the structure of WoweeTerrain::exportOpen
// in the editor but stripped to the bytes the runtime needs (no PNG
// previews, no normal map). Keeps the asset extractor independent of
// the editor target.
static bool writeWhmWot(const pipeline::ADTTerrain& terrain,
const std::string& outBase, int tileX, int tileY) {
namespace fs = std::filesystem;
fs::create_directories(fs::path(outBase).parent_path());
// .whm — binary heightmap, fixed 256 chunks * 145 floats
{
std::ofstream f(outBase + ".whm", std::ios::binary);
if (!f) return false;
uint32_t magic = 0x314D4857; // "WHM1"
uint32_t chunks = 256, verts = 145;
f.write(reinterpret_cast<const char*>(&magic), 4);
f.write(reinterpret_cast<const char*>(&chunks), 4);
f.write(reinterpret_cast<const char*>(&verts), 4);
for (int ci = 0; ci < 256; ci++) {
const auto& chunk = terrain.chunks[ci];
float base = std::isfinite(chunk.position[2]) ? chunk.position[2] : 0.0f;
f.write(reinterpret_cast<const char*>(&base), 4);
float clean[145];
for (int v = 0; v < 145; v++) {
clean[v] = chunk.heightMap.heights[v];
if (!std::isfinite(clean[v])) clean[v] = 0.0f;
}
f.write(reinterpret_cast<const char*>(clean), 145 * 4);
uint32_t alphaSize = std::min<uint32_t>(
static_cast<uint32_t>(chunk.alphaMap.size()), 65536);
f.write(reinterpret_cast<const char*>(&alphaSize), 4);
if (alphaSize > 0)
f.write(reinterpret_cast<const char*>(chunk.alphaMap.data()), alphaSize);
}
}
// .wot — JSON metadata (textures + chunkLayers + water + placements)
{
nlohmann::json j;
j["format"] = "wot-1.0";
j["editor"] = "asset_extract-1.0.0";
j["tileX"] = tileX;
j["tileY"] = tileY;
j["chunkGrid"] = {16, 16};
j["vertsPerChunk"] = 145;
j["heightmapFile"] = fs::path(outBase + ".whm").filename().string();
nlohmann::json texArr = nlohmann::json::array();
for (const auto& tex : terrain.textures) texArr.push_back(tex);
j["textures"] = texArr;
nlohmann::json chunkArr = nlohmann::json::array();
for (int ci = 0; ci < 256; ci++) {
const auto& chunk = terrain.chunks[ci];
nlohmann::json cl;
nlohmann::json layerIds = nlohmann::json::array();
for (const auto& layer : chunk.layers) layerIds.push_back(layer.textureId);
cl["layers"] = layerIds;
cl["holes"] = chunk.holes;
chunkArr.push_back(cl);
}
j["chunkLayers"] = chunkArr;
nlohmann::json waterArr = nlohmann::json::array();
for (int ci = 0; ci < 256; ci++) {
const auto& w = terrain.waterData[ci];
if (w.hasWater()) {
float h = std::isfinite(w.layers[0].maxHeight) ? w.layers[0].maxHeight : 0.0f;
waterArr.push_back({{"chunk", ci},
{"type", w.layers[0].liquidType},
{"height", h}});
} else {
waterArr.push_back(nullptr);
}
}
j["water"] = waterArr;
auto san = [](float x) { return std::isfinite(x) ? x : 0.0f; };
nlohmann::json doodadNames = nlohmann::json::array();
for (const auto& n : terrain.doodadNames) doodadNames.push_back(n);
j["doodadNames"] = doodadNames;
nlohmann::json doodads = nlohmann::json::array();
for (const auto& dp : terrain.doodadPlacements) {
doodads.push_back({
{"nameId", dp.nameId}, {"uniqueId", dp.uniqueId},
{"pos", {san(dp.position[0]), san(dp.position[1]), san(dp.position[2])}},
{"rot", {san(dp.rotation[0]), san(dp.rotation[1]), san(dp.rotation[2])}},
{"scale", dp.scale}, {"flags", dp.flags}
});
}
j["doodads"] = doodads;
nlohmann::json wmoNames = nlohmann::json::array();
for (const auto& n : terrain.wmoNames) wmoNames.push_back(n);
j["wmoNames"] = wmoNames;
nlohmann::json wmos = nlohmann::json::array();
for (const auto& wp : terrain.wmoPlacements) {
wmos.push_back({
{"nameId", wp.nameId}, {"uniqueId", wp.uniqueId},
{"pos", {san(wp.position[0]), san(wp.position[1]), san(wp.position[2])}},
{"rot", {san(wp.rotation[0]), san(wp.rotation[1]), san(wp.rotation[2])}},
{"flags", wp.flags}, {"doodadSet", wp.doodadSet}
});
}
j["wmos"] = wmos;
std::ofstream f(outBase + ".wot");
if (!f) return false;
f << j.dump(2) << "\n";
}
return true;
}
bool emitTerrainFromAdt(const std::string& adtPath, const std::string& outBase) {
auto bytes = readBytes(adtPath);
if (bytes.empty()) return false;
auto terrain = pipeline::ADTLoader::load(bytes);
if (!terrain.loaded) return false;
// Parse "<map>_<x>_<y>.adt" tile coords from the filename so the WOT
// can record them; fall back to (32,32) if the layout is unexpected.
int tileX = 32, tileY = 32;
{
std::string stem = fs::path(adtPath).stem().string();
auto last = stem.rfind('_');
auto prev = (last != std::string::npos) ? stem.rfind('_', last - 1) : std::string::npos;
if (last != std::string::npos && prev != std::string::npos) {
try {
tileX = std::stoi(stem.substr(prev + 1, last - prev - 1));
tileY = std::stoi(stem.substr(last + 1));
} catch (...) {}
}
}
terrain.coord.x = tileX;
terrain.coord.y = tileY;
if (!writeWhmWot(terrain, outBase, tileX, tileY)) return false;
// Also build a terrain-only WOC (collision mesh) so the runtime can
// do walkability queries without re-deriving from the heightmap.
auto col = pipeline::WoweeCollisionBuilder::fromTerrain(terrain);
pipeline::WoweeCollisionBuilder::save(col, outBase + ".woc");
return true;
}
bool emitWobFromWmo(const std::string& wmoPath, const std::string& wobBase) {
auto rootBytes = readBytes(wmoPath);
if (rootBytes.empty()) return false;
@ -138,9 +285,10 @@ bool emitWobFromWmo(const std::string& wmoPath, const std::string& wobBase) {
void emitOpenFormats(const std::string& rootDir,
bool emitPng, bool emitJsonDbc,
bool emitWom, bool emitWob,
bool emitTerrain,
OpenFormatStats& stats) {
if (!fs::exists(rootDir)) return;
if (!emitPng && !emitJsonDbc && !emitWom && !emitWob) return;
if (!emitPng && !emitJsonDbc && !emitWom && !emitWob && !emitTerrain) return;
auto lower = [](std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
@ -181,6 +329,14 @@ void emitOpenFormats(const std::string& rootDir,
if (emitWobFromWmo(entry.path().string(), base)) stats.wobOk++;
else stats.wobFail++;
}
} else if (emitTerrain && ext == ".adt") {
if (emitTerrainFromAdt(entry.path().string(), base)) {
stats.whmOk++;
stats.wocOk++;
} else {
stats.whmFail++;
stats.wocFail++;
}
}
}
}

View file

@ -20,6 +20,8 @@ struct OpenFormatStats {
uint32_t jsonDbcOk = 0, jsonDbcFail = 0;
uint32_t womOk = 0, womFail = 0;
uint32_t wobOk = 0, wobFail = 0;
uint32_t whmOk = 0, whmFail = 0;
uint32_t wocOk = 0, wocFail = 0;
};
// Convert one BLP file on disk to a PNG side-file.
@ -40,11 +42,18 @@ bool emitWomFromM2(const std::string& m2Path, const std::string& womBase);
// matching <base>_NNN.wmo group files if present.
bool emitWobFromWmo(const std::string& wmoPath, const std::string& wobBase);
// Convert one ADT file on disk to a WHM (binary heightmap) + WOT (JSON
// metadata) pair, plus a WOC (collision mesh) for movement queries.
// Returns true if all three were written. tileX/tileY are parsed from
// the filename (e.g. "MapName_32_48.adt").
bool emitTerrainFromAdt(const std::string& adtPath, const std::string& outBase);
// Walk an extracted-asset directory and emit open-format side-files for
// every requested format. Counts accumulated into stats.
void emitOpenFormats(const std::string& rootDir,
bool emitPng, bool emitJsonDbc,
bool emitWom, bool emitWob,
bool emitTerrain,
OpenFormatStats& stats);
} // namespace tools