feat: WOC collision mesh format — 7th novel open format

New format: WOC (Wowee Open Collision) — binary collision mesh for
custom zone walkability. Magic WOC1 (0x31434F57).

- WoweeCollisionBuilder::fromTerrain() generates collision triangles
  from terrain heightmap with slope classification (50 deg threshold)
- Per-triangle flags: walkable (0x01), water (0x02), steep (0x04)
- Respects terrain holes (skips triangles in hole regions)
- Binary save/load with bounds, tile coords, triangle data
- Auto-exported on zone save alongside WOT/WHM/WOM/WOB
- Added to content pack validation (score now 0-7)
- FORMAT_SPEC.md v1.1 updated with WOC binary layout
- 19 new test assertions: flat terrain generation (32k tris all
  walkable), save/load round-trip, hole skipping
- 328 total assertions across 84 test cases
This commit is contained in:
Kelsi 2026-05-05 15:23:58 -07:00
parent 961d863f82
commit 4d5eef480e
10 changed files with 342 additions and 7 deletions

View file

@ -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

View file

@ -0,0 +1,54 @@
#pragma once
#include <glm/glm.hpp>
#include <string>
#include <vector>
#include <cstdint>
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<Triangle> 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

View file

@ -0,0 +1,176 @@
#include "pipeline/wowee_collision.hpp"
#include "pipeline/adt_loader.hpp"
#include "core/logger.hpp"
#include <fstream>
#include <filesystem>
#include <cstring>
#include <cmath>
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<const char*>(&WOC_MAGIC), 4);
uint32_t triCount = static_cast<uint32_t>(collision.triangles.size());
f.write(reinterpret_cast<const char*>(&triCount), 4);
f.write(reinterpret_cast<const char*>(&collision.tileX), 4);
f.write(reinterpret_cast<const char*>(&collision.tileY), 4);
f.write(reinterpret_cast<const char*>(&collision.bounds.min), 12);
f.write(reinterpret_cast<const char*>(&collision.bounds.max), 12);
for (const auto& tri : collision.triangles) {
f.write(reinterpret_cast<const char*>(&tri.v0), 12);
f.write(reinterpret_cast<const char*>(&tri.v1), 12);
f.write(reinterpret_cast<const char*>(&tri.v2), 12);
f.write(reinterpret_cast<const char*>(&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<char*>(&magic), 4);
if (magic != WOC_MAGIC) return col;
uint32_t triCount;
f.read(reinterpret_cast<char*>(&triCount), 4);
f.read(reinterpret_cast<char*>(&col.tileX), 4);
f.read(reinterpret_cast<char*>(&col.tileY), 4);
f.read(reinterpret_cast<char*>(&col.bounds.min), 12);
f.read(reinterpret_cast<char*>(&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<char*>(&tri.v0), 12);
f.read(reinterpret_cast<char*>(&tri.v1), 12);
f.read(reinterpret_cast<char*>(&tri.v2), 12);
f.read(reinterpret_cast<char*>(&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

View file

@ -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

View file

@ -1,6 +1,7 @@
// Tests for Wowee open format round-trips (WOM, WOB, WHM/WOT)
#include <catch_amalgamated.hpp>
#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"));
}

View file

@ -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.

View file

@ -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 ";

View file

@ -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;
};

View file

@ -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 <nlohmann/json.hpp>
@ -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");

View file

@ -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");