feat(extract): emit WOM and WOB side-files (M2/WMO → open formats)

Extends asset_extract with two more open-format emitters:
  --emit-wom  foo.m2 (+ foo00.skin) → foo.wom
  --emit-wob  foo.wmo (+ foo_NNN.wmo groups) → foo.wob
  --emit-open now also turns these on

Originals are preserved so private servers still load .m2/.wmo
through the manifest path; the wowee runtime/editor pick up the
.wom/.wob next to them via the existing open-format search rules.

Implementation:
- New WoweeModelLoader::fromM2Bytes(m2Data, skinData) shares the
  conversion body with fromM2(path, am) via a static helper
  (convertM2ToWom). Lets the extractor convert without standing
  up an AssetManager.
- fromM2(path, am) moved to a separate translation unit
  (wowee_model_fromm2.cpp) so asset_extract doesn't have to
  link the AssetManager dependency.
- WoweeBuildingLoader::fromWMO already takes a WMOModel directly,
  so emitWobFromWmo just needs to read root + group files and
  call save().
- Group sub-files (<base>_NNN.wmo) are skipped during the walk
  since they're merged into the root WMO.
This commit is contained in:
Kelsi 2026-05-06 10:32:17 -07:00
parent 5ed2008621
commit e6ace7cce5
9 changed files with 167 additions and 26 deletions

View file

