From 36d6245331ab4145119122e49465fd1f02635ab5 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Fri, 16 Oct 2026 11:13:19 -0500 Subject: [PATCH] test: raw terrain reading, scene lookup, scene graph and scene commands Co-Authored-By: Claude Opus 5.5 --- tests/dGameTests/CMakeLists.txt | 3 + .../dZoneManagerTests/CMakeLists.txt | 10 + .../dGameTests/dZoneManagerTests/RawTests.cpp | 373 ++++++++++++++++++ .../dZoneManagerTests/TerrainTestWriter.h | 183 +++++++++ .../dZoneManagerTests/ZoneSceneTests.cpp | 236 +++++++++++ 5 files changed, 805 insertions(+) create mode 100644 tests/dGameTests/dZoneManagerTests/CMakeLists.txt create mode 100644 tests/dGameTests/dZoneManagerTests/RawTests.cpp create mode 100644 tests/dGameTests/dZoneManagerTests/TerrainTestWriter.h create mode 100644 tests/dGameTests/dZoneManagerTests/ZoneSceneTests.cpp diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index f4749ce8c..20ff493fb 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -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}) diff --git a/tests/dGameTests/dZoneManagerTests/CMakeLists.txt b/tests/dGameTests/dZoneManagerTests/CMakeLists.txt new file mode 100644 index 000000000..009ec7ddc --- /dev/null +++ b/tests/dGameTests/dZoneManagerTests/CMakeLists.txt @@ -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) diff --git a/tests/dGameTests/dZoneManagerTests/RawTests.cpp b/tests/dGameTests/dZoneManagerTests/RawTests.cpp new file mode 100644 index 000000000..fadac2fe8 --- /dev/null +++ b/tests/dGameTests/dZoneManagerTests/RawTests.cpp @@ -0,0 +1,373 @@ +#include + +#include +#include +#include + +#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 MakeGrid(uint32_t chunksX, uint32_t chunksZ, float originX = -8.0f, float originZ = -8.0f) { + std::vector 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(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(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{ 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(data.size())); + ASSERT_EQ(raw.chunks.size(), 1); + EXPECT_EQ(raw.chunks[0].colorMapResolution, 2); + EXPECT_EQ(raw.chunks[0].sceneMap, (std::vector{ 1, 2, 3, 4 })); + EXPECT_EQ(raw.chunks[0].shaderId, 77); + EXPECT_EQ(raw.chunks[0].textureIds, (std::vector{ 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(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(&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(&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 randomX(raw.minBoundsX - 20.0f, raw.maxBoundsX + 20.0f); + std::uniform_real_distribution 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 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); +} diff --git a/tests/dGameTests/dZoneManagerTests/TerrainTestWriter.h b/tests/dGameTests/dZoneManagerTests/TerrainTestWriter.h new file mode 100644 index 000000000..ee56a0e5c --- /dev/null +++ b/tests/dGameTests/dZoneManagerTests/TerrainTestWriter.h @@ -0,0 +1,183 @@ +#pragma once + +#include +#include +#include +#include + +#include "NiPoint3.h" + +// Writes .raw terrain and .luz zone files in the client's layout, for the zone manager tests +namespace TerrainTestWriter { + template + void Write(std::ostream& stream, const T& value) { + stream.write(reinterpret_cast(&value), sizeof(T)); + } + + inline void WriteBytes(std::ostream& stream, size_t count, uint8_t value = 0xAB) { + const std::string bytes(count, static_cast(value)); + stream.write(bytes.data(), bytes.size()); + } + + inline void WriteString8(std::ostream& stream, const std::string& value) { + Write(stream, static_cast(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 sceneMap; + // width * height heights; empty = the point index + std::vector heights; + uint32_t lightMapSize = 5; + uint32_t textureMapResolution = 2; + uint32_t blendMapSize = 7; + uint32_t flairCount = 2; + uint32_t vertSize = 4; + std::vector 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(stream, 77); // shader ID + for (uint32_t i = 0; i < 4; ++i) Write(stream, 100 + i); // texture IDs + Write(stream, chunk.scale); + for (uint32_t i = 0; i < chunk.width * chunk.height; ++i) { + Write(stream, chunk.heights.empty() ? static_cast(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(resolution) * resolution * 4); + Write(stream, chunk.lightMapSize); + WriteBytes(stream, chunk.lightMapSize); + } else { + WriteBytes(stream, static_cast(chunk.width) * chunk.width * 4); + } + + Write(stream, chunk.textureMapResolution); + WriteBytes(stream, static_cast(chunk.textureMapResolution) * chunk.textureMapResolution * 4); + if (version >= 32) { + Write(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(stream, 500 + i); + Write(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(resolution) * resolution : static_cast(resolution + 1) * (resolution + 1); + for (size_t i = 0; i < cells; ++i) Write(stream, chunk.sceneMap.empty() ? 0 : chunk.sceneMap[i]); + } else { + Write(stream, 0); + } + + if (version < 32) return; + Write(stream, chunk.vertSize); + if (chunk.vertSize == 0) return; + WriteBytes(stream, static_cast(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) * sizeof(uint16_t)); + } + } + + inline std::string WriteRaw(const std::vector& 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(stream, static_cast(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& scenes, uint8_t boundaryCount, const std::string& rawName, + const std::vector>& transitions) { + std::ostringstream stream; + Write(stream, 41); // version + Write(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(stream, static_cast(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(i))); + Write(stream, 1100); + Write(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(stream, static_cast(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(stream, 8); // path data length + Write(stream, 1); // path chunk version + Write(stream, 0); // path count + return stream.str(); + } +} diff --git a/tests/dGameTests/dZoneManagerTests/ZoneSceneTests.cpp b/tests/dGameTests/dZoneManagerTests/ZoneSceneTests.cpp new file mode 100644 index 000000000..e11f6fb05 --- /dev/null +++ b/tests/dGameTests/dZoneManagerTests/ZoneSceneTests.cpp @@ -0,0 +1,236 @@ +#include "GameDependencies.h" + +#include +#include +#include + +#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 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& 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(); + 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()[lot] = 0; + + EntityInfo playerInfo = info; + playerInfo.pos = NiPoint3(0.5f, 0.0f, 0.5f); // scene 1 + auto player = std::make_unique(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(); +}