diff --git a/CMakeLists.txt b/CMakeLists.txt index df438a4a..72ee8156 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -592,6 +592,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_terrain_loader.cpp src/pipeline/wowee_model.cpp src/pipeline/wowee_building.cpp + src/pipeline/wowee_collision.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1323,6 +1324,7 @@ add_executable(wowee_editor src/pipeline/wowee_terrain_loader.cpp src/pipeline/wowee_model.cpp src/pipeline/wowee_building.cpp + src/pipeline/wowee_collision.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_collision.hpp b/include/pipeline/wowee_collision.hpp new file mode 100644 index 00000000..9d047b27 --- /dev/null +++ b/include/pipeline/wowee_collision.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { + +struct ADTTerrain; + +// Wowee Open Collision format (.woc) — walkability mesh for custom zones +struct WoweeCollision { + struct Triangle { + glm::vec3 v0, v1, v2; + uint8_t flags; // 0x01=walkable, 0x02=water, 0x04=steep, 0x08=indoor + }; + + struct BoundingBox { + glm::vec3 min{1e30f}, max{-1e30f}; + void expand(const glm::vec3& p) { + min = glm::min(min, p); + max = glm::max(max, p); + } + }; + + std::vector triangles; + BoundingBox bounds; + uint32_t tileX = 0, tileY = 0; + + bool isValid() const { return !triangles.empty(); } + size_t walkableCount() const; + size_t steepCount() const; +}; + +class WoweeCollisionBuilder { +public: + // Generate collision mesh from terrain heightmap + static WoweeCollision fromTerrain(const ADTTerrain& terrain, + float steepAngle = 50.0f); + + // Save collision mesh to binary file + static bool save(const WoweeCollision& collision, const std::string& path); + + // Load collision mesh from binary file + static WoweeCollision load(const std::string& path); + + // Check if a collision file exists + static bool exists(const std::string& basePath); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_collision.cpp b/src/pipeline/wowee_collision.cpp new file mode 100644 index 00000000..6060139a --- /dev/null +++ b/src/pipeline/wowee_collision.cpp @@ -0,0 +1,176 @@ +#include "pipeline/wowee_collision.hpp" +#include "pipeline/adt_loader.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { + +static constexpr uint32_t WOC_MAGIC = 0x31434F57; // "WOC1" + +size_t WoweeCollision::walkableCount() const { + size_t n = 0; + for (const auto& t : triangles) + if (t.flags & 0x01) n++; + return n; +} + +size_t WoweeCollision::steepCount() const { + size_t n = 0; + for (const auto& t : triangles) + if (t.flags & 0x04) n++; + return n; +} + +WoweeCollision WoweeCollisionBuilder::fromTerrain(const ADTTerrain& terrain, + float steepAngle) { + WoweeCollision col; + col.tileX = terrain.coord.x; + col.tileY = terrain.coord.y; + + float steepCos = std::cos(steepAngle * 3.14159265f / 180.0f); + + float tileSize = 533.33333f; + float chunkSize = tileSize / 16.0f; + float vertSpacing = chunkSize / 8.0f; + + for (int ci = 0; ci < 256; ci++) { + const auto& chunk = terrain.chunks[ci]; + if (!chunk.hasHeightMap()) continue; + + int cx = ci % 16; + int cy = ci / 16; + float chunkBaseX = (32.0f - terrain.coord.y) * tileSize - cy * chunkSize; + float chunkBaseY = (32.0f - terrain.coord.x) * tileSize - cx * chunkSize; + + bool isHoleChunk = (chunk.holes != 0); + + // Build outer vertex grid (9x9) + for (int row = 0; row < 8; row++) { + for (int col2 = 0; col2 < 8; col2++) { + // Check hole mask (4x4 grid, each bit covers 2x2 sub-quads) + if (isHoleChunk) { + int hx = col2 / 2, hy = row / 2; + if (chunk.holes & (1 << (hy * 4 + hx))) continue; + } + + int i00 = row * 17 + col2; + int i10 = row * 17 + col2 + 1; + int i01 = (row + 1) * 17 + col2; + int i11 = (row + 1) * 17 + col2 + 1; + + auto vtx = [&](int idx) -> glm::vec3 { + int r = idx / 17, c = idx % 17; + float x = chunkBaseX - r * vertSpacing; + float y = chunkBaseY - c * vertSpacing; + float z = chunk.position[2] + chunk.heightMap.heights[idx]; + return glm::vec3(x, y, z); + }; + + glm::vec3 v00 = vtx(i00), v10 = vtx(i10); + glm::vec3 v01 = vtx(i01), v11 = vtx(i11); + + auto classifyTri = [&](const glm::vec3& a, const glm::vec3& b, const glm::vec3& c) { + WoweeCollision::Triangle tri; + tri.v0 = a; tri.v1 = b; tri.v2 = c; + + glm::vec3 normal = glm::normalize(glm::cross(b - a, c - a)); + float nz = std::abs(normal.z); + + tri.flags = 0; + if (nz >= steepCos) + tri.flags |= 0x01; // walkable + else + tri.flags |= 0x04; // steep + + col.bounds.expand(a); + col.bounds.expand(b); + col.bounds.expand(c); + col.triangles.push_back(tri); + }; + + classifyTri(v00, v10, v01); + classifyTri(v10, v11, v01); + } + } + + // Mark water triangles + if (terrain.waterData[ci].hasWater()) { + float waterH = terrain.waterData[ci].layers[0].maxHeight; + for (size_t ti = col.triangles.size() - 128; ti < col.triangles.size(); ti++) { + auto& tri = col.triangles[ti]; + float avgZ = (tri.v0.z + tri.v1.z + tri.v2.z) / 3.0f; + if (avgZ < waterH) tri.flags |= 0x02; + } + } + } + + LOG_INFO("Collision mesh: ", col.triangles.size(), " triangles (", + col.walkableCount(), " walkable, ", col.steepCount(), " steep)"); + return col; +} + +bool WoweeCollisionBuilder::save(const WoweeCollision& collision, const std::string& path) { + namespace fs = std::filesystem; + fs::create_directories(fs::path(path).parent_path()); + + std::ofstream f(path, std::ios::binary); + if (!f) return false; + + f.write(reinterpret_cast(&WOC_MAGIC), 4); + uint32_t triCount = static_cast(collision.triangles.size()); + f.write(reinterpret_cast(&triCount), 4); + f.write(reinterpret_cast(&collision.tileX), 4); + f.write(reinterpret_cast(&collision.tileY), 4); + f.write(reinterpret_cast(&collision.bounds.min), 12); + f.write(reinterpret_cast(&collision.bounds.max), 12); + + for (const auto& tri : collision.triangles) { + f.write(reinterpret_cast(&tri.v0), 12); + f.write(reinterpret_cast(&tri.v1), 12); + f.write(reinterpret_cast(&tri.v2), 12); + f.write(reinterpret_cast(&tri.flags), 1); + } + + LOG_INFO("WOC saved: ", path, " (", triCount, " triangles)"); + return true; +} + +WoweeCollision WoweeCollisionBuilder::load(const std::string& path) { + WoweeCollision col; + std::ifstream f(path, std::ios::binary); + if (!f) return col; + + uint32_t magic; + f.read(reinterpret_cast(&magic), 4); + if (magic != WOC_MAGIC) return col; + + uint32_t triCount; + f.read(reinterpret_cast(&triCount), 4); + f.read(reinterpret_cast(&col.tileX), 4); + f.read(reinterpret_cast(&col.tileY), 4); + f.read(reinterpret_cast(&col.bounds.min), 12); + f.read(reinterpret_cast(&col.bounds.max), 12); + + col.triangles.resize(triCount); + for (uint32_t i = 0; i < triCount; i++) { + auto& tri = col.triangles[i]; + f.read(reinterpret_cast(&tri.v0), 12); + f.read(reinterpret_cast(&tri.v1), 12); + f.read(reinterpret_cast(&tri.v2), 12); + f.read(reinterpret_cast(&tri.flags), 1); + } + + LOG_INFO("WOC loaded: ", path, " (", triCount, " triangles)"); + return col; +} + +bool WoweeCollisionBuilder::exists(const std::string& basePath) { + return std::filesystem::exists(basePath + ".woc"); +} + +} // namespace pipeline +} // namespace wowee diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cd13c5fd..7e53b4f1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -403,6 +403,7 @@ register_test_target(test_gm_commands) add_executable(test_open_formats test_open_formats.cpp ${CMAKE_SOURCE_DIR}/src/pipeline/wowee_building.cpp + ${CMAKE_SOURCE_DIR}/src/pipeline/wowee_collision.cpp ${CMAKE_SOURCE_DIR}/src/pipeline/wowee_terrain_loader.cpp ${CMAKE_SOURCE_DIR}/src/pipeline/wmo_loader.cpp ${CMAKE_SOURCE_DIR}/src/core/logger.cpp diff --git a/tests/test_open_formats.cpp b/tests/test_open_formats.cpp index 91bcece1..dd724942 100644 --- a/tests/test_open_formats.cpp +++ b/tests/test_open_formats.cpp @@ -1,6 +1,7 @@ // Tests for Wowee open format round-trips (WOM, WOB, WHM/WOT) #include #include "pipeline/wowee_building.hpp" +#include "pipeline/wowee_collision.hpp" #include "pipeline/wmo_loader.hpp" #include "pipeline/wowee_terrain_loader.hpp" #include "pipeline/adt_loader.hpp" @@ -262,6 +263,83 @@ TEST_CASE("WOT metadata round-trip with placements", "[wot]") { std::filesystem::remove(wotPath); } +// ============== WOC Tests ============== + +TEST_CASE("WOC collision from flat terrain", "[woc]") { + ADTTerrain terrain{}; + terrain.loaded = true; + terrain.coord = {32, 48}; + 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.position[2] = 100.0f; + chunk.holes = 0; + for (int v = 0; v < 145; v++) + chunk.heightMap.heights[v] = 0.0f; + } + + auto col = WoweeCollisionBuilder::fromTerrain(terrain); + REQUIRE(col.isValid()); + REQUIRE(col.triangles.size() == 256 * 64 * 2); // 8x8 quads * 2 tris * 256 chunks + REQUIRE(col.walkableCount() == col.triangles.size()); // flat = all walkable + REQUIRE(col.steepCount() == 0); + REQUIRE(col.tileX == 32); + REQUIRE(col.tileY == 48); +} + +TEST_CASE("WOC save and load round-trip", "[woc]") { + ensureTestDir(); + + WoweeCollision col; + col.tileX = 10; col.tileY = 20; + WoweeCollision::Triangle tri; + tri.v0 = {0,0,0}; tri.v1 = {1,0,0}; tri.v2 = {0,1,0}; tri.flags = 0x01; + col.triangles.push_back(tri); + tri.v0 = {5,5,10}; tri.v1 = {6,5,10}; tri.v2 = {5,6,15}; tri.flags = 0x04; + col.triangles.push_back(tri); + col.bounds.expand({0,0,0}); col.bounds.expand({6,6,15}); + + std::string path = TEST_DIR + "/test_collision.woc"; + REQUIRE(WoweeCollisionBuilder::save(col, path)); + + auto loaded = WoweeCollisionBuilder::load(path); + REQUIRE(loaded.isValid()); + REQUIRE(loaded.triangles.size() == 2); + REQUIRE(loaded.tileX == 10); + REQUIRE(loaded.tileY == 20); + REQUIRE(loaded.triangles[0].flags == 0x01); + REQUIRE(loaded.triangles[1].flags == 0x04); + REQUIRE(loaded.triangles[0].v0.x == Catch::Approx(0.0f)); + REQUIRE(loaded.triangles[1].v2.z == Catch::Approx(15.0f)); + REQUIRE(loaded.walkableCount() == 1); + REQUIRE(loaded.steepCount() == 1); +} + +TEST_CASE("WOC holes skip triangles", "[woc]") { + ADTTerrain terrain{}; + terrain.loaded = true; + terrain.coord = {32, 32}; + 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.position[2] = 100.0f; + chunk.holes = 0; + for (int v = 0; v < 145; v++) + chunk.heightMap.heights[v] = 0.0f; + } + // Punch a hole in chunk 0 (all 16 sub-quads) + terrain.chunks[0].holes = 0xFFFF; + + auto col = WoweeCollisionBuilder::fromTerrain(terrain); + REQUIRE(col.isValid()); + // Chunk 0 should produce zero triangles, rest produce 128 each + REQUIRE(col.triangles.size() == 255 * 128); +} + TEST_CASE("WOB rejects missing file", "[wob]") { REQUIRE_FALSE(WoweeBuildingLoader::exists("nonexistent_path")); } diff --git a/tools/editor/FORMAT_SPEC.md b/tools/editor/FORMAT_SPEC.md index 8de90adf..207d360c 100644 --- a/tools/editor/FORMAT_SPEC.md +++ b/tools/editor/FORMAT_SPEC.md @@ -59,18 +59,28 @@ Novel file formats for custom WoW zone content. No Blizzard IP. - Standard PNG format, loaded by client's texture override system - Editor auto-converts BLP→PNG on export via stb_image_write +## WOC — Wowee Open Collision (binary) +- Extension: `.woc` +- Magic: `WOC1` (0x31434F57) +- Layout: magic(4) + triCount(4) + tileX(4) + tileY(4) + boundsMin(12) + boundsMax(12) + triangles +- Triangle: v0(12) + v1(12) + v2(12) + flags(1) = 37 bytes +- Flags: 0x01=walkable, 0x02=water, 0x04=steep, 0x08=indoor +- Generated from terrain heightmap with slope classification (50 deg threshold) +- Respects terrain holes (skips triangles in hole regions) + ## Terrain Stamps (.json) - Portable terrain feature snapshots (mountains, craters, etc.) - Format: `{"format": "wowee-stamp-1.0", "vertices": [[dx, dy, height], ...]}` - Can be saved/loaded across zones and sessions -## Open Format Scoring (0-6) +## Open Format Scoring (0-7) 1. WOT terrain metadata present 2. WHM heightmap with valid magic 3. zone.json map definition 4. PNG textures present 5. WOM models with valid magic 6. WOB buildings with valid magic +7. WOC collision mesh with valid magic ## All formats are novel, portable, and open for redistribution. ## No Blizzard intellectual property is used in any format definition. diff --git a/tools/editor/content_pack.cpp b/tools/editor/content_pack.cpp index 44ac67c0..c59c578f 100644 --- a/tools/editor/content_pack.cpp +++ b/tools/editor/content_pack.cpp @@ -202,6 +202,7 @@ ContentPacker::ValidationResult ContentPacker::validateZone(const std::string& z static constexpr uint32_t WHM_MAGIC = 0x314D4857; // "WHM1" static constexpr uint32_t WOM_MAGIC = 0x314D4F57; // "WOM1" static constexpr uint32_t WOB_MAGIC = 0x31424F57; // "WOB1" + static constexpr uint32_t WOC_MAGIC = 0x31434F57; // "WOC1" for (auto& entry : fs::recursive_directory_iterator(zoneDir)) { if (!entry.is_regular_file()) continue; @@ -220,6 +221,10 @@ ContentPacker::ValidationResult ContentPacker::validateZone(const std::string& z r.hasWob = true; if (checkMagic(entry.path().string(), WOB_MAGIC)) r.wobValid = true; } + if (ext == ".woc") { + r.hasWoc = true; + if (checkMagic(entry.path().string(), WOC_MAGIC)) r.wocValid = true; + } if (ext == ".png") r.hasPng = true; if (fname == "zone.json") r.hasZoneJson = true; if (fname == "creatures.json") r.hasCreatures = true; @@ -237,7 +242,8 @@ int ContentPacker::ValidationResult::openFormatScore() const { if (hasPng) score++; if (hasWom && womValid) score++; if (hasWob && wobValid) score++; - return score; // max 6 for fully open + if (hasWoc && wocValid) score++; + return score; // max 7 for fully open } std::string ContentPacker::ValidationResult::summary() const { @@ -252,6 +258,7 @@ std::string ContentPacker::ValidationResult::summary() const { add(hasWhm, whmValid, "WHM"); add(hasWom, womValid, "WOM"); add(hasWob, wobValid, "WOB"); + add(hasWoc, wocValid, "WOC"); if (hasZoneJson) s += "zone.json "; if (hasPng) s += "PNG "; if (hasCreatures) s += "creatures "; diff --git a/tools/editor/content_pack.hpp b/tools/editor/content_pack.hpp index 6ba05098..d44b35d1 100644 --- a/tools/editor/content_pack.hpp +++ b/tools/editor/content_pack.hpp @@ -38,9 +38,9 @@ public: // Validate that a zone directory has all required open format files struct ValidationResult { bool hasWot = false, hasWhm = false, hasZoneJson = false; - bool hasPng = false, hasWom = false, hasWob = false; + bool hasPng = false, hasWom = false, hasWob = false, hasWoc = false; bool hasCreatures = false, hasQuests = false, hasObjects = false; - bool whmValid = false, womValid = false, wobValid = false; + bool whmValid = false, womValid = false, wobValid = false, wocValid = false; int openFormatScore() const; std::string summary() const; }; diff --git a/tools/editor/editor_app.cpp b/tools/editor/editor_app.cpp index a6c27eb6..72b220c2 100644 --- a/tools/editor/editor_app.cpp +++ b/tools/editor/editor_app.cpp @@ -7,6 +7,7 @@ #include "dbc_exporter.hpp" #include "pipeline/wowee_model.hpp" #include "pipeline/wowee_building.hpp" +#include "pipeline/wowee_collision.hpp" #include "pipeline/wmo_loader.hpp" #include "core/coordinates.hpp" #include @@ -956,6 +957,11 @@ void EditorApp::exportZone(const std::string& outputDir) { std::to_string(loadedTileX_) + "_" + std::to_string(loadedTileY_); WoweeTerrain::exportOpen(terrain_, openBase, loadedTileX_, loadedTileY_); WoweeTerrain::exportNormalMap(terrain_, openBase + "_normals.png"); + + // Export collision mesh (.woc) + auto collision = pipeline::WoweeCollisionBuilder::fromTerrain(terrain_); + if (collision.isValid()) + pipeline::WoweeCollisionBuilder::save(collision, openBase + ".woc"); WoweeTerrain::exportAlphaMaps(terrain_, base + "/alphamaps"); WoweeTerrain::exportWaterMask(terrain_, openBase + "_watermask.png"); WoweeTerrain::exportHoleMask(terrain_, openBase + "_holemask.png"); @@ -1059,11 +1065,11 @@ void EditorApp::exportZone(const std::string& outputDir) { if (objectPlacer_.objectCount() > 0) summary += ", " + std::to_string(objectPlacer_.objectCount()) + " obj"; if (npcSpawner_.spawnCount() > 0) summary += ", " + std::to_string(npcSpawner_.spawnCount()) + " NPC"; if (questEditor_.questCount() > 0) summary += ", " + std::to_string(questEditor_.questCount()) + " quest"; - summary += " (score " + std::to_string(score) + "/6)"; + summary += " (score " + std::to_string(score) + "/7)"; showToast(summary, 5.0f); LOG_INFO("=== Zone Export Summary ==="); LOG_INFO(" Output: ", base); - LOG_INFO(" Open format score: ", score, "/6"); + LOG_INFO(" Open format score: ", score, "/7"); LOG_INFO(" Formats: ", validation.summary()); LOG_INFO(" Terrain: WOT/WHM + heightmap/normals PNG"); LOG_INFO(" Textures: ", usedTextures.size(), " BLP→PNG"); diff --git a/tools/editor/editor_ui.cpp b/tools/editor/editor_ui.cpp index d011d45f..3354a8ab 100644 --- a/tools/editor/editor_ui.cpp +++ b/tools/editor/editor_ui.cpp @@ -366,7 +366,7 @@ void EditorUI::renderMenuBar(EditorApp& app) { ImVec4 scoreColor = score >= 5 ? ImVec4(0.3f, 1, 0.3f, 1) : score >= 3 ? ImVec4(1, 1, 0.3f, 1) : ImVec4(1, 0.3f, 0.3f, 1); - ImGui::TextColored(scoreColor, "Open Format Score: %d/6", score); + ImGui::TextColored(scoreColor, "Open Format Score: %d/7", score); ImGui::Separator(); auto fmt = [](bool has, bool valid, const char* name, const char* desc) { ImVec4 c = has ? (valid ? ImVec4(0.3f,1,0.3f,1) : ImVec4(1,0.7f,0.3f,1)) @@ -380,6 +380,7 @@ void EditorUI::renderMenuBar(EditorApp& app) { fmt(val.hasPng, true, "PNG", "textures"); fmt(val.hasWom, val.womValid, "WOM", "models"); fmt(val.hasWob, val.wobValid, "WOB", "buildings"); + fmt(val.hasWoc, val.wocValid, "WOC", "collision mesh"); ImGui::Separator(); fmt(val.hasCreatures, true, "creatures", "NPC spawns"); fmt(val.hasQuests, true, "quests", "quest data");