From 954894460e10e4dcf069b01a03d1ba9d4daf8cfa Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 5 May 2026 09:56:24 -0700 Subject: [PATCH] feat: integrate Wowee Open Terrain loader into client terrain pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wowee client can now load custom zones exported from the editor using the novel WOT/WHM format — no Blizzard files needed. Loading priority in TerrainManager::prepareTile(): 1. Check custom_zones/{mapName}/{mapName}_{x}_{y}.wot/.whm 2. Check output/{mapName}/{mapName}_{x}_{y}.wot/.whm (editor output) 3. Fall back to World\Maps\...\*.adt (standard extracted data) Pipeline: - WoweeTerrainLoader in src/pipeline/ (shared between client + editor) - Loads .whm binary heightmap (WHM1 magic, 256 chunks × 145 floats) - Loads .wot JSON metadata (textures, layers, holes, water) - Populates the same ADTTerrain struct the mesh generator uses - obj0 merge only runs for ADT-loaded tiles (custom zones have no obj0) To use: export zone from editor → files appear in output/ → client loads them automatically on next terrain request for that map name. --- CMakeLists.txt | 2 + include/pipeline/wowee_terrain_loader.hpp | 28 +++ src/pipeline/wowee_terrain_loader.cpp | 210 ++++++++++++++++++++++ src/rendering/terrain_manager.cpp | 57 ++++-- 4 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 include/pipeline/wowee_terrain_loader.hpp create mode 100644 src/pipeline/wowee_terrain_loader.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 217a0864..a64e4945 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -589,6 +589,7 @@ set(WOWEE_SOURCES src/pipeline/wmo_loader.cpp src/pipeline/adt_loader.cpp src/pipeline/wdt_loader.cpp + src/pipeline/wowee_terrain_loader.cpp src/pipeline/dbc_layout.cpp src/pipeline/terrain_mesh.cpp @@ -1313,6 +1314,7 @@ add_executable(wowee_editor src/pipeline/wmo_loader.cpp src/pipeline/adt_loader.cpp src/pipeline/wdt_loader.cpp + src/pipeline/wowee_terrain_loader.cpp src/pipeline/terrain_mesh.cpp # Rendering core diff --git a/include/pipeline/wowee_terrain_loader.hpp b/include/pipeline/wowee_terrain_loader.hpp new file mode 100644 index 00000000..bd07bd8d --- /dev/null +++ b/include/pipeline/wowee_terrain_loader.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "pipeline/adt_loader.hpp" +#include +#include + +namespace wowee { +namespace pipeline { + +// Loader for the Wowee Open Terrain format (.wot/.whm) +// Novel format — no Blizzard structures, fully portable +class WoweeTerrainLoader { +public: + // Load terrain from .whm binary heightmap file + static bool loadHeightmap(const std::string& whmPath, ADTTerrain& terrain); + + // Load terrain metadata from .wot JSON file + static bool loadMetadata(const std::string& wotPath, ADTTerrain& terrain); + + // Full load: .wot metadata + .whm heightmap + static bool load(const std::string& basePath, ADTTerrain& terrain); + + // Check if a wowee open terrain exists at the given base path + static bool exists(const std::string& basePath); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_terrain_loader.cpp b/src/pipeline/wowee_terrain_loader.cpp new file mode 100644 index 00000000..125fcaf2 --- /dev/null +++ b/src/pipeline/wowee_terrain_loader.cpp @@ -0,0 +1,210 @@ +#include "pipeline/wowee_terrain_loader.hpp" +#include "core/logger.hpp" +#include +#include +#include + +namespace wowee { +namespace pipeline { + +static constexpr uint32_t WHM_MAGIC = 0x314D4857; // "WHM1" + +bool WoweeTerrainLoader::exists(const std::string& basePath) { + return std::filesystem::exists(basePath + ".whm") && + std::filesystem::exists(basePath + ".wot"); +} + +bool WoweeTerrainLoader::loadHeightmap(const std::string& whmPath, ADTTerrain& terrain) { + std::ifstream f(whmPath, std::ios::binary); + if (!f) return false; + + uint32_t magic, chunks, verts; + f.read(reinterpret_cast(&magic), 4); + if (magic != WHM_MAGIC) { + LOG_ERROR("Not a WHM file: ", whmPath); + return false; + } + f.read(reinterpret_cast(&chunks), 4); + f.read(reinterpret_cast(&verts), 4); + + if (chunks != 256 || verts != 145) { + LOG_ERROR("WHM unexpected dimensions: ", chunks, " chunks, ", verts, " verts"); + return false; + } + + terrain.loaded = true; + terrain.version = 18; + + for (int ci = 0; ci < 256; ci++) { + auto& chunk = terrain.chunks[ci]; + chunk.heightMap.loaded = true; + chunk.indexX = ci % 16; + chunk.indexY = ci / 16; + chunk.flags = 0; + chunk.holes = 0; + + float base; + f.read(reinterpret_cast(&base), 4); + chunk.position[2] = base; + + f.read(reinterpret_cast(chunk.heightMap.heights.data()), 145 * 4); + + // Default normals (up) + for (int i = 0; i < 145; i++) { + chunk.normals[i * 3 + 0] = 0; + chunk.normals[i * 3 + 1] = 0; + chunk.normals[i * 3 + 2] = 127; + } + } + + LOG_INFO("WHM loaded: ", whmPath, " (256 chunks, 145 verts each)"); + return true; +} + +bool WoweeTerrainLoader::loadMetadata(const std::string& wotPath, ADTTerrain& terrain) { + std::ifstream f(wotPath); + if (!f) return false; + + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + + // Parse tile coordinates + auto findInt = [&](const std::string& key) -> int { + auto pos = content.find("\"" + key + "\""); + if (pos == std::string::npos) return 0; + pos = content.find(':', pos); + return std::stoi(content.substr(pos + 1)); + }; + + terrain.coord.x = findInt("tileX"); + terrain.coord.y = findInt("tileY"); + + // Compute chunk world positions from tile coordinates + float tileSize = 533.33333f; + float chunkSize = tileSize / 16.0f; + for (int cy = 0; cy < 16; cy++) { + for (int cx = 0; cx < 16; cx++) { + auto& chunk = terrain.chunks[cy * 16 + cx]; + chunk.position[0] = (32.0f - terrain.coord.x) * tileSize - cx * chunkSize; + chunk.position[1] = (32.0f - terrain.coord.y) * tileSize - cy * chunkSize; + // position[2] already set by heightmap loader + } + } + + // Parse textures array + auto texStart = content.find("\"textures\""); + if (texStart != std::string::npos) { + size_t pos = texStart; + while ((pos = content.find('"', pos + 1)) != std::string::npos) { + if (content[pos - 1] == '[' || content[pos - 1] == ',') { + auto end = content.find('"', pos + 1); + if (end == std::string::npos) break; + std::string tex = content.substr(pos + 1, end - pos - 1); + if (tex != "textures" && !tex.empty()) + terrain.textures.push_back(tex); + pos = end; + } + auto closeBracket = content.find(']', texStart); + if (pos > closeBracket) break; + } + } + + // Parse chunk layers + auto layersStart = content.find("\"chunkLayers\""); + if (layersStart != std::string::npos) { + size_t pos = layersStart; + int ci = 0; + while (ci < 256 && (pos = content.find('{', pos + 1)) != std::string::npos) { + auto endObj = content.find('}', pos); + if (endObj == std::string::npos) break; + auto layersClose = content.find(']', content.find("\"chunkLayers\"")); + if (pos > layersClose) break; + + std::string block = content.substr(pos, endObj - pos + 1); + + // Parse layers array + auto lStart = block.find("\"layers\":["); + if (lStart != std::string::npos) { + lStart += 10; + auto lEnd = block.find(']', lStart); + std::string layerStr = block.substr(lStart, lEnd - lStart); + // Parse comma-separated integers + size_t lp = 0; + while (lp < layerStr.size()) { + while (lp < layerStr.size() && !std::isdigit(layerStr[lp])) lp++; + if (lp >= layerStr.size()) break; + uint32_t texId = std::stoi(layerStr.substr(lp)); + TextureLayer layer{}; + layer.textureId = texId; + layer.flags = (terrain.chunks[ci].layers.empty()) ? 0 : 0x100; + terrain.chunks[ci].layers.push_back(layer); + while (lp < layerStr.size() && std::isdigit(layerStr[lp])) lp++; + } + } + + // Parse holes + auto holesPos = block.find("\"holes\":"); + if (holesPos != std::string::npos) + terrain.chunks[ci].holes = static_cast(std::stoi(block.substr(holesPos + 8))); + + ci++; + pos = endObj; + } + } + + // Parse water data + auto waterStart = content.find("\"water\""); + if (waterStart != std::string::npos) { + size_t pos = waterStart; + int ci = 0; + while (ci < 256) { + auto nextObj = content.find('{', pos + 1); + auto nextNull = content.find("null", pos + 1); + auto waterClose = content.find(']', waterStart); + + if (nextObj != std::string::npos && nextObj < waterClose && + (nextNull == std::string::npos || nextObj < nextNull)) { + auto endObj = content.find('}', nextObj); + std::string block = content.substr(nextObj, endObj - nextObj + 1); + + auto chunkPos = block.find("\"chunk\":"); + auto typePos = block.find("\"type\":"); + auto heightPos = block.find("\"height\":"); + + if (chunkPos != std::string::npos) { + int wci = std::stoi(block.substr(chunkPos + 8)); + if (wci >= 0 && wci < 256) { + ADTTerrain::WaterLayer wl; + wl.liquidType = (typePos != std::string::npos) ? + static_cast(std::stoi(block.substr(typePos + 7))) : 0; + wl.maxHeight = (heightPos != std::string::npos) ? + std::stof(block.substr(heightPos + 9)) : 0; + wl.minHeight = wl.maxHeight; + wl.x = 0; wl.y = 0; wl.width = 9; wl.height = 9; + wl.heights.assign(81, wl.maxHeight); + wl.mask.assign(8, 0xFF); + terrain.waterData[wci].layers.push_back(wl); + } + } + pos = endObj; + } else { + pos = (nextNull != std::string::npos && nextNull < waterClose) ? nextNull + 4 : waterClose; + } + ci++; + if (pos >= waterClose) break; + } + } + + LOG_INFO("WOT loaded: ", wotPath, " (tile [", terrain.coord.x, ",", terrain.coord.y, + "], ", terrain.textures.size(), " textures)"); + return true; +} + +bool WoweeTerrainLoader::load(const std::string& basePath, ADTTerrain& terrain) { + if (!loadHeightmap(basePath + ".whm", terrain)) return false; + if (!loadMetadata(basePath + ".wot", terrain)) return false; + return true; +} + +} // namespace pipeline +} // namespace wowee diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 6bc3338e..0100d4a5 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -8,6 +8,7 @@ #include "rendering/camera.hpp" #include "audio/ambient_sound_manager.hpp" #include "core/coordinates.hpp" +#include "pipeline/wowee_terrain_loader.hpp" #include "core/memory_monitor.hpp" #include "core/profiler.hpp" #include "pipeline/asset_manager.hpp" @@ -310,28 +311,53 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { // Early-exit check — worker should bail fast during shutdown if (!workerRunning.load()) return nullptr; - // Load ADT file - std::string adtPath = getADTPath(coord); - auto adtData = assetManager->readFile(adtPath); + // Try Wowee Open Terrain format first (custom zones) + std::string wotBase = "custom_zones/" + mapName + "/" + mapName + "_" + + std::to_string(coord.x) + "_" + std::to_string(coord.y); + auto terrainPtr = std::make_unique(); + bool loadedFromWot = false; - if (adtData.empty()) { - logMissingAdtOnce(adtPath); - return nullptr; + if (pipeline::WoweeTerrainLoader::exists(wotBase)) { + if (pipeline::WoweeTerrainLoader::load(wotBase, *terrainPtr)) { + loadedFromWot = true; + LOG_INFO("Loaded custom zone terrain: ", wotBase); + } } - // Parse ADT — allocate on heap to avoid stack overflow on macOS - // (ADTTerrain contains std::array ≈ 280 KB; macOS worker - // threads default to 512 KB stack, so two on-stack copies would overflow) - auto terrainPtr = std::make_unique(pipeline::ADTLoader::load(adtData)); - if (!terrainPtr->isLoaded()) { - LOG_ERROR("Failed to parse ADT terrain: ", adtPath); - return nullptr; + // Also check output directory (editor exports here) + if (!loadedFromWot) { + std::string outputBase = "output/" + mapName + "/" + mapName + "_" + + std::to_string(coord.x) + "_" + std::to_string(coord.y); + if (pipeline::WoweeTerrainLoader::exists(outputBase)) { + if (pipeline::WoweeTerrainLoader::load(outputBase, *terrainPtr)) { + loadedFromWot = true; + LOG_INFO("Loaded editor output terrain: ", outputBase); + } + } + } + + // Fall back to ADT format + if (!loadedFromWot) { + std::string adtPath = getADTPath(coord); + auto adtData = assetManager->readFile(adtPath); + + if (adtData.empty()) { + logMissingAdtOnce(adtPath); + return nullptr; + } + + *terrainPtr = pipeline::ADTLoader::load(adtData); + if (!terrainPtr->isLoaded()) { + LOG_ERROR("Failed to parse ADT terrain: ", adtPath); + return nullptr; + } } if (!workerRunning.load()) return nullptr; // WotLK split ADTs can store placements in *_obj0.adt. - // Merge object chunks so doodads/WMOs (including ground clutter) are available. + // Only needed for ADT-loaded tiles, not for WOT custom zones. + if (!loadedFromWot) { std::string objPath = "World\\Maps\\" + mapName + "\\" + mapName + "_" + std::to_string(coord.x) + "_" + std::to_string(coord.y) + "_obj0.adt"; auto objData = assetManager->readFile(objPath); @@ -386,6 +412,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { } } } + } // end if (!loadedFromWot) obj0 merge // Set tile coordinates so mesh knows where to position this tile in world terrainPtr->coord.x = x; @@ -394,7 +421,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { // Generate mesh pipeline::TerrainMesh mesh = pipeline::TerrainMeshGenerator::generate(*terrainPtr); if (mesh.validChunkCount == 0) { - LOG_ERROR("Failed to generate terrain mesh: ", adtPath); + LOG_ERROR("Failed to generate terrain mesh for tile [", x, ",", y, "]"); return nullptr; }