test: raw terrain reading, scene lookup, scene graph and scene commands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-10-16 11:13:19 -05:00
parent db0bae76d0
commit 36d6245331
5 changed files with 805 additions and 0 deletions

View File

@@ -8,6 +8,9 @@ list(APPEND DGAMETEST_SOURCES ${DCOMPONENTS_TESTS})
add_subdirectory(dGameMessagesTests)
list(APPEND DGAMETEST_SOURCES ${DGAMEMESSAGES_TESTS})
add_subdirectory(dZoneManagerTests)
list(APPEND DGAMETEST_SOURCES ${DZONEMANAGER_TESTS})
file(COPY ${GAMEMESSAGE_TESTBITSTREAMS} DESTINATION ${CMAKE_CURRENT_BINARY_DIR})
file(COPY ${COMPONENT_TEST_DATA} DESTINATION ${CMAKE_CURRENT_BINARY_DIR})

View File

@@ -0,0 +1,10 @@
SET(DZONEMANAGER_TESTS
"RawTests.cpp"
"ZoneSceneTests.cpp")
# Get the folder name and prepend it to the files above
get_filename_component(thisFolderName ${CMAKE_CURRENT_SOURCE_DIR} NAME)
list(TRANSFORM DZONEMANAGER_TESTS PREPEND "${thisFolderName}/")
# Export to parent scope
set(DZONEMANAGER_TESTS ${DZONEMANAGER_TESTS} PARENT_SCOPE)

View File