@ -591,6 +591,7 @@ set(WOWEE_SOURCES
src/pipeline/wdt_loader.cpp
src/pipeline/wowee_terrain_loader.cpp
src/pipeline/wowee_model.cpp
src/pipeline/wowee_model_fromm2.cpp
src/pipeline/wowee_building.cpp
src/pipeline/wowee_collision.cpp
src/pipeline/custom_zone_discovery.cpp
@ -1177,6 +1178,10 @@ if(STORMLIB_LIBRARY AND STORMLIB_INCLUDE_DIR)
tools/asset_extract/open_format_emitter.cpp
src/pipeline/dbc_loader.cpp
src/pipeline/blp_loader.cpp
src/pipeline/m2_loader.cpp
src/pipeline/wmo_loader.cpp
src/pipeline/wowee_model.cpp
src/pipeline/wowee_building.cpp
src/core/logger.cpp
)
target_include_directories(asset_extract PRIVATE
@ -1330,6 +1335,7 @@ add_executable(wowee_editor
src/pipeline/wdt_loader.cpp
src/pipeline/wowee_terrain_loader.cpp
src/pipeline/wowee_model.cpp
src/pipeline/wowee_model_fromm2.cpp
src/pipeline/wowee_building.cpp
src/pipeline/wowee_collision.cpp
src/pipeline/custom_zone_discovery.cpp

View file

@ -81,6 +81,12 @@ public:
// Convert an M2 model to WoweeModel (static geometry only, no animation)
static WoweeModel fromM2(const std::string& m2Path, class AssetManager* am);
// Same as fromM2() but takes parsed M2 bytes + optional skin bytes
// directly. Lets the asset extractor convert during MPQ→loose-files
// without standing up an AssetManager.
static WoweeModel fromM2Bytes(const std::vector<uint8_t>& m2Data,
const std::vector<uint8_t>& skinData = {});
// Check if a .wom exists
static bool exists(const std::string& basePath);

View file

@ -438,30 +438,28 @@ bool WoweeModelLoader::save(const WoweeModel& model, const std::string& basePath
return true;
}
WoweeModel WoweeModelLoader::fromM2(const std::string& m2Path, AssetManager* am) {
// Internal helper: convert a parsed M2Model (already merged with skin if
// applicable) into a WoweeModel. Shared by fromM2 (AssetManager path)
// and fromM2Bytes (extractor path).
static WoweeModel convertM2ToWom(const M2Model& m2);
// fromM2(path, am) lives in wowee_model_fromm2.cpp so the asset extractor
// can link wowee_model.cpp without pulling in the AssetManager dependency.
// convertM2ToWom() (defined below) is the shared conversion body.
WoweeModel convertM2ToWomShared(const M2Model& m2) { return convertM2ToWom(m2); }
WoweeModel WoweeModelLoader::fromM2Bytes(const std::vector<uint8_t>& m2Data,
const std::vector<uint8_t>& skinData) {
WoweeModel model;
if (!am) return model;
auto data = am->readFile(m2Path);
if (data.empty()) return model;
auto m2 = M2Loader::load(data);
// WotLK+ M2s store header in .m2 but geometry in .skin — always merge the
// skin file when present so we get vertices/indices/batches even for M2s
// that already report isValid() (older expansions).
{
std::string skinPath = m2Path;
auto dotPos = skinPath.rfind('.');
if (dotPos != std::string::npos)
skinPath = skinPath.substr(0, dotPos) + "00.skin";
auto skinData = am->readFile(skinPath);
if (!skinData.empty())
M2Loader::loadSkin(skinData, m2);
}
if (m2Data.empty()) return model;
auto m2 = M2Loader::load(m2Data);
if (!skinData.empty()) M2Loader::loadSkin(skinData, m2);
if (!m2.isValid()) return model;
return convertM2ToWom(m2);
}
static WoweeModel convertM2ToWom(const M2Model& m2) {
WoweeModel model;
model.name = m2.name;
model.boundRadius = m2.boundRadius;

View file

@ -0,0 +1,42 @@
// AssetManager-bound implementation of WoweeModelLoader::fromM2(path, am).
// Split out from wowee_model.cpp so the asset extractor can link the core
// model loader/saver without dragging the AssetManager dependency in.
#include "pipeline/wowee_model.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/m2_loader.hpp"
namespace wowee {
namespace pipeline {
// Friend bridge to the conversion helper defined in wowee_model.cpp.
WoweeModel convertM2ToWomShared(const M2Model& m2);
WoweeModel WoweeModelLoader::fromM2(const std::string& m2Path, AssetManager* am) {
WoweeModel model;
if (!am) return model;
auto data = am->readFile(m2Path);
if (data.empty()) return model;
auto m2 = M2Loader::load(data);
// WotLK+ M2s store header in .m2 but geometry in .skin — always merge
// the skin file when present so we get vertices/indices/batches even
// for M2s that already report isValid() (older expansions).
{
std::string skinPath = m2Path;
auto dotPos = skinPath.rfind('.');
if (dotPos != std::string::npos)
skinPath = skinPath.substr(0, dotPos) + "00.skin";
auto skinData = am->readFile(skinPath);
if (!skinData.empty())
M2Loader::loadSkin(skinData, m2);
}
if (!m2.isValid()) return model;
return convertM2ToWomShared(m2);
}
} // namespace pipeline
} // namespace wowee

View file

@ -979,10 +979,11 @@ 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) {
if (opts.emitPng || opts.emitJsonDbc || opts.emitWom || opts.emitWob) {
std::cout << "Emitting wowee open-format side-files...\n";
OpenFormatStats ofs;
emitOpenFormats(effectiveOutputDir, opts.emitPng, opts.emitJsonDbc, ofs);
emitOpenFormats(effectiveOutputDir, opts.emitPng, opts.emitJsonDbc,
opts.emitWom, opts.emitWob, ofs);
if (opts.emitPng) {
std::cout << " PNG (BLP→PNG) : " << ofs.pngOk << " ok";
if (ofs.pngFail) std::cout << ", " << ofs.pngFail << " failed";
@ -993,6 +994,16 @@ bool Extractor::run(const Options& opts) {
if (ofs.jsonDbcFail) std::cout << ", " << ofs.jsonDbcFail << " failed";
std::cout << "\n";
}
if (opts.emitWom) {
std::cout << " WOM (M2→WOM) : " << ofs.womOk << " ok";
if (ofs.womFail) std::cout << ", " << ofs.womFail << " failed";
std::cout << "\n";
}
if (opts.emitWob) {
std::cout << " WOB (WMO→WOB) : " << ofs.wobOk << " ok";
if (ofs.wobFail) std::cout << ", " << ofs.wobFail << " failed";
std::cout << "\n";
}
}
// Cache WoW.exe for Warden MEM_CHECK responses

View file

@ -34,6 +34,8 @@ public:
// private servers (AzerothCore/TrinityCore) read from.
bool emitPng = false; // BLP → PNG side-files
bool emitJsonDbc = false; // DBC → JSON side-files
bool emitWom = false; // M2 (+skin) → WOM side-files
bool emitWob = false; // WMO (+groups) → WOB side-files
};
struct Stats {

View file

@ -26,7 +26,9 @@ static void printUsage(const char* prog) {
<< " --dbc-csv-out <dir> Write CSV DBCs into <dir> (overrides default output path)\n"
<< " --emit-png Emit foo.png next to every extracted foo.blp\n"
<< " --emit-json-dbc Emit foo.json next to every extracted foo.dbc\n"
<< " --emit-open Shortcut: enable every open-format emitter (png+json)\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"
<< " --verify CRC32 verify all extracted files\n"
<< " --threads <N> Number of extraction threads (default: auto)\n"
<< " --verbose Verbose output\n"
@ -59,10 +61,16 @@ int main(int argc, char** argv) {
opts.emitPng = true;
} else if (std::strcmp(argv[i], "--emit-json-dbc") == 0) {
opts.emitJsonDbc = true;
} else if (std::strcmp(argv[i], "--emit-wom") == 0) {
opts.emitWom = true;
} else if (std::strcmp(argv[i], "--emit-wob") == 0) {
opts.emitWob = 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;
} 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

@ -1,6 +1,10 @@
#include "open_format_emitter.hpp"
#include "pipeline/blp_loader.hpp"
#include "pipeline/dbc_loader.hpp"
#include "pipeline/wowee_model.hpp"
#include "pipeline/wowee_building.hpp"
#include "pipeline/m2_loader.hpp"
#include "pipeline/wmo_loader.hpp"
#include <nlohmann/json.hpp>
@ -93,11 +97,50 @@ bool emitJsonFromDbc(const std::string& dbcPath, const std::string& jsonPath) {
return true;
}
bool emitWomFromM2(const std::string& m2Path, const std::string& womBase) {
auto m2Bytes = readBytes(m2Path);
if (m2Bytes.empty()) return false;
// WotLK+ M2s store the actual geometry in <base>00.skin; merge it if
// it sits next to the .m2 (usual case after extraction).
std::vector<uint8_t> skinBytes;
{
std::string skinPath = m2Path;
auto dot = skinPath.rfind('.');
if (dot != std::string::npos)
skinPath = skinPath.substr(0, dot) + "00.skin";
skinBytes = readBytes(skinPath);
}
auto wom = pipeline::WoweeModelLoader::fromM2Bytes(m2Bytes, skinBytes);
if (!wom.isValid()) return false;
return pipeline::WoweeModelLoader::save(wom, womBase);
}
bool emitWobFromWmo(const std::string& wmoPath, const std::string& wobBase) {
auto rootBytes = readBytes(wmoPath);
if (rootBytes.empty()) return false;
auto wmo = pipeline::WMOLoader::load(rootBytes);
if (wmo.nGroups == 0) return false;
// Merge group files <base>_NNN.wmo for groups that have them.
std::string base = wmoPath;
if (base.size() > 4) base = base.substr(0, base.size() - 4);
for (uint32_t gi = 0; gi < wmo.nGroups; ++gi) {
char suffix[16];
std::snprintf(suffix, sizeof(suffix), "_%03u.wmo", gi);
auto gd = readBytes(base + suffix);
if (!gd.empty()) pipeline::WMOLoader::loadGroup(gd, wmo, gi);
}
auto bld = pipeline::WoweeBuildingLoader::fromWMO(
wmo, fs::path(wmoPath).stem().string());
if (!bld.isValid()) return false;
return pipeline::WoweeBuildingLoader::save(bld, wobBase);
}
void emitOpenFormats(const std::string& rootDir,
bool emitPng, bool emitJsonDbc,
bool emitWom, bool emitWob,
OpenFormatStats& stats) {
if (!fs::exists(rootDir)) return;
if (!emitPng && !emitJsonDbc) return;
if (!emitPng && !emitJsonDbc && !emitWom && !emitWob) return;
auto lower = [](std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
@ -124,6 +167,20 @@ void emitOpenFormats(const std::string& rootDir,
} else {
stats.jsonDbcFail++;
}
} else if (emitWom && ext == ".m2") {
if (emitWomFromM2(entry.path().string(), base)) stats.womOk++;
else stats.womFail++;
} else if (emitWob && ext == ".wmo") {
// Skip group sub-files (<base>_NNN.wmo) — those get merged
// into the root WMO during conversion.
std::string fname = entry.path().filename().string();
auto under = fname.rfind('_');
bool isGroup = (under != std::string::npos &&
fname.size() - under == 8); // "_NNN.wmo" suffix
if (!isGroup) {
if (emitWobFromWmo(entry.path().string(), base)) stats.wobOk++;
else stats.wobFail++;
}
}
}
}

View file

@ -18,6 +18,8 @@ namespace tools {
struct OpenFormatStats {
uint32_t pngOk = 0, pngFail = 0;
uint32_t jsonDbcOk = 0, jsonDbcFail = 0;
uint32_t womOk = 0, womFail = 0;
uint32_t wobOk = 0, wobFail = 0;
};
// Convert one BLP file on disk to a PNG side-file.
@ -30,10 +32,19 @@ bool emitPngFromBlp(const std::string& blpPath, const std::string& pngPath);
// the output drops into custom_zones/<zone>/data/ directly.
bool emitJsonFromDbc(const std::string& dbcPath, const std::string& jsonPath);
// Convert one M2 file on disk to a WOM side-file. Auto-locates and merges
// the matching <base>00.skin if present (WotLK+ models store geometry there).
bool emitWomFromM2(const std::string& m2Path, const std::string& womBase);
// Convert one WMO file on disk to a WOB side-file. Auto-locates and merges
// matching <base>_NNN.wmo group files if present.
bool emitWobFromWmo(const std::string& wmoPath, const std::string& wobBase);
// Walk an extracted-asset directory and emit open-format side-files for
// every BLP and/or DBC found. Counts are accumulated into stats.
// every requested format. Counts accumulated into stats.
void emitOpenFormats(const std::string& rootDir,
bool emitPng, bool emitJsonDbc,
bool emitWom, bool emitWob,
OpenFormatStats& stats);
} // namespace tools