@@ -0,0 +1,373 @@
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <random>
#include "Game.h"
#include "Logger.h"
#include "NiColor.h"
#include "Raw.h"
#include "SceneColor.h"
#include "TerrainTestWriter.h"
using namespace TerrainTestWriter;
class RawTest : public ::testing::Test {
protected:
void SetUp() override {
Game::logger = new Logger("./testing.log", false, false);
}
void TearDown() override {
delete Game::logger;
Game::logger = nullptr;
}
static bool Read(const std::string& data, Raw::Raw& raw) {
std::istringstream stream(data);
return Raw::ReadRaw(stream, raw);
}
// A chunksX by chunksZ grid of 5x5 point chunks, scale 2 (8 units across), each with a 4x4 scene map whose cells
// are numbered [x * 4 + z] plus 16 * the chunk's index
static std::vector<TestChunk> MakeGrid(uint32_t chunksX, uint32_t chunksZ, float originX = -8.0f, float originZ = -8.0f) {
std::vector<TestChunk> chunks;
for (uint32_t x = 0; x < chunksX; ++x) {
for (uint32_t z = 0; z < chunksZ; ++z) {
TestChunk chunk;
chunk.id = x * chunksZ + z;
chunk.width = 5;
chunk.height = 5;
chunk.scale = 2.0f;
chunk.offsetX = originX + 8.0f * x;
chunk.offsetZ = originZ + 8.0f * z;
chunk.colorMapResolution = 4;
for (uint8_t cell = 0; cell < 16; ++cell) chunk.sceneMap.push_back(static_cast<uint8_t>(cell + 16 * chunk.id));
chunks.push_back(chunk);
}
}
return chunks;
}
};
TEST_F(RawTest, ReadsEveryPartOfVersion32) {
auto chunks = MakeGrid(2, 3);
chunks[1].vertSize = 0; // a chunk without a render mesh
chunks[2].flairCount = 0;
const auto data = WriteRaw(chunks, 32, 2, 3);
std::istringstream stream(data);
Raw::Raw raw;
ASSERT_TRUE(Raw::ReadRaw(stream, raw));
EXPECT_EQ(stream.tellg(), static_cast<std::streamoff>(data.size())); // every block was read or skipped
EXPECT_EQ(raw.version, 32);
EXPECT_EQ(raw.dev, 0);
EXPECT_EQ(raw.numChunks, 6);
EXPECT_EQ(raw.numChunksWidth, 3);
EXPECT_EQ(raw.numChunksHeight, 2);
ASSERT_EQ(raw.chunks.size(), 6);
const auto& chunk = raw.chunks[4];
EXPECT_EQ(chunk.id, 4);
EXPECT_EQ(chunk.width, 5);
EXPECT_EQ(chunk.height, 5);
EXPECT_FLOAT_EQ(chunk.offsetX, 0.0f);
EXPECT_FLOAT_EQ(chunk.offsetZ, 0.0f);
EXPECT_FLOAT_EQ(chunk.scaleFactor, 2.0f);
ASSERT_EQ(chunk.heightMap.size(), 25);
EXPECT_FLOAT_EQ(chunk.heightMap[7], 7.0f);
EXPECT_EQ(chunk.colorMapResolution, 4);
ASSERT_EQ(chunk.sceneMap.size(), 16);
EXPECT_EQ(chunk.sceneMap[5], 5 + 16 * 4);
EXPECT_TRUE(chunk.IsValidForSceneLookup());
EXPECT_FLOAT_EQ(chunk.sceneCellSizeX, 2.0f);
EXPECT_FLOAT_EQ(chunk.sceneCellsPerUnitZ, 0.5f);
EXPECT_EQ(chunk.textureIds, (std::vector<uint32_t>{ 100, 101, 102, 103 }));
EXPECT_EQ(chunk.colorMap.size(), 4 * 4 * 4);
EXPECT_EQ(chunk.colorMap[0], 0xAB);
EXPECT_EQ(chunk.lightMap.size(), 5);
EXPECT_EQ(chunk.textureMapResolution, 2);
EXPECT_EQ(chunk.textureMap.size(), 2 * 2 * 4);
EXPECT_EQ(chunk.textureSettings, 1);
EXPECT_EQ(chunk.blendMap.size(), 7);
ASSERT_EQ(chunk.flairs.size(), 2);
const auto& flair = chunk.flairs[1];
EXPECT_EQ(flair.id, 501);
EXPECT_FLOAT_EQ(flair.scaleFactor, 1.5f);
EXPECT_FLOAT_EQ(flair.position.x, 2.0f);
EXPECT_FLOAT_EQ(flair.position.z, 3.0f);
EXPECT_FLOAT_EQ(flair.rotation.y, 90.0f);
EXPECT_EQ(flair.colorR, 10);
EXPECT_EQ(flair.colorG, 20);
EXPECT_EQ(flair.colorB, 30);
EXPECT_EQ(flair.colorA, 40);
EXPECT_TRUE(raw.chunks[2].flairs.empty());
EXPECT_EQ(chunk.vertSize, 4);
EXPECT_EQ(chunk.meshVertUsage.size(), 4);
EXPECT_EQ(chunk.meshVertSize.size(), 16);
ASSERT_EQ(chunk.meshTri.size(), 16);
EXPECT_EQ(chunk.meshTri[2].meshTriListSize, 6);
EXPECT_EQ(chunk.meshTri[2].meshTriList.size(), 6);
EXPECT_EQ(chunk.meshTri[15].meshTriListSize, 3);
EXPECT_EQ(raw.chunks[1].vertSize, 0);
EXPECT_TRUE(raw.chunks[1].meshTri.empty());
}
TEST_F(RawTest, BoundsAndChunkGridUseTheSpanBetweenFirstAndLastPoint) {
Raw::Raw raw;
ASSERT_TRUE(Read(WriteRaw(MakeGrid(2, 3), 32, 2, 3), raw));
// 5 points 2 apart span 8 units, so chunks don't overlap
EXPECT_FLOAT_EQ(raw.minBoundsX, -8.0f);
EXPECT_FLOAT_EQ(raw.maxBoundsX, 8.0f);
EXPECT_FLOAT_EQ(raw.minBoundsZ, -8.0f);
EXPECT_FLOAT_EQ(raw.maxBoundsZ, 16.0f);
EXPECT_FLOAT_EQ(raw.chunks[0].GetMaxX(), 0.0f);
EXPECT_EQ(raw.chunkGridCountX, 2);
EXPECT_EQ(raw.chunkGridCountZ, 3);
EXPECT_FLOAT_EQ(raw.chunkSpanX, 8.0f);
EXPECT_FLOAT_EQ(raw.chunkSpanZ, 8.0f);
ASSERT_EQ(raw.chunkGrid.size(), 6);
for (int32_t i = 0; i < 6; ++i) EXPECT_EQ(raw.chunkGrid[i], i);
}
TEST_F(RawTest, ChunksOutOfOrderAreFoundThroughTheGrid) {
auto chunks = MakeGrid(2, 2);
std::swap(chunks[0], chunks[3]);
Raw::Raw raw;
ASSERT_TRUE(Read(WriteRaw(chunks, 32, 2, 2), raw));
ASSERT_EQ(raw.chunkGrid.size(), 4);
EXPECT_EQ(raw.chunkGrid[0], 3);
EXPECT_EQ(raw.chunkGrid[3], 0);
EXPECT_EQ(raw.GetSceneIDAt(-7.0f, -7.0f), 1 * 4 + 1); // chunk id 0, local (1, 1)
}
TEST_F(RawTest, ChunksThatAreNotAGridAreSearchedOneByOne) {
auto chunks = MakeGrid(2, 1);
chunks[1].offsetZ = 3.0f; // shifted off the grid
Raw::Raw raw;
ASSERT_TRUE(Read(WriteRaw(chunks, 32, 2, 1), raw));
EXPECT_TRUE(raw.chunkGrid.empty());
EXPECT_EQ(raw.GetSceneIDAt(-7.0f, -7.0f), 1 * 4 + 1);
EXPECT_EQ(raw.GetSceneIDAt(1.0f, 4.0f), 16 + 1 * 4 + 1); // chunk 1, local (1, 1)
}
TEST_F(RawTest, ReadsVersion31SceneMapWithoutTheLastRowAndColumn) {
TestChunk chunk;
chunk.width = 3;
chunk.height = 3;
// resolution is width - 1 = 2; the file has a 3x3 scene map
chunk.sceneMap = { 1, 2, 9, 3, 4, 9, 9, 9, 9 };
const auto data = WriteRaw({ chunk }, 31);
std::istringstream stream(data);
Raw::Raw raw;
ASSERT_TRUE(Raw::ReadRaw(stream, raw));
EXPECT_EQ(stream.tellg(), static_cast<std::streamoff>(data.size()));
ASSERT_EQ(raw.chunks.size(), 1);
EXPECT_EQ(raw.chunks[0].colorMapResolution, 2);
EXPECT_EQ(raw.chunks[0].sceneMap, (std::vector<uint8_t>{ 1, 2, 3, 4 }));
EXPECT_EQ(raw.chunks[0].shaderId, 77);
EXPECT_EQ(raw.chunks[0].textureIds, (std::vector<uint32_t>{ 100, 101, 102, 103 }));
EXPECT_EQ(raw.chunks[0].colorMap.size(), 3 * 3 * 4); // width^2 pixels
EXPECT_TRUE(raw.chunks[0].lightMap.empty());
EXPECT_EQ(raw.chunks[0].textureMap.size(), 2 * 2 * 4);
EXPECT_EQ(raw.chunks[0].flairs.size(), 2);
EXPECT_TRUE(raw.chunks[0].meshTri.empty());
}
TEST_F(RawTest, ReadsVersion30WithoutASceneMap) {
TestChunk chunk;
const auto data = WriteRaw({ chunk, chunk }, 30, 1, 2);
std::istringstream stream(data);
Raw::Raw raw;
ASSERT_TRUE(Raw::ReadRaw(stream, raw));
EXPECT_EQ(stream.tellg(), static_cast<std::streamoff>(data.size()));
ASSERT_EQ(raw.chunks.size(), 2);
EXPECT_TRUE(raw.chunks[1].sceneMap.empty());
EXPECT_FALSE(raw.chunks[1].IsValidForSceneLookup());
EXPECT_EQ(raw.GetSceneIDAt(0.5f, 0.5f), std::nullopt);
}
TEST_F(RawTest, DevFileHasNoChunks) {
Raw::Raw raw;
ASSERT_TRUE(Read(WriteRaw({}, 32, 0, 0, 1), raw));
EXPECT_EQ(raw.dev, 1);
EXPECT_TRUE(raw.chunks.empty());
EXPECT_EQ(raw.GetSceneIDAt(0.0f, 0.0f), std::nullopt);
}
TEST_F(RawTest, RejectsBadFiles) {
Raw::Raw raw;
EXPECT_FALSE(Read("", raw));
const auto data = WriteRaw(MakeGrid(1, 2), 32, 1, 2);
EXPECT_FALSE(Read(data.substr(0, data.size() - 3), raw)); // truncated in the last chunk's mesh
std::string tooManyChunks = data;
const uint32_t chunkCount = 5000;
tooManyChunks.replace(3, sizeof(chunkCount), reinterpret_cast<const char*>(&chunkCount), sizeof(chunkCount));
EXPECT_FALSE(Read(tooManyChunks, raw));
// a 3x3 chunk's color map resolution is at 15 (header) + 40 (chunk info) + 36 (heights)
std::string hugeColorMap = WriteRaw({ TestChunk{} });
const uint32_t resolution = 5000;
hugeColorMap.replace(91, sizeof(resolution), reinterpret_cast<const char*>(&resolution), sizeof(resolution));
EXPECT_FALSE(Read(hugeColorMap, raw));
}
TEST_F(RawTest, SceneCellIsTheNearestOneLikeTheClient) {
Raw::Raw raw;
ASSERT_TRUE(Read(WriteRaw(MakeGrid(1, 1, 0.0f, 0.0f)), raw));
const auto& chunk = raw.chunks[0];
// cells are 2 units; floor(position / 2 + 0.5), clamped to [0, 3]; index [x cell * 4 + z cell]
EXPECT_EQ(chunk.GetSceneIDAtLocal(0.0f, 0.0f), 0);
EXPECT_EQ(chunk.GetSceneIDAtLocal(0.9f, 0.0f), 0);
EXPECT_EQ(chunk.GetSceneIDAtLocal(1.0f, 0.0f), 4);
EXPECT_EQ(chunk.GetSceneIDAtLocal(0.0f, 3.1f), 2);
EXPECT_EQ(chunk.GetSceneIDAtLocal(5.0f, 1.0f), 13);
EXPECT_EQ(chunk.GetSceneIDAtLocal(100.0f, 100.0f), 15);
EXPECT_EQ(chunk.GetSceneIDAtLocal(-100.0f, -100.0f), 0);
// grid points are 2 units apart: point (i, j) is at (2i, 2j)
EXPECT_EQ(chunk.GetSceneIDAtGrid(1, 2), 1 * 4 + 2);
EXPECT_EQ(chunk.GetSceneIDAtGrid(4, 4), 15);
}
TEST_F(RawTest, SceneAtPositionMatchesTheClient) {
Raw::Raw raw;
ASSERT_TRUE(Read(WriteRaw(MakeGrid(2, 2), 32, 2, 2), raw));
// inside chunk 0 ([-8, 0] x [-8, 0])
EXPECT_EQ(raw.GetSceneIDAt(-8.0f, -8.0f), 0);
EXPECT_EQ(raw.GetSceneIDAt(-5.0f, -8.0f), 4 * 2);
// chunk 3 ([0, 8] x [0, 8])
EXPECT_EQ(raw.GetSceneIDAt(0.5f, 0.5f), 48);
// within 0.001 of a chunk's start counts as the next chunk
EXPECT_EQ(raw.GetSceneIDAt(-0.0005f, -7.0f), 32 + 1);
EXPECT_EQ(raw.GetSceneIDAt(-0.01f, -7.0f), 3 * 4 + 1);
// outside the terrain clamps to its edge
EXPECT_EQ(raw.GetSceneIDAt(-500.0f, -500.0f), 0);
// the far edge is kept one scene cell inside, so it is the last cell of the last chunk
EXPECT_EQ(raw.GetSceneIDAt(8.0f, 8.0f), 48 + 15);
EXPECT_EQ(raw.GetSceneIDAt(500.0f, 500.0f), 48 + 15);
}
TEST_F(RawTest, GridLookupMatchesCheckingEveryChunk) {
auto chunks = MakeGrid(3, 4, -1126.4f, -921.6f);
for (auto& chunk : chunks) {
chunk.scale = 3.2f;
chunk.offsetX = -1126.4f + (chunk.offsetX + 1126.4f) / 8.0f * 12.8f;
chunk.offsetZ = -921.6f + (chunk.offsetZ + 921.6f) / 8.0f * 12.8f;
}
chunks.erase(chunks.begin() + 5); // a hole in the grid
Raw::Raw raw;
ASSERT_TRUE(Read(WriteRaw(chunks, 32, 3, 4), raw));
ASSERT_FALSE(raw.chunkGrid.empty());
Raw::Raw linear = raw;
linear.chunkGrid.clear();
std::mt19937 random(1);
std::uniform_real_distribution<float> randomX(raw.minBoundsX - 20.0f, raw.maxBoundsX + 20.0f);
std::uniform_real_distribution<float> randomZ(raw.minBoundsZ - 20.0f, raw.maxBoundsZ + 20.0f);
for (int i = 0; i < 20000; ++i) {
float x = randomX(random);
float z = randomZ(random);
// every few positions on a chunk edge
if (i % 4 == 0) x = raw.minBoundsX + std::round((x - raw.minBoundsX) / raw.chunkSpanX) * raw.chunkSpanX;
if (i % 5 == 0) z = raw.minBoundsZ + std::round((z - raw.minBoundsZ) / raw.chunkSpanZ) * raw.chunkSpanZ;
ASSERT_EQ(raw.GetSceneIDAt(x, z), linear.GetSceneIDAt(x, z)) << x << ", " << z;
}
}
TEST_F(RawTest, GridToWorldPosition) {
Raw::Raw raw;
ASSERT_TRUE(Read(WriteRaw(MakeGrid(1, 1)), raw));
const auto position = raw.chunks[0].GridToWorldPos(1, 3);
EXPECT_FLOAT_EQ(position.x, -8.0f + 2.0f);
EXPECT_FLOAT_EQ(position.y, 1 * 5 + 3);
EXPECT_FLOAT_EQ(position.z, -8.0f + 6.0f);
}
TEST_F(RawTest, TerrainMeshForNonSquareChunks) {
TestChunk first;
first.width = 3;
first.height = 4;
first.colorMapResolution = 2;
first.sceneMap = { 1, 2, 3, 4 };
TestChunk second = first;
second.offsetX = 2.0f;
Raw::Raw raw;
ASSERT_TRUE(Read(WriteRaw({ first, second }, 32, 2, 1), raw));
Raw::TerrainMesh mesh;
Raw::GenerateTerrainMesh(raw, mesh);
ASSERT_EQ(mesh.vertices.size(), 2 * 12);
ASSERT_EQ(mesh.triangles.size(), 2 * (2 * 3) * 2 * 3);
for (const auto index : mesh.triangles) EXPECT_LT(index, mesh.vertices.size());
// vertex [i * height + j]; the first quad of the first chunk
EXPECT_EQ(mesh.triangles[0], 1 * 4 + 1);
EXPECT_EQ(mesh.triangles[1], 1 * 4 + 0);
EXPECT_EQ(mesh.triangles[2], 0);
EXPECT_FLOAT_EQ(mesh.vertices[1 * 4 + 3].position.x, 1.0f);
EXPECT_FLOAT_EQ(mesh.vertices[1 * 4 + 3].position.z, 3.0f);
EXPECT_EQ(mesh.vertices[2 * 4 + 3].sceneID, 4);
// the second chunk's vertices come after the first's
EXPECT_FLOAT_EQ(mesh.vertices[12].position.x, 2.0f);
EXPECT_EQ(mesh.triangles[36], 12 + 1 * 4 + 1);
}
TEST_F(RawTest, NoTerrainMeshWithoutSceneData) {
TestChunk chunk;
Raw::Raw raw;
ASSERT_TRUE(Read(WriteRaw({ chunk }, 30), raw));
Raw::TerrainMesh mesh;
Raw::GenerateTerrainMesh(raw, mesh);
EXPECT_TRUE(mesh.vertices.empty());
EXPECT_TRUE(mesh.triangles.empty());
}
TEST_F(RawTest, WritesTheTerrainMeshAsObj) {
Raw::TerrainMesh mesh;
mesh.vertices = { Raw::SceneVertex(NiPoint3(0.0f, 1.0f, 2.0f), 1), Raw::SceneVertex(NiPoint3(3.0f, 4.0f, 5.0f), 200), Raw::SceneVertex() };
mesh.triangles = { 0, 1, 2 };
const auto path = (std::filesystem::temp_directory_path() / "dlu_raw_test.obj").string();
ASSERT_TRUE(Raw::WriteTerrainMeshToOBJ(mesh, path));
std::ifstream file(path);
std::vector<std::string> lines;
for (std::string line; std::getline(file, line);) lines.push_back(line);
std::filesystem::remove(path);
ASSERT_EQ(lines.size(), 4);
EXPECT_EQ(lines[0], "v 0 1 2 1 0 0"); // scene 1 is red
EXPECT_EQ(lines[1], "v 3 4 5 1 1 1"); // past the table: white
EXPECT_EQ(lines[3], "f 1 2 3");
EXPECT_FALSE(Raw::WriteTerrainMeshToOBJ(mesh, "/nonexistent-directory/terrain.obj"));
}
TEST(SceneColorTest, TemplateColorsAndFallback) {
EXPECT_FLOAT_EQ(SceneColor::Get(0).m_Red, 128.0f / 255.0f);
EXPECT_FLOAT_EQ(SceneColor::Get(3).m_Blue, 1.0f);
EXPECT_FLOAT_EQ(SceneColor::Get(145).m_Green, SceneColor::TEMPLATE_COLORS.back().m_Green);
EXPECT_EQ(&SceneColor::Get(146), &SceneColor::FALLBACK_COLOR);
EXPECT_EQ(&SceneColor::Get(255), &SceneColor::FALLBACK_COLOR);
}
TEST(NiColorTest, ConstructionAndLuminance) {
constexpr NiColor black;
EXPECT_FLOAT_EQ(black.m_Red, 0.0f);
EXPECT_FLOAT_EQ(black.Luminance(), 0.0f);
EXPECT_NEAR(NiColor(1.0f, 1.0f, 1.0f).Luminance(), 1.0f, 0.0001f);
EXPECT_FLOAT_EQ(NiColor(0.0f, 1.0f, 0.0f).Luminance(), 0.71516f);
}

View File

@@ -0,0 +1,183 @@
#pragma once
#include <cstdint>
#include <sstream>
#include <string>
#include <vector>
#include "NiPoint3.h"
// Writes .raw terrain and .luz zone files in the client's layout, for the zone manager tests
namespace TerrainTestWriter {
template<typename T>
void Write(std::ostream& stream, const T& value) {
stream.write(reinterpret_cast<const char*>(&value), sizeof(T));
}
inline void WriteBytes(std::ostream& stream, size_t count, uint8_t value = 0xAB) {
const std::string bytes(count, static_cast<char>(value));
stream.write(bytes.data(), bytes.size());
}
inline void WriteString8(std::ostream& stream, const std::string& value) {
Write<uint8_t>(stream, static_cast<uint8_t>(value.size()));
stream.write(value.data(), value.size());
}
struct TestChunk {
uint32_t id = 0;
uint32_t width = 3;
uint32_t height = 3;
float offsetX = 0.0f;
float offsetZ = 0.0f;
float scale = 1.0f;
// Version 32+: the color map resolution; older versions use width - 1
uint32_t colorMapResolution = 2;
// colorMapResolution^2 scene IDs ((resolution + 1)^2 for version 31); empty = all 0
std::vector<uint8_t> sceneMap;
// width * height heights; empty = the point index
std::vector<float> heights;
uint32_t lightMapSize = 5;
uint32_t textureMapResolution = 2;
uint32_t blendMapSize = 7;
uint32_t flairCount = 2;
uint32_t vertSize = 4;
std::vector<uint16_t> triangleListSizes = { 3, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3 };
};
inline void WriteChunk(std::ostream& stream, const TestChunk& chunk, uint16_t version) {
Write(stream, chunk.id);
Write(stream, chunk.width);
Write(stream, chunk.height);
Write(stream, chunk.offsetX);
Write(stream, chunk.offsetZ);
if (version < 32) Write<uint32_t>(stream, 77); // shader ID
for (uint32_t i = 0; i < 4; ++i) Write<uint32_t>(stream, 100 + i); // texture IDs
Write(stream, chunk.scale);
for (uint32_t i = 0; i < chunk.width * chunk.height; ++i) {
Write<float>(stream, chunk.heights.empty() ? static_cast<float>(i) : chunk.heights[i]);
}
const uint32_t resolution = version >= 32 ? chunk.colorMapResolution : chunk.width - 1;
if (version >= 32) {
Write(stream, resolution);
WriteBytes(stream, static_cast<size_t>(resolution) * resolution * 4);
Write(stream, chunk.lightMapSize);
WriteBytes(stream, chunk.lightMapSize);
} else {
WriteBytes(stream, static_cast<size_t>(chunk.width) * chunk.width * 4);
}
Write(stream, chunk.textureMapResolution);
WriteBytes(stream, static_cast<size_t>(chunk.textureMapResolution) * chunk.textureMapResolution * 4);
if (version >= 32) {
Write<uint8_t>(stream, 1); // texture settings
Write(stream, chunk.blendMapSize);
WriteBytes(stream, chunk.blendMapSize);
}
// Flair: id, scale, position, rotation, RGBA (36 bytes)
Write(stream, chunk.flairCount);
for (uint32_t i = 0; i < chunk.flairCount; ++i) {
Write<uint32_t>(stream, 500 + i);
Write<float>(stream, 1.5f);
Write(stream, NiPoint3(1.0f + i, 2.0f, 3.0f));
Write(stream, NiPoint3(0.0f, 90.0f, 0.0f));
for (const uint8_t channel : { 10, 20, 30, 40 }) Write(stream, channel);
}
if (version >= 31) {
const size_t cells = version >= 32 ? static_cast<size_t>(resolution) * resolution : static_cast<size_t>(resolution + 1) * (resolution + 1);
for (size_t i = 0; i < cells; ++i) Write<uint8_t>(stream, chunk.sceneMap.empty() ? 0 : chunk.sceneMap[i]);
} else {
Write<uint8_t>(stream, 0);
}
if (version < 32) return;
Write(stream, chunk.vertSize);
if (chunk.vertSize == 0) return;
WriteBytes(stream, static_cast<size_t>(chunk.vertSize) * sizeof(uint16_t));
WriteBytes(stream, 16 * sizeof(uint16_t));
for (uint32_t i = 0; i < 16; ++i) {
const uint16_t size = i < chunk.triangleListSizes.size() ? chunk.triangleListSizes[i] : 0;
Write(stream, size);
WriteBytes(stream, static_cast<size_t>(size) * sizeof(uint16_t));
}
}
inline std::string WriteRaw(const std::vector<TestChunk>& chunks, uint16_t version = 32, uint32_t chunksX = 1, uint32_t chunksZ = 1, uint8_t dev = 0) {
std::ostringstream stream;
Write(stream, version);
Write(stream, dev);
if (dev == 0) {
Write<uint32_t>(stream, static_cast<uint32_t>(chunks.size()));
Write(stream, chunksZ); // the header's "width" is the chunk count along Z
Write(stream, chunksX);
for (const auto& chunk : chunks) WriteChunk(stream, chunk, version);
}
return stream.str();
}
struct TestScene {
std::string filename;
uint32_t id = 0;
uint32_t layer = 0;
std::string name;
uint8_t r = 0, g = 0, b = 0;
};
struct TestTransitionPoint {
uint32_t sceneID = 0;
uint32_t layerID = 0;
NiPoint3 position;
};
// A Latest (41) version .luz with no paths
inline std::string WriteLuz(uint32_t worldID, const std::vector<TestScene>& scenes, uint8_t boundaryCount, const std::string& rawName,
const std::vector<std::pair<TestTransitionPoint, TestTransitionPoint>>& transitions) {
std::ostringstream stream;
Write<uint32_t>(stream, 41); // version
Write<uint32_t>(stream, 5); // revision
Write(stream, worldID);
Write(stream, NiPoint3(1.0f, 2.0f, 3.0f)); // spawn position
for (const float value : { 1.0f, 0.0f, 0.0f, 0.0f }) Write(stream, value); // spawn rotation
Write<uint32_t>(stream, static_cast<uint32_t>(scenes.size()));
for (const auto& scene : scenes) {
WriteString8(stream, scene.filename);
Write(stream, scene.id);
Write(stream, scene.layer);
WriteString8(stream, scene.name);
Write(stream, scene.r);
Write(stream, scene.b);
Write(stream, scene.g);
}
// Zone boundary lines: normal, point, destination zone, destination scene, spawn location (44 bytes)
Write(stream, boundaryCount);
for (uint8_t i = 0; i < boundaryCount; ++i) {
Write(stream, NiPoint3(0.0f, 0.0f, 1.0f));
Write(stream, NiPoint3(10.0f, 0.0f, static_cast<float>(i)));
Write<uint32_t>(stream, 1100);
Write<uint32_t>(stream, 2);
Write(stream, NiPoint3(5.0f, 6.0f, 7.0f));
}
WriteString8(stream, rawName);
WriteString8(stream, "Test Zone");
WriteString8(stream, "A zone for tests");
Write<uint32_t>(stream, static_cast<uint32_t>(transitions.size()));
for (const auto& [from, to] : transitions) {
for (const auto& point : { from, to }) {
Write(stream, point.sceneID);
Write(stream, point.layerID);
Write(stream, point.position);
}
}
Write<uint32_t>(stream, 8); // path data length
Write<uint32_t>(stream, 1); // path chunk version
Write<uint32_t>(stream, 0); // path count
return stream.str();
}
}

View File

@@ -0,0 +1,236 @@
#include "GameDependencies.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include "AssetManager.h"
#include "CDBrickIDTableTable.h"
#include "CDComponentsRegistryTable.h"
#include "CDZoneTableTable.h"
#include "DEVGMCommands.h"
#include "Entity.h"
#include "TerrainTestWriter.h"
#include "Zone.h"
#include "dZoneManager.h"
using namespace TerrainTestWriter;
// The zone table's storage (CDZoneTableTable.cpp), to add the test zone
namespace CDZoneTableTable {
extern Table entries;
}
// Loads a written test zone (a .luz and its .raw) through the zone manager
class ZoneSceneTest : public GameDependenciesTest {
protected:
static constexpr LWOMAPID TEST_MAP = 9990;
std::filesystem::path root;
void SetUp() override {
SetUpDependencies();
root = std::filesystem::temp_directory_path() / (std::string("dlu_zone_scene_test_") + ::testing::UnitTest::GetInstance()->current_test_info()->name());
std::filesystem::create_directories(root / "res" / "maps" / "testzone");
std::ofstream(root / "res" / "CDServer.sqlite").close(); // marks an unpacked client
Game::assetManager = new AssetManager(root);
CDZoneTable zone{};
zone.zoneID = TEST_MAP;
zone.zoneName = "testzone/test.luz";
CDZoneTableTable::entries[TEST_MAP] = zone;
}
void TearDown() override {
CDZoneTableTable::entries.erase(TEST_MAP);
delete Game::assetManager;
Game::assetManager = nullptr;
std::filesystem::remove_all(root);
TearDownDependencies();
}
void WriteFile(const std::string& name, const std::string& data) const {
std::ofstream file(root / "res" / "maps" / "testzone" / name, std::ios::binary);
file.write(data.data(), data.size());
}
// Scenes 0 (global), 1, 2 and 3 (general) and 5 (audio, layer 1); transitions 1 <-> 2 and 2 <-> 3
void WriteZone(const std::string& rawName = "test.raw", uint8_t boundaryCount = 2) const {
const std::vector<TestScene> scenes = {
{ "global.lvl", 0, 0, "Global", 10, 20, 30 },
{ "one.lvl", 1, 0, "Scene One", 255, 0, 0 },
{ "two.lvl", 2, 0, "Scene Two", 0, 255, 0 },
{ "three.lvl", 3, 0, "Scene Three", 0, 0, 255 },
{ "audio.lvl", 5, 1, "Audio", 0, 0, 0 },
};
WriteFile("test.luz", WriteLuz(TEST_MAP, scenes, boundaryCount, rawName, {
{ { 1, 0, NiPoint3(1.0f, 0.0f, 0.0f) }, { 2, 0, NiPoint3(2.0f, 0.0f, 0.0f) } },
{ { 2, 0, NiPoint3(3.0f, 0.0f, 0.0f) }, { 3, 0, NiPoint3(4.0f, 0.0f, 0.0f) } },
}));
}
// One 3x3 point chunk from (0, 0) to (4, 4) whose 2x2 scene map holds scenes 1 2 / 3 1
static std::string MakeRaw() {
TestChunk chunk;
chunk.width = 3;
chunk.height = 3;
chunk.scale = 2.0f;
chunk.colorMapResolution = 2;
chunk.sceneMap = { 1, 2, 3, 1 };
return WriteRaw({ chunk });
}
Zone* LoadZone() const {
Game::zoneManager->LoadZone(LWOZONEID(TEST_MAP, 0, 0));
Zone* zone = Game::zoneManager->GetZoneMut();
zone->LoadZoneIntoMemory(); // the levels the scenes name aren't written
Game::zoneManager->BuildSceneGraph();
return zone;
}
static bool Contains(const std::vector<LWOSCENEID>& scenes, LWOSCENEID scene) {
return std::ranges::find(scenes, scene) != scenes.end();
}
};
TEST_F(ZoneSceneTest, ReadsScenesBoundariesAndTransitions) {
WriteZone();
WriteFile("test.raw", MakeRaw());
const Zone* zone = LoadZone();
EXPECT_EQ(zone->GetWorldID(), TEST_MAP);
EXPECT_EQ(zone->GetZoneRawPath(), "test.raw");
EXPECT_EQ(zone->GetZoneName(), "Test Zone"); // read after the skipped boundary lines
EXPECT_EQ(zone->GetZonePath(), "maps/testzone/");
ASSERT_EQ(zone->GetScenes().size(), 5);
const SceneRef* one = zone->GetScene(LWOSCENEID(1, 0));
ASSERT_NE(one, nullptr);
EXPECT_EQ(one->name, "Scene One");
EXPECT_EQ(one->sceneType, eSceneType::General);
EXPECT_FLOAT_EQ(one->color.m_Red, 1.0f);
const SceneRef* global = zone->GetScene(LWOSCENEID(0, 0));
ASSERT_NE(global, nullptr);
// stored in the file as red, blue, green
EXPECT_FLOAT_EQ(global->color.m_Red, 10 / 255.0f);
EXPECT_FLOAT_EQ(global->color.m_Green, 20 / 255.0f);
EXPECT_FLOAT_EQ(global->color.m_Blue, 30 / 255.0f);
const SceneRef* audio = zone->GetScene(LWOSCENEID(5, 1));
ASSERT_NE(audio, nullptr);
EXPECT_EQ(audio->sceneType, eSceneType::Audio);
EXPECT_EQ(zone->GetScene(LWOSCENEID(4, 0)), nullptr);
const auto& transitions = zone->GetSceneTransitions();
ASSERT_EQ(transitions.size(), 2);
ASSERT_EQ(transitions[1].points.size(), 2);
EXPECT_EQ(transitions[1].points[0].sceneID, LWOSCENEID(2, 0));
EXPECT_EQ(transitions[1].points[1].sceneID, LWOSCENEID(3, 0));
EXPECT_FLOAT_EQ(transitions[1].points[1].position.x, 4.0f);
}
TEST_F(ZoneSceneTest, ZoneWithoutBoundaryLines) {
WriteZone("test.raw", 0);
WriteFile("test.raw", MakeRaw());
const Zone* zone = LoadZone();
EXPECT_EQ(zone->GetZoneName(), "Test Zone");
EXPECT_EQ(zone->GetSceneTransitions().size(), 2);
}
TEST_F(ZoneSceneTest, SceneAtPosition) {
WriteZone();
WriteFile("test.raw", MakeRaw());
LoadZone();
EXPECT_EQ(Game::zoneManager->GetSceneIDFromPosition(NiPoint3(0.5f, 100.0f, 0.5f)), LWOSCENEID(1, 0));
EXPECT_EQ(Game::zoneManager->GetSceneIDFromPosition(NiPoint3(0.5f, -50.0f, 3.5f)), LWOSCENEID(2, 0));
EXPECT_EQ(Game::zoneManager->GetSceneIDFromPosition(NiPoint3(3.5f, 0.0f, 0.5f)), LWOSCENEID(3, 0));
EXPECT_EQ(Game::zoneManager->GetSceneIDFromPosition(NiPoint3(50.0f, 0.0f, 50.0f)), LWOSCENEID(1, 0));
}
TEST_F(ZoneSceneTest, SceneGraphFromTransitions) {
WriteZone();
WriteFile("test.raw", MakeRaw());
LoadZone();
const auto& graph = Game::zoneManager->GetSceneAdjacencyList();
EXPECT_EQ(graph.size(), 4); // the audio scene is left out
EXPECT_FALSE(graph.contains(LWOSCENEID(5, 1)));
const auto one = Game::zoneManager->GetAdjacentScenes(LWOSCENEID(1, 0));
EXPECT_EQ(one.size(), 2);
EXPECT_TRUE(Contains(one, LWOSCENEID(2, 0)));
EXPECT_TRUE(Contains(one, LWOSCENEID(0, 0)));
const auto two = Game::zoneManager->GetAdjacentScenes(LWOSCENEID(2, 0));
EXPECT_EQ(two.size(), 3);
EXPECT_TRUE(Contains(two, LWOSCENEID(1, 0)));
EXPECT_TRUE(Contains(two, LWOSCENEID(3, 0)));
// the global scene is next to every other scene
const auto global = Game::zoneManager->GetAdjacentScenes(LWOSCENEID(0, 0));
EXPECT_EQ(global.size(), 3);
EXPECT_FALSE(Contains(global, LWOSCENEID(0, 0)));
EXPECT_TRUE(Game::zoneManager->GetAdjacentScenes(LWOSCENEID(9, 0)).empty());
// building again doesn't add anything twice
Game::zoneManager->BuildSceneGraph();
EXPECT_EQ(Game::zoneManager->GetAdjacentScenes(LWOSCENEID(2, 0)).size(), 3);
}
TEST_F(ZoneSceneTest, MissingRawStillLoadsTheZone) {
WriteZone("missing.raw");
const Zone* zone = LoadZone();
EXPECT_EQ(zone->GetZoneName(), "Test Zone");
EXPECT_EQ(zone->GetSceneTransitions().size(), 2);
EXPECT_TRUE(zone->GetZoneRaw().chunks.empty());
EXPECT_EQ(Game::zoneManager->GetSceneIDFromPosition(NiPoint3(0.5f, 0.0f, 0.5f)), LWOSCENEID_INVALID);
}
TEST_F(ZoneSceneTest, UnreadableRawStillLoadsTheZone) {
WriteZone();
const auto raw = MakeRaw();
WriteFile("test.raw", raw.substr(0, raw.size() / 2));
const Zone* zone = LoadZone();
EXPECT_EQ(zone->GetZoneName(), "Test Zone");
EXPECT_TRUE(zone->GetZoneRaw().chunks.empty());
EXPECT_EQ(Game::zoneManager->GetSceneIDFromPosition(NiPoint3(0.5f, 0.0f, 0.5f)), LWOSCENEID_INVALID);
}
TEST_F(ZoneSceneTest, ScenePointCommandsSpawnBricks) {
WriteZone();
WriteFile("test.raw", MakeRaw());
LoadZone();
auto& bricks = CDClientManager::GetEntriesMutable<CDBrickIDTableTable>();
bricks = { { 40, 3005 }, { 33, 3001 }, { 35, 3003 } };
// the bricks have no components, so creating them needs no client database
for (const LOT lot : { 33, 35, 40 }) CDClientManager::GetEntriesMutable<CDComponentsRegistryTable>()[lot] = 0;
EntityInfo playerInfo = info;
playerInfo.pos = NiPoint3(0.5f, 0.0f, 0.5f); // scene 1
auto player = std::make_unique<Entity>(1, playerInfo);
// Grid point (i, j) is at (2i, 2j); its scene cell is (min(i, 1), min(j, 1)), so of the 9 points scene 1 has 5,
// scene 2 has 2 ((0, 1), (0, 2)) and scene 3 has 2 ((1, 0), (2, 0))
DEVGMCommands::SpawnScenePoints(player.get(), UNASSIGNED_SYSTEM_ADDRESS, "1");
// bricks sorted by LOT: 33, 35, 40; scene 1 -> 35, scene 2 -> 40, scene 3 -> 33
const auto sceneOne = Game::entityManager->GetEntitiesByLOT(35);
EXPECT_EQ(sceneOne.size(), 5);
for (const auto* brick : sceneOne) EXPECT_EQ(Game::zoneManager->GetSceneIDFromPosition(brick->GetPosition()), LWOSCENEID(1, 0));
EXPECT_TRUE(Game::entityManager->GetEntitiesByLOT(40).empty());
// the default step of 8 only samples point (0, 0)
DEVGMCommands::SpawnAllScenePoints(player.get(), UNASSIGNED_SYSTEM_ADDRESS, "");
EXPECT_EQ(Game::entityManager->GetEntitiesByLOT(35).size(), 5 + 1);
EXPECT_TRUE(Game::entityManager->GetEntitiesByLOT(40).empty());
DEVGMCommands::SpawnAllScenePoints(player.get(), UNASSIGNED_SYSTEM_ADDRESS, "1");
EXPECT_EQ(Game::entityManager->GetEntitiesByLOT(35).size(), 6 + 5);
EXPECT_EQ(Game::entityManager->GetEntitiesByLOT(40).size(), 2);
EXPECT_EQ(Game::entityManager->GetEntitiesByLOT(33).size(), 2);
// the scene commands only report; they must not fail with or without scene data
DEVGMCommands::GetScene(player.get(), UNASSIGNED_SYSTEM_ADDRESS, "");
DEVGMCommands::GetAdjacentScenes(player.get(), UNASSIGNED_SYSTEM_ADDRESS, "");
bricks.clear();
}