feat: Raw reading on zone load and scene tools (#1910)

* feat: raw terrain parsing for scene data

Replace old dNavigation/dTerrain raw parser with new Raw module in
dZoneManager. Parse heightmaps, color maps, and scene maps from .raw
files to determine which scene a position belongs to. Build scene
adjacency graph from terrain data and scene transitions.

Adds NiColor type, SceneColor lookup table, eSceneType enum, terrain
mesh generation with OBJ export, and debug slash commands for scene
visualization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: deduplicate terrain grid lookup logic, remove unnecessary cast

Add IsValidForSceneLookup(), GetSceneIDAtGrid(), and GridToWorldPos()
to Raw::Chunk. Use them in GenerateTerrainMesh, GetSceneIDFromPosition,
SpawnScenePoints, and SpawnAllScenePoints instead of duplicated inline
math. Remove unnecessary static_cast in LoadSceneTransitionInfo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add version < 32 support to raw terrain parser

The parser was only correct for v32 files. For v<32:
- colorMapResolution is width-1, not width
- Color map reads width*width*4 bytes (BGRA per-pixel), not colorMapRes^2*4
- No DDS lightmap
- No texture settings byte or blend DDS
- Scene map: v31 reads (colorMapRes+1)^2 cells with border skip,
  v<31 skips 1 byte
- No mesh data

Verified against all 309 .raw files across multiple client versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: log and reject legacy raw terrain format, clean up ReadRaw API

Log a clear message and throw if the zone uses a legacy raw format
(LUZ version < 30) since no known maps use it. Remove ReadLegacyRaw
and the mapVersion parameter from ReadRaw.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: revert v33 scene field renames to unknown1/unknown2

These fields are skipped by the client in every known build (ptr += 12
and ptr += 4). No client ever reads them into a struct. Keep the
original unknown1/unknown2 names from main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: load the zone without scene lookups when its raw terrain is missing or unreadable

Log at debug when it parses; build the terrain mesh only for the OBJ export; read the zone name and description only for version 31 and later, as the client does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: look up the scene at a position as the client does, in constant time

Chunk bounds span (width - 1) * scale; the nearest scene map cell is used and the far edge is kept one cell inside. Chunks are indexed by grid cell when they form a grid. Skipping reads instead of seeking, so a cut-off file can't seek past the end. Terrain mesh indices follow the vertex order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: scene point commands take a step and use brick LOTs from the BrickIDTable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: rename NiColor::ToXYZ to Luminance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: close the OBJ file before removing it so the test passes on Windows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the zone boundary lines and read the luz scene count as the client does

Each boundary line's destination is read as a map ID and an instance ID into a LWOZONEID. The scene count is a byte before version 37 and a u32 from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: name the fourth flair colour byte padding

The client never sets or reads it, so it holds leftover memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the scene map of version 31 and older raws

These files have a (resolution + 1)^2 scene map, kept in sceneMapLegacy. Version 31 uses its first resolution rows and columns; older versions are all scene 0, as in the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: simplify the raw heightmap size check

The dimensions are capped at kMaxResolution, so the product can't overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: remove the scene point spawn commands and send /getadjacentscenes as one message

The spawn commands were only for testing. /getscene and /getadjacentscenes stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-10-27 09:22:28 -05:00
committed by GitHub
parent fc6a7fbd52
commit 9bf2ff4d44
29 changed files with 2102 additions and 315 deletions

16
dCommon/NiColor.h Normal file
View File

@@ -0,0 +1,16 @@
#ifndef NICOLOR_H
#define NICOLOR_H
struct NiColor {
float m_Red;
float m_Green;
float m_Blue;
constexpr NiColor(float red, float green, float blue) : m_Red(red), m_Green(green), m_Blue(blue) {}
constexpr NiColor() : NiColor(0.0f, 0.0f, 0.0f) {}
// Relative luminance (Rec. 709 weights) of a linear color
constexpr float Luminance() const { return (m_Red * 0.212671f) + (m_Green * 0.71516f) + (m_Blue * 0.072169f); }
};
#endif // NICOLOR_H

View File

@@ -0,0 +1,166 @@
#ifndef SCENE_COLOR_H
#define SCENE_COLOR_H
#include "NiColor.h"
#include <array>
#include <cstdint>
namespace SceneColor {
// these are not random values, they are the actual template colors used by the game
static constexpr std::array<NiColor, 146> TEMPLATE_COLORS = {{
{ 0.5019608f, 0.5019608f, 0.5019608f },
{ 1.0f, 0.0f, 0.0f },
{ 0.0f, 1.0f, 0.0f },
{ 0.0f, 0.0f, 1.0f },
{ 1.0f, 1.0f, 0.0f },
{ 1.0f, 0.0f, 1.0f },
{ 0.0f, 1.0f, 1.0f },
{ 0.5019608f, 0.0f, 1.0f },
{ 1.0f, 0.5019608f, 0.0f },
{ 1.0f, 0.5019608f, 0.5019608f },
{ 0.5019608f, 0.2509804f, 0.0f },
{ 0.5019608f, 0.0f, 0.2509804f },
{ 0.0f, 0.5019608f, 0.2509804f },
{ 0.2509804f, 0.0f, 0.5019608f },
{ 0.8745098f, 0.0f, 0.2509804f },
{ 0.2509804f, 0.8745098f, 0.5019608f },
{ 1.0f, 0.7490196f, 0.0f },
{ 1.0f, 0.2509804f, 0.0627451f },
{ 0.2509804f, 0.0f, 0.8745098f },
{ 0.7490196f, 0.0627451f, 0.0627451f },
{ 0.0627451f, 0.7490196f, 0.0627451f },
{ 1.0f, 0.5019608f, 1.0f },
{ 0.9372549f, 0.8705882f, 0.8039216f },
{ 0.8039216f, 0.5843138f, 0.4588235f },
{ 0.9921569f, 0.8509804f, 0.7098039f },
{ 0.4705882f, 0.8588235f, 0.8862745f },
{ 0.5294118f, 0.6627451f, 0.4196078f },
{ 1.0f, 0.6431373f, 0.454902f },
{ 0.9803922f, 0.9058824f, 0.7098039f },
{ 0.6235294f, 0.5058824f, 0.4392157f },
{ 0.9921569f, 0.4862745f, 0.4313726f },
{ 0.0f, 0.0f, 0.0f },
{ 0.6745098f, 0.8980392f, 0.9333333f },
{ 0.1215686f, 0.4588235f, 0.9960784f },
{ 0.6352941f, 0.6352941f, 0.8156863f },
{ 0.4f, 0.6f, 0.8f },
{ 0.05098039f, 0.5960785f, 0.7294118f },
{ 0.4509804f, 0.4f, 0.7411765f },
{ 0.8705882f, 0.3647059f, 0.5137255f },
{ 0.7960784f, 0.254902f, 0.3294118f },
{ 0.7058824f, 0.4039216f, 0.3019608f },
{ 1.0f, 0.4980392f, 0.2862745f },
{ 0.9176471f, 0.4941176f, 0.3647059f },
{ 0.6901961f, 0.7176471f, 0.7764706f },
{ 1.0f, 1.0f, 0.6f },
{ 0.1098039f, 0.827451f, 0.6352941f },
{ 1.0f, 0.6666667f, 0.8f },
{ 0.8666667f, 0.2666667f, 0.572549f },
{ 0.1137255f, 0.6745098f, 0.8392157f },
{ 0.7372549f, 0.3647059f, 0.345098f },
{ 0.8666667f, 0.5803922f, 0.4588235f },
{ 0.6039216f, 0.8078431f, 0.9215686f },
{ 1.0f, 0.7372549f, 0.8509804f },
{ 0.9921569f, 0.8588235f, 0.427451f },
{ 0.1686275f, 0.4235294f, 0.7686275f },
{ 0.9372549f, 0.8039216f, 0.7215686f },
{ 0.4313726f, 0.3176471f, 0.3764706f },
{ 0.8078431f, 1.0f, 0.1137255f },
{ 0.427451f, 0.682353f, 0.5058824f },
{ 0.7647059f, 0.3921569f, 0.772549f },
{ 0.8f, 0.4f, 0.4f },
{ 0.9058824f, 0.7764706f, 0.5921569f },
{ 0.9882353f, 0.8509804f, 0.4588235f },
{ 0.6588235f, 0.8941177f, 0.627451f },
{ 0.5843138f, 0.5686275f, 0.5490196f },
{ 0.1098039f, 0.6745098f, 0.4705882f },
{ 0.06666667f, 0.3921569f, 0.7058824f },
{ 0.9411765f, 0.9098039f, 0.5686275f },
{ 1.0f, 0.1137255f, 0.8078431f },
{ 0.6980392f, 0.9254902f, 0.3647059f },
{ 0.3647059f, 0.4627451f, 0.7960784f },
{ 0.7921569f, 0.2156863f, 0.4039216f },
{ 0.2313726f, 0.6901961f, 0.5607843f },
{ 0.9882353f, 0.7058824f, 0.8352941f },
{ 1.0f, 0.9568627f, 0.3098039f },
{ 1.0f, 0.7411765f, 0.5333334f },
{ 0.9647059f, 0.3921569f, 0.6862745f },
{ 0.6666667f, 0.9411765f, 0.8196079f },
{ 0.8039216f, 0.2901961f, 0.2980392f },
{ 0.9294118f, 0.8196079f, 0.6117647f },
{ 0.5921569f, 0.6039216f, 0.6666667f },
{ 0.7843137f, 0.2196078f, 0.3529412f },
{ 0.9372549f, 0.5960785f, 0.6666667f },
{ 0.9921569f, 0.7372549f, 0.7058824f },
{ 0.1019608f, 0.282353f, 0.4627451f },
{ 0.1882353f, 0.7294118f, 0.5607843f },
{ 0.772549f, 0.2941177f, 0.5490196f },
{ 0.09803922f, 0.454902f, 0.8235294f },
{ 0.7294118f, 0.7215686f, 0.4235294f },
{ 1.0f, 0.4588235f, 0.2196078f },
{ 1.0f, 0.1686275f, 0.1686275f },
{ 0.972549f, 0.8352941f, 0.4078431f },
{ 0.9019608f, 0.6588235f, 0.8431373f },
{ 0.254902f, 0.2901961f, 0.2980392f },
{ 1.0f, 0.4313726f, 0.2901961f },
{ 0.1098039f, 0.6627451f, 0.7882353f },
{ 1.0f, 0.8117647f, 0.6705883f },
{ 0.772549f, 0.8156863f, 0.9019608f },
{ 0.9921569f, 0.8666667f, 0.9019608f },
{ 0.08235294f, 0.5019608f, 0.4705882f },
{ 0.9882353f, 0.454902f, 0.9921569f },
{ 0.9686275f, 0.5607843f, 0.654902f },
{ 0.5568628f, 0.2705882f, 0.5215687f },
{ 0.454902f, 0.2588235f, 0.7843137f },
{ 0.6156863f, 0.5058824f, 0.7294118f },
{ 1.0f, 0.2862745f, 0.4235294f },
{ 0.8392157f, 0.5411765f, 0.3490196f },
{ 0.4431373f, 0.2941177f, 0.1372549f },
{ 1.0f, 0.282353f, 0.8156863f },
{ 0.9333333f, 0.1254902f, 0.3019608f },
{ 1.0f, 0.3254902f, 0.2862745f },
{ 0.7529412f, 0.2666667f, 0.5607843f },
{ 0.1215686f, 0.8078431f, 0.7960784f },
{ 0.4705882f, 0.3176471f, 0.6627451f },
{ 1.0f, 0.6078432f, 0.6666667f },
{ 0.9882353f, 0.1568628f, 0.2784314f },
{ 0.4627451f, 1.0f, 0.4784314f },
{ 0.6235294f, 0.8862745f, 0.7490196f },
{ 0.6470588f, 0.4117647f, 0.3098039f },
{ 0.5411765f, 0.4745098f, 0.3647059f },
{ 0.2705882f, 0.8078431f, 0.6352941f },
{ 0.8039216f, 0.772549f, 0.7607843f },
{ 0.5019608f, 0.854902f, 0.9215686f },
{ 0.9254902f, 0.9176471f, 0.7450981f },
{ 1.0f, 0.8117647f, 0.282353f },
{ 0.9921569f, 0.3686275f, 0.3254902f },
{ 0.9803922f, 0.654902f, 0.4235294f },
{ 0.09411765f, 0.654902f, 0.7098039f },
{ 0.9215686f, 0.7803922f, 0.8745098f },
{ 0.9882353f, 0.5372549f, 0.6745098f },
{ 0.8588235f, 0.8431373f, 0.8235294f },
{ 0.8705882f, 0.6666667f, 0.5333334f },
{ 0.4666667f, 0.8666667f, 0.9058824f },
{ 1.0f, 1.0f, 0.4f },
{ 0.572549f, 0.4313726f, 0.682353f },
{ 0.1960784f, 0.2901961f, 0.6980392f },
{ 0.9686275f, 0.3254902f, 0.5803922f },
{ 1.0f, 0.627451f, 0.5372549f },
{ 0.5607843f, 0.3137255f, 0.6156863f },
{ 1.0f, 1.0f, 1.0f },
{ 0.6352941f, 0.6784314f, 0.8156863f },
{ 0.9882353f, 0.4235294f, 0.5215687f },
{ 0.8039216f, 0.6431373f, 0.8705882f },
{ 0.9882353f, 0.9098039f, 0.5137255f },
{ 0.772549f, 0.8901961f, 0.5176471f },
{ 1.0f, 0.682353f, 0.2588235f },
}};
static constexpr NiColor FALLBACK_COLOR{ 1.0f, 1.0f, 1.0f };
inline const NiColor& Get(uint8_t index) {
return (index < TEMPLATE_COLORS.size()) ? TEMPLATE_COLORS[index] : FALLBACK_COLOR;
}
} // namespace SceneColor
#endif // SCENE_COLOR_H

View File

@@ -817,6 +817,25 @@ void SlashCommandHandler::Startup() {
};
RegisterCommand(ExecuteCommand);
Command GetSceneCommand{
.help = "Get the current scene ID and name at your position",
.info = "Displays the scene ID and name at the player's current position. Scenes do not care about height.",
.aliases = { "getscene", "scene" },
.handle = DEVGMCommands::GetScene,
.requiredLevel = eGameMasterLevel::DEVELOPER
};
RegisterCommand(GetSceneCommand);
Command GetAdjacentScenesCommand{
.help = "Get all scenes adjacent to your current scene",
.info = "Displays all scenes that are directly connected to the player's current scene via scene transitions.",
.aliases = { "getadjacentscenes", "adjacentscenes" },
.handle = DEVGMCommands::GetAdjacentScenes,
.requiredLevel = eGameMasterLevel::DEVELOPER
};
RegisterCommand(GetAdjacentScenesCommand);
// Register Greater Than Zero Commands
Command KickCommand{

View File

@@ -1890,4 +1890,81 @@ namespace DEVGMCommands {
}
}
}
void GetScene(Entity* entity, const SystemAddress& sysAddr, const std::string args) {
const auto position = entity->GetPosition();
// Get the scene ID from the zone manager
const auto sceneID = Game::zoneManager->GetSceneIDFromPosition(position);
if (sceneID == LWOSCENEID_INVALID) {
ChatPackets::SendSystemMessage(sysAddr, u"No scene found at current position.");
return;
}
// Get the scene reference from the zone to get the name
const auto* zone = Game::zoneManager->GetZone();
if (!zone) {
ChatPackets::SendSystemMessage(sysAddr, u"No zone loaded.");
return;
}
// Build the feedback message
std::ostringstream feedback;
feedback << "Scene ID: " << sceneID.GetSceneID();
feedback << " (Layer: " << sceneID.GetLayerID() << ")";
// Get the scene name
const auto* sceneRef = zone->GetScene(sceneID);
if (sceneRef && !sceneRef->name.empty()) {
feedback << " - Name: " << sceneRef->name;
}
ChatPackets::SendSystemMessage(sysAddr, GeneralUtils::ASCIIToUTF16(feedback.str()));
}
void GetAdjacentScenes(Entity* entity, const SystemAddress& sysAddr, const std::string args) {
const auto position = entity->GetPosition();
// Get the scene ID from the zone manager
const auto sceneID = Game::zoneManager->GetSceneIDFromPosition(position);
if (sceneID == LWOSCENEID_INVALID) {
ChatPackets::SendSystemMessage(sysAddr, u"No scene found at current position.");
return;
}
// Get the zone reference
const auto* zone = Game::zoneManager->GetZone();
if (!zone) {
ChatPackets::SendSystemMessage(sysAddr, u"No zone loaded.");
return;
}
// Get current scene info
const auto* currentScene = zone->GetScene(sceneID);
std::string currentSceneName = currentScene && !currentScene->name.empty() ? currentScene->name : "Unknown";
// Get adjacent scenes
const auto adjacentSceneIDs = Game::zoneManager->GetAdjacentScenes(sceneID);
std::ostringstream feedback;
feedback << "Current Scene: " << sceneID.GetSceneID() << " (" << currentSceneName << ")";
if (adjacentSceneIDs.empty()) {
feedback << " - No adjacent scenes found.";
} else {
feedback << "\nAdjacent Scenes (" << adjacentSceneIDs.size() << "):";
for (const auto& adjSceneID : adjacentSceneIDs) {
feedback << "\n - Scene ID: " << adjSceneID.GetSceneID() << " (Layer: " << adjSceneID.GetLayerID() << ")";
// Get the scene name if available
const auto* sceneRef = zone->GetScene(adjSceneID);
if (sceneRef && !sceneRef->name.empty()) {
feedback << " - " << sceneRef->name;
}
}
}
ChatPackets::SendSystemMessage(sysAddr, GeneralUtils::ASCIIToUTF16(feedback.str()));
}
};

View File

@@ -77,6 +77,8 @@ namespace DEVGMCommands {
void Barfight(Entity* entity, const SystemAddress& sysAddr, const std::string args);
void Despawn(Entity* entity, const SystemAddress& sysAddr, const std::string args);
void Execute(Entity* entity, const SystemAddress& sysAddr, const std::string args);
void GetScene(Entity* entity, const SystemAddress& sysAddr, const std::string args);
void GetAdjacentScenes(Entity* entity, const SystemAddress& sysAddr, const std::string args);
}
#endif //!DEVGMCOMMANDS_H

View File

@@ -1,11 +1,5 @@
set(DNAVIGATION_SOURCES "dNavMesh.cpp")
add_subdirectory(dTerrain)
foreach(file ${DNAVIGATIONS_DTERRAIN_SOURCES})
set(DNAVIGATION_SOURCES ${DNAVIGATION_SOURCES} "dTerrain/${file}")
endforeach()
add_library(dNavigation OBJECT ${DNAVIGATION_SOURCES})
target_include_directories(dNavigation PUBLIC "."
PRIVATE

View File

@@ -1,6 +1,5 @@
#include "dNavMesh.h"
#include "RawFile.h"
#include "Game.h"
#include "Logger.h"

View File

@@ -1,3 +0,0 @@
set(DNAVIGATIONS_DTERRAIN_SOURCES "RawFile.cpp"
"RawChunk.cpp"
"RawHeightMap.cpp" PARENT_SCOPE)

View File

@@ -1,93 +0,0 @@
#include "RawChunk.h"
#include "BinaryIO.h"
#include "RawMesh.h"
#include "RawHeightMap.h"
RawChunk::RawChunk(std::ifstream& stream) {
// Read the chunk index and info
BinaryIO::BinaryRead(stream, m_ChunkIndex);
BinaryIO::BinaryRead(stream, m_Width);
BinaryIO::BinaryRead(stream, m_Height);
BinaryIO::BinaryRead(stream, m_X);
BinaryIO::BinaryRead(stream, m_Z);
m_HeightMap = new RawHeightMap(stream, m_Height, m_Width);
// We can just skip the rest of the data so we can read the next chunks, we don't need anymore data
// Possible overflow here? TODO make reasonable upper bound or confirm big numbers arent necessary to have
uint32_t colorMapSize;
BinaryIO::BinaryRead(stream, colorMapSize);
stream.seekg(static_cast<uint32_t>(stream.tellg()) + (colorMapSize * colorMapSize * 4));
uint32_t lightmapSize;
BinaryIO::BinaryRead(stream, lightmapSize);
stream.seekg(static_cast<uint32_t>(stream.tellg()) + (lightmapSize));
uint32_t colorMapSize2;
BinaryIO::BinaryRead(stream, colorMapSize2);
stream.seekg(static_cast<uint32_t>(stream.tellg()) + (colorMapSize2 * colorMapSize2 * 4));
uint8_t unknown;
BinaryIO::BinaryRead(stream, unknown);
uint32_t blendmapSize;
BinaryIO::BinaryRead(stream, blendmapSize);
stream.seekg(static_cast<uint32_t>(stream.tellg()) + (blendmapSize));
uint32_t pointSize;
BinaryIO::BinaryRead(stream, pointSize);
stream.seekg(static_cast<uint32_t>(stream.tellg()) + (pointSize * 9 * 4));
stream.seekg(static_cast<uint32_t>(stream.tellg()) + (colorMapSize * colorMapSize));
uint32_t endCounter;
BinaryIO::BinaryRead(stream, endCounter);
stream.seekg(static_cast<uint32_t>(stream.tellg()) + (endCounter * 2));
if (endCounter != 0) {
stream.seekg(static_cast<uint32_t>(stream.tellg()) + (32));
for (int i = 0; i < 0x10; i++) {
uint16_t finalCountdown;
BinaryIO::BinaryRead(stream, finalCountdown);
stream.seekg(static_cast<uint32_t>(stream.tellg()) + (finalCountdown * 2));
}
}
// Generate our mesh/geo data for this chunk
this->GenerateMesh();
}
RawChunk::~RawChunk() {
if (m_Mesh) delete m_Mesh;
if (m_HeightMap) delete m_HeightMap;
}
void RawChunk::GenerateMesh() {
RawMesh* meshData = new RawMesh();
for (int i = 0; i < m_Width; ++i) {
for (int j = 0; j < m_Height; ++j) {
float y = *std::next(m_HeightMap->m_FloatMap.begin(), m_Width * i + j);
meshData->m_Vertices.push_back(NiPoint3(i, y, j));
if (i == 0 || j == 0) continue;
meshData->m_Triangles.push_back(m_Width * i + j);
meshData->m_Triangles.push_back(m_Width * i + j - 1);
meshData->m_Triangles.push_back(m_Width * (i - 1) + j - 1);
meshData->m_Triangles.push_back(m_Width * (i - 1) + j - 1);
meshData->m_Triangles.push_back(m_Width * (i - 1) + j);
meshData->m_Triangles.push_back(m_Width * i + j);
}
}
m_Mesh = meshData;
}

View File

@@ -1,24 +0,0 @@
#pragma once
#include <cstdint>
#include <fstream>
struct RawMesh;
class RawHeightMap;
class RawChunk {
public:
RawChunk(std::ifstream& stream);
~RawChunk();
void GenerateMesh();
uint32_t m_ChunkIndex;
uint32_t m_Width;
uint32_t m_Height;
float m_X;
float m_Z;
RawHeightMap* m_HeightMap;
RawMesh* m_Mesh;
};

View File

@@ -1,84 +0,0 @@
#include "RawFile.h"
#include "BinaryIO.h"
#include "RawChunk.h"
#include "RawMesh.h"
#include "RawHeightMap.h"
RawFile::RawFile(std::string fileName) {
if (!BinaryIO::DoesFileExist(fileName)) return;
std::ifstream file(fileName, std::ios::binary);
// Read header
BinaryIO::BinaryRead(file, m_Version);
BinaryIO::BinaryRead(file, m_Padding);
BinaryIO::BinaryRead(file, m_ChunkCount);
BinaryIO::BinaryRead(file, m_Width);
BinaryIO::BinaryRead(file, m_Height);
if (m_Version < 0x20) {
return; // Version is too old to be supported
}
// Read in chunks
m_Chunks = {};
for (uint32_t i = 0; i < m_ChunkCount; i++) {
RawChunk* chunk = new RawChunk(file);
m_Chunks.push_back(chunk);
}
m_FinalMesh = new RawMesh();
this->GenerateFinalMeshFromChunks();
}
RawFile::~RawFile() {
if (m_FinalMesh) delete m_FinalMesh;
for (const auto* item : m_Chunks) {
if (item) delete item;
}
}
void RawFile::GenerateFinalMeshFromChunks() {
uint32_t lenOfLastChunk = 0; // index of last vert set in the last chunk
for (const auto& chunk : m_Chunks) {
for (const auto& vert : chunk->m_Mesh->m_Vertices) {
auto tempVert = vert;
// Scale X and Z by the chunk's position in the world
// Scale Y by the chunk's heightmap scale factor
tempVert.SetX(tempVert.GetX() + (chunk->m_X / chunk->m_HeightMap->m_ScaleFactor));
tempVert.SetY(tempVert.GetY() / chunk->m_HeightMap->m_ScaleFactor);
tempVert.SetZ(tempVert.GetZ() + (chunk->m_Z / chunk->m_HeightMap->m_ScaleFactor));
// Then scale it again for some reason
tempVert *= chunk->m_HeightMap->m_ScaleFactor;
m_FinalMesh->m_Vertices.push_back(tempVert);
}
for (const auto& tri : chunk->m_Mesh->m_Triangles) {
m_FinalMesh->m_Triangles.push_back(tri + lenOfLastChunk);
}
lenOfLastChunk += chunk->m_Mesh->m_Vertices.size();
}
}
void RawFile::WriteFinalMeshToOBJ(std::string path) {
std::ofstream file(path);
for (const auto& v : m_FinalMesh->m_Vertices) {
file << "v " << v.x << ' ' << v.y << ' ' << v.z << '\n';
}
for (int i = 0; i < m_FinalMesh->m_Triangles.size(); i += 3) {
file << "f " << *std::next(m_FinalMesh->m_Triangles.begin(), i) + 1 << ' ' << *std::next(m_FinalMesh->m_Triangles.begin(), i + 1) + 1 << ' ' << *std::next(m_FinalMesh->m_Triangles.begin(), i + 2) + 1 << '\n';
}
}

View File

@@ -1,28 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
class RawChunk;
struct RawMesh;
class RawFile {
public:
RawFile(std::string filePath);
~RawFile();
private:
void GenerateFinalMeshFromChunks();
void WriteFinalMeshToOBJ(std::string path);
uint8_t m_Version;
uint16_t m_Padding;
uint32_t m_ChunkCount;
uint32_t m_Width;
uint32_t m_Height;
std::vector<RawChunk*> m_Chunks;
RawMesh* m_FinalMesh = nullptr;
};

View File

@@ -1,27 +0,0 @@
#include "RawHeightMap.h"
#include "BinaryIO.h"
RawHeightMap::RawHeightMap() {}
RawHeightMap::RawHeightMap(std::ifstream& stream, float height, float width) {
// Read in height map data header and scale
BinaryIO::BinaryRead(stream, m_Unknown1);
BinaryIO::BinaryRead(stream, m_Unknown2);
BinaryIO::BinaryRead(stream, m_Unknown3);
BinaryIO::BinaryRead(stream, m_Unknown4);
BinaryIO::BinaryRead(stream, m_ScaleFactor);
// read all vertices in
for (uint64_t i = 0; i < width * height; i++) {
float value;
BinaryIO::BinaryRead(stream, value);
m_FloatMap.push_back(value);
}
}
RawHeightMap::~RawHeightMap() {
}

View File

@@ -1,21 +0,0 @@
#pragma once
#include <cstdint>
#include <vector>
#include <fstream>
class RawHeightMap {
public:
RawHeightMap();
RawHeightMap(std::ifstream& stream, float height, float width);
~RawHeightMap();
uint32_t m_Unknown1;
uint32_t m_Unknown2;
uint32_t m_Unknown3;
uint32_t m_Unknown4;
float m_ScaleFactor;
std::vector<float> m_FloatMap = {};
};

View File

@@ -1,10 +0,0 @@
#pragma once
#include <vector>
#include "NiPoint3.h"
struct RawMesh {
std::vector<NiPoint3> m_Vertices;
std::vector<uint32_t> m_Triangles;
};

View File

@@ -1,5 +1,6 @@
set(DZONEMANAGER_SOURCES "dZoneManager.cpp"
"Level.cpp"
"Raw.cpp"
"Spawner.cpp"
"Zone.cpp")
@@ -14,6 +15,7 @@ target_include_directories(dZoneManager PUBLIC "."
"${PROJECT_SOURCE_DIR}/dGame" # Entity.h
"${PROJECT_SOURCE_DIR}/dGame/dEntity" # EntityInfo.h
PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon/dClient" # SceneColor.h
"${PROJECT_SOURCE_DIR}/dGame/dComponents" #InventoryComponent.h
"${PROJECT_SOURCE_DIR}/dGame/dInventory" #InventoryComponent.h (transitive)
"${PROJECT_SOURCE_DIR}/dGame/dBehaviors" #BehaviorSlot.h

573
dZoneManager/Raw.cpp Normal file
View File

@@ -0,0 +1,573 @@
#include "Raw.h"
#include "BinaryIO.h"
#include "Logger.h"
#include "SceneColor.h"
#include <fstream>
#include <algorithm>
#include <limits>
#include <cmath>
namespace {
constexpr uint32_t kMaxResolution = 4096;
constexpr size_t kMaxBlobBytes = 64ULL * 1024 * 1024; // 64 MiB
constexpr uint32_t kMaxChunks = 1024;
} // namespace
namespace Raw {
bool Chunk::IsValidForSceneLookup() const {
return !sceneMap.empty() && colorMapResolution > 0 && !heightMap.empty()
&& scaleFactor > 0.0f && width > 1 && height > 1;
}
uint8_t Chunk::GetSceneIDAtLocal(float localX, float localZ) const {
// The nearest scene map cell
const auto toCell = [this](float cell) {
return static_cast<uint32_t>(std::clamp(static_cast<int32_t>(std::floor(cell + 0.5f)), 0, static_cast<int32_t>(colorMapResolution) - 1));
};
const uint32_t sceneIndex = toCell(sceneCellsPerUnitX * localX) * colorMapResolution + toCell(sceneCellsPerUnitZ * localZ);
if (sceneIndex >= sceneMap.size()) return 0;
return sceneMap[sceneIndex];
}
uint8_t Chunk::GetSceneIDAtGrid(uint32_t i, uint32_t j) const {
return GetSceneIDAtLocal(static_cast<float>(i) * scaleFactor, static_cast<float>(j) * scaleFactor);
}
NiPoint3 Chunk::GridToWorldPos(uint32_t i, uint32_t j) const {
const uint32_t heightIndex = i * height + j;
const float y = heightIndex < heightMap.size() ? heightMap[heightIndex] : 0.0f;
return NiPoint3(offsetX + static_cast<float>(i) * scaleFactor, y, offsetZ + static_cast<float>(j) * scaleFactor);
}
/**
* @brief Read flair attributes from stream
*/
static bool ReadFlairAttributes(std::istream& stream, FlairAttributes& flair) {
try {
BinaryIO::BinaryRead(stream, flair.id);
BinaryIO::BinaryRead(stream, flair.scaleFactor);
BinaryIO::BinaryRead(stream, flair.position.x);
BinaryIO::BinaryRead(stream, flair.position.y);
BinaryIO::BinaryRead(stream, flair.position.z);
BinaryIO::BinaryRead(stream, flair.rotation.x);
BinaryIO::BinaryRead(stream, flair.rotation.y);
BinaryIO::BinaryRead(stream, flair.rotation.z);
BinaryIO::BinaryRead(stream, flair.colorR);
BinaryIO::BinaryRead(stream, flair.colorG);
BinaryIO::BinaryRead(stream, flair.colorB);
BinaryIO::BinaryRead(stream, flair.padding);
return true;
} catch (const std::exception&) {
return false;
}
}
/**
* @brief Read mesh triangle data from stream
*/
static bool ReadMeshTri(std::istream& stream, MeshTri& meshTri) {
try {
BinaryIO::BinaryRead(stream, meshTri.meshTriListSize);
meshTri.meshTriList.resize(meshTri.meshTriListSize);
for (uint16_t i = 0; i < meshTri.meshTriListSize; ++i) {
BinaryIO::BinaryRead(stream, meshTri.meshTriList[i]);
}
return true;
} catch (const std::exception&) {
return false;
}
}
/**
* @brief Read a chunk from stream
*/
static bool ReadChunkData(std::istream& stream, Chunk& chunk, uint16_t version) {
try {
// Read basic chunk info
BinaryIO::BinaryRead(stream, chunk.id);
if (stream.fail()) {
return false;
}
BinaryIO::BinaryRead(stream, chunk.width);
BinaryIO::BinaryRead(stream, chunk.height);
BinaryIO::BinaryRead(stream, chunk.offsetX);
BinaryIO::BinaryRead(stream, chunk.offsetZ);
if (stream.fail()) {
return false;
}
// For version < 32, shader ID comes before texture IDs
if (version < 32) {
BinaryIO::BinaryRead(stream, chunk.shaderId);
}
// Read texture IDs (4 textures)
chunk.textureIds.resize(4);
for (int i = 0; i < 4; ++i) {
BinaryIO::BinaryRead(stream, chunk.textureIds[i]);
}
if (stream.fail()) {
return false;
}
// Read scale factor
BinaryIO::BinaryRead(stream, chunk.scaleFactor);
if (stream.fail()) {
return false;
}
// Read heightmap: width * height floats, at most kMaxResolution^2 (64 MiB)
if (chunk.width == 0 || chunk.height == 0 || chunk.width > kMaxResolution || chunk.height > kMaxResolution) {
LOG("Chunk %u has invalid heightmap dimensions: width=%u, height=%u (max %u)", chunk.id, chunk.width, chunk.height, kMaxResolution);
return false;
}
const size_t heightMapSize = static_cast<size_t>(chunk.width) * chunk.height;
chunk.heightMap.resize(heightMapSize);
for (size_t i = 0; i < heightMapSize; ++i) {
BinaryIO::BinaryRead(stream, chunk.heightMap[i]);
}
if (stream.fail()) {
return false;
}
// ColorMap
if (version >= 32) {
BinaryIO::BinaryRead(stream, chunk.colorMapResolution);
} else {
chunk.colorMapResolution = chunk.width - 1;
}
if (chunk.colorMapResolution > kMaxResolution) {
LOG("Chunk colorMapResolution %u exceeds maximum %u", chunk.colorMapResolution, kMaxResolution);
return false;
}
if (version >= 32) {
const size_t colorMapPixelCount = static_cast<size_t>(chunk.colorMapResolution) * chunk.colorMapResolution * 4;
if (colorMapPixelCount > kMaxBlobBytes) {
LOG("Chunk colorMap size %zu exceeds maximum %zu bytes", colorMapPixelCount, kMaxBlobBytes);
return false;
}
chunk.colorMap.resize(colorMapPixelCount);
stream.read(reinterpret_cast<char*>(chunk.colorMap.data()), static_cast<std::streamsize>(colorMapPixelCount));
} else {
const size_t legacyColorBytes = static_cast<size_t>(chunk.width) * chunk.width * 4;
if (legacyColorBytes > kMaxBlobBytes) {
LOG("Chunk legacy colorMap size %zu exceeds maximum %zu bytes", legacyColorBytes, kMaxBlobBytes);
return false;
}
chunk.colorMap.resize(legacyColorBytes);
stream.read(reinterpret_cast<char*>(chunk.colorMap.data()), static_cast<std::streamsize>(legacyColorBytes));
}
if (stream.fail()) {
return false;
}
// LightMap/diffusemap.dds (v>=32 only)
if (version >= 32) {
uint32_t lightMapSize;
BinaryIO::BinaryRead(stream, lightMapSize);
if (lightMapSize > kMaxBlobBytes) {
LOG("Chunk lightMap size %u exceeds maximum %zu bytes", lightMapSize, kMaxBlobBytes);
return false;
}
chunk.lightMap.resize(lightMapSize);
stream.read(reinterpret_cast<char*>(chunk.lightMap.data()), static_cast<std::streamsize>(lightMapSize));
if (stream.fail()) {
return false;
}
}
// Blend/texture map
BinaryIO::BinaryRead(stream, chunk.textureMapResolution);
if (chunk.textureMapResolution > kMaxResolution) {
LOG("Chunk textureMapResolution %u exceeds maximum %u", chunk.textureMapResolution, kMaxResolution);
return false;
}
const size_t textureMapPixelCount = static_cast<size_t>(chunk.textureMapResolution) * chunk.textureMapResolution * 4;
if (textureMapPixelCount > kMaxBlobBytes) {
LOG("Chunk textureMap size %zu exceeds maximum %zu bytes", textureMapPixelCount, kMaxBlobBytes);
return false;
}
chunk.textureMap.resize(textureMapPixelCount);
stream.read(reinterpret_cast<char*>(chunk.textureMap.data()), static_cast<std::streamsize>(textureMapPixelCount));
if (stream.fail()) {
return false;
}
// Texture settings + blend map DDS (v>=32 only)
if (version >= 32) {
BinaryIO::BinaryRead(stream, chunk.textureSettings);
uint32_t blendMapDDSSize;
BinaryIO::BinaryRead(stream, blendMapDDSSize);
if (blendMapDDSSize > kMaxBlobBytes) {
LOG("Chunk blendMap size %u exceeds maximum %zu bytes", blendMapDDSSize, kMaxBlobBytes);
return false;
}
chunk.blendMap.resize(blendMapDDSSize);
stream.read(reinterpret_cast<char*>(chunk.blendMap.data()), static_cast<std::streamsize>(blendMapDDSSize));
if (stream.fail()) {
return false;
}
}
// Read flairs
uint32_t numFlairs;
BinaryIO::BinaryRead(stream, numFlairs);
if (stream.fail()) {
return false;
}
const size_t flairBytes = static_cast<size_t>(numFlairs) * sizeof(FlairAttributes);
if (flairBytes > kMaxBlobBytes) {
LOG("Chunk %u flair count %u exceeds maximum (byte size %zu > %zu)", chunk.id, numFlairs, flairBytes, kMaxBlobBytes);
return false;
}
chunk.flairs.resize(numFlairs);
for (uint32_t i = 0; i < numFlairs; ++i) {
if (!ReadFlairAttributes(stream, chunk.flairs[i])) {
return false;
}
}
// Scene map
if (version >= 32) {
const size_t sceneMapSize = static_cast<size_t>(chunk.colorMapResolution) * chunk.colorMapResolution;
if (sceneMapSize > kMaxBlobBytes) {
LOG("Chunk sceneMap size %zu exceeds maximum %zu bytes", sceneMapSize, kMaxBlobBytes);
return false;
}
chunk.sceneMap.resize(sceneMapSize);
stream.read(reinterpret_cast<char*>(chunk.sceneMap.data()), static_cast<std::streamsize>(sceneMapSize));
} else {
const size_t sceneMapCells = static_cast<size_t>(chunk.colorMapResolution + 1) * (chunk.colorMapResolution + 1);
if (sceneMapCells > kMaxBlobBytes) {
LOG("Chunk legacy sceneMap size %zu exceeds maximum %zu bytes", sceneMapCells, kMaxBlobBytes);
return false;
}
chunk.sceneMapLegacy.resize(sceneMapCells);
stream.read(reinterpret_cast<char*>(chunk.sceneMapLegacy.data()), static_cast<std::streamsize>(sceneMapCells));
// Like the client: version 31 uses the first colorMapResolution rows and columns, older versions are all scene 0
chunk.sceneMap.assign(static_cast<size_t>(chunk.colorMapResolution) * chunk.colorMapResolution, 0);
if (version == 31) {
for (uint32_t row = 0; row < chunk.colorMapResolution; ++row) {
for (uint32_t col = 0; col < chunk.colorMapResolution; ++col) {
chunk.sceneMap[row * chunk.colorMapResolution + col] = chunk.sceneMapLegacy[row * (chunk.colorMapResolution + 1) + col];
}
}
}
}
if (stream.fail()) {
return false;
}
// Mesh data (v>=32 only)
if (version < 32) {
return true;
}
BinaryIO::BinaryRead(stream, chunk.vertSize);
if (stream.fail()) {
return false;
}
if (chunk.vertSize == 0) {
return true;
}
const size_t vertBytes = static_cast<size_t>(chunk.vertSize) * sizeof(uint16_t);
if (vertBytes > kMaxBlobBytes) {
LOG("Chunk %u vertSize %u exceeds maximum (byte size %zu > %zu)", chunk.id, chunk.vertSize, vertBytes, kMaxBlobBytes);
return false;
}
chunk.meshVertUsage.resize(chunk.vertSize);
for (uint32_t i = 0; i < chunk.vertSize; ++i) {
BinaryIO::BinaryRead(stream, chunk.meshVertUsage[i]);
}
if (stream.fail()) {
return false;
}
chunk.meshVertSize.resize(16);
for (int i = 0; i < 16; ++i) {
BinaryIO::BinaryRead(stream, chunk.meshVertSize[i]);
}
if (stream.fail()) {
return false;
}
chunk.meshTri.resize(16);
for (int i = 0; i < 16; ++i) {
if (!ReadMeshTri(stream, chunk.meshTri[i])) {
return false;
}
}
return true;
} catch (const std::exception&) {
return false;
}
}
/**
* @brief Read a chunk and work out its scene map cell sizes
*/
static bool ReadChunk(std::istream& stream, Chunk& chunk, uint16_t version) {
if (!ReadChunkData(stream, chunk, version)) return false;
if (chunk.IsValidForSceneLookup()) {
chunk.sceneCellSizeX = static_cast<float>(chunk.width - 1) * chunk.scaleFactor / static_cast<float>(chunk.colorMapResolution);
chunk.sceneCellSizeZ = static_cast<float>(chunk.height - 1) * chunk.scaleFactor / static_cast<float>(chunk.colorMapResolution);
chunk.sceneCellsPerUnitX = 1.0f / chunk.sceneCellSizeX;
chunk.sceneCellsPerUnitZ = 1.0f / chunk.sceneCellSizeZ;
}
return true;
}
/**
* @brief Index the chunks by grid cell when they form a regular grid of equal chunks
*/
static void BuildChunkGrid(Raw& raw) {
raw.chunkGrid.clear();
const auto& first = raw.chunks.front();
const float spanX = first.GetMaxX() - first.offsetX;
const float spanZ = first.GetMaxZ() - first.offsetZ;
if (spanX <= 0.0f || spanZ <= 0.0f) return;
const auto countX = static_cast<uint32_t>(std::lround((raw.maxBoundsX - raw.minBoundsX) / spanX));
const auto countZ = static_cast<uint32_t>(std::lround((raw.maxBoundsZ - raw.minBoundsZ) / spanZ));
if (countX == 0 || countZ == 0 || static_cast<size_t>(countX) * countZ > kMaxChunks) return;
std::vector<int32_t> grid(static_cast<size_t>(countX) * countZ, -1);
for (size_t i = 0; i < raw.chunks.size(); ++i) {
const auto& chunk = raw.chunks[i];
const float cellX = (chunk.offsetX - raw.minBoundsX) / spanX;
const float cellZ = (chunk.offsetZ - raw.minBoundsZ) / spanZ;
const long x = std::lround(cellX);
const long z = std::lround(cellZ);
const bool sameSize = chunk.width == first.width && chunk.height == first.height && chunk.scaleFactor == first.scaleFactor;
if (!sameSize || std::abs(cellX - x) > 0.01f || std::abs(cellZ - z) > 0.01f || x < 0 || z < 0
|| x >= static_cast<long>(countX) || z >= static_cast<long>(countZ)) {
LOG("Raw chunks are not a regular grid (chunk %u); scene lookups check every chunk", chunk.id);
return;
}
auto& cell = grid[static_cast<size_t>(x) * countZ + z];
if (cell == -1) cell = static_cast<int32_t>(i); // the client takes the first chunk that matches
}
raw.chunkGrid = std::move(grid);
raw.chunkGridCountX = countX;
raw.chunkGridCountZ = countZ;
raw.chunkSpanX = spanX;
raw.chunkSpanZ = spanZ;
}
bool ReadRaw(std::istream& stream, Raw& outRaw) {
// Get stream size
stream.seekg(0, std::ios::end);
auto streamSize = stream.tellg();
stream.seekg(0, std::ios::beg);
if (streamSize <= 0) {
return false;
}
try {
// Read header
BinaryIO::BinaryRead(stream, outRaw.version);
if (stream.fail()) {
return false;
}
BinaryIO::BinaryRead(stream, outRaw.dev);
if (stream.fail()) {
return false;
}
// Only read chunks if dev == 0
if (outRaw.dev == 0) {
BinaryIO::BinaryRead(stream, outRaw.numChunks);
BinaryIO::BinaryRead(stream, outRaw.numChunksWidth);
BinaryIO::BinaryRead(stream, outRaw.numChunksHeight);
if (outRaw.numChunks > kMaxChunks) {
LOG("Raw numChunks %u exceeds maximum %u", outRaw.numChunks, kMaxChunks);
return false;
}
// Read all chunks
outRaw.chunks.resize(outRaw.numChunks);
for (uint32_t i = 0; i < outRaw.numChunks; ++i) {
if (!ReadChunk(stream, outRaw.chunks[i], outRaw.version)) {
return false;
}
}
// Calculate terrain bounds from all chunks
if (!outRaw.chunks.empty()) {
outRaw.minBoundsX = std::numeric_limits<float>::max();
outRaw.minBoundsZ = std::numeric_limits<float>::max();
outRaw.maxBoundsX = std::numeric_limits<float>::lowest();
outRaw.maxBoundsZ = std::numeric_limits<float>::lowest();
for (const auto& chunk : outRaw.chunks) {
outRaw.minBoundsX = std::min(outRaw.minBoundsX, chunk.offsetX);
outRaw.minBoundsZ = std::min(outRaw.minBoundsZ, chunk.offsetZ);
outRaw.maxBoundsX = std::max(outRaw.maxBoundsX, chunk.GetMaxX());
outRaw.maxBoundsZ = std::max(outRaw.maxBoundsZ, chunk.GetMaxZ());
}
LOG_DEBUG("Raw terrain bounds: X[%.2f, %.2f], Z[%.2f, %.2f]",
outRaw.minBoundsX, outRaw.maxBoundsX, outRaw.minBoundsZ, outRaw.maxBoundsZ);
BuildChunkGrid(outRaw);
}
}
return true;
} catch (const std::exception&) {
return false;
}
}
// The client's TerrainManager::GetSceneAtPos: clamp to the terrain bounds, keep one scene cell inside the far edge,
// find the chunk containing the position (edges moved in by 0.001) and take its nearest scene map cell
std::optional<uint8_t> Raw::GetSceneIDAt(float x, float z) const {
if (chunks.empty()) return std::nullopt;
constexpr float kEdgeEpsilon = 0.001f;
const float posX = std::clamp(x, minBoundsX, maxBoundsX);
const float posZ = std::clamp(z, minBoundsZ, maxBoundsZ);
const auto sceneInChunk = [&](const Chunk& chunk) -> std::optional<uint8_t> {
if (!chunk.IsValidForSceneLookup()) return std::nullopt;
const float chunkX = std::min(posX, maxBoundsX - chunk.sceneCellSizeX);
const float chunkZ = std::min(posZ, maxBoundsZ - chunk.sceneCellSizeZ);
if (chunk.offsetX - kEdgeEpsilon <= chunkX && chunkX < chunk.GetMaxX() - kEdgeEpsilon &&
chunk.offsetZ - kEdgeEpsilon <= chunkZ && chunkZ < chunk.GetMaxZ() - kEdgeEpsilon) {
return chunk.GetSceneIDAtLocal(chunkX - chunk.offsetX, chunkZ - chunk.offsetZ);
}
return std::nullopt;
};
if (chunkGrid.empty()) {
for (const auto& chunk : chunks) {
if (const auto sceneID = sceneInChunk(chunk)) return sceneID;
}
return std::nullopt;
}
// The grid cell the position falls in, checked with the client's test; for a position on a float edge the
// neighbouring cells are checked too
const auto toCell = [](float pos, float min, float span, uint32_t count) {
const auto cell = static_cast<int32_t>(std::floor((pos + kEdgeEpsilon - min) / span));
return std::clamp(cell, 0, static_cast<int32_t>(count) - 1);
};
const int32_t cellX = toCell(posX, minBoundsX, chunkSpanX, chunkGridCountX);
const int32_t cellZ = toCell(posZ, minBoundsZ, chunkSpanZ, chunkGridCountZ);
for (const int32_t offsetX : { 0, -1, 1 }) {
for (const int32_t offsetZ : { 0, -1, 1 }) {
const int32_t x = cellX + offsetX;
const int32_t z = cellZ + offsetZ;
if (x < 0 || z < 0 || x >= static_cast<int32_t>(chunkGridCountX) || z >= static_cast<int32_t>(chunkGridCountZ)) continue;
const int32_t index = chunkGrid[static_cast<size_t>(x) * chunkGridCountZ + z];
if (index < 0) continue;
if (const auto sceneID = sceneInChunk(chunks[index])) return sceneID;
}
}
return std::nullopt;
}
void GenerateTerrainMesh(const Raw& raw, TerrainMesh& outMesh) {
outMesh.vertices.clear();
outMesh.triangles.clear();
if (raw.chunks.empty() || raw.version < 32) {
return; // No scene data available
}
LOG_DEBUG("GenerateTerrainMesh: Processing %zu chunks", raw.chunks.size());
uint32_t vertexOffset = 0;
for (const auto& chunk : raw.chunks) {
if (!chunk.IsValidForSceneLookup()) continue;
// One vertex per heightmap point, in [i * height + j] order
for (uint32_t i = 0; i < chunk.width; ++i) {
for (uint32_t j = 0; j < chunk.height; ++j) {
outMesh.vertices.emplace_back(chunk.GridToWorldPos(i, j), chunk.GetSceneIDAtGrid(i, j));
if (i > 0 && j > 0) {
const uint32_t currentVert = vertexOffset + chunk.height * i + j;
const uint32_t leftVert = currentVert - 1;
const uint32_t bottomLeftVert = vertexOffset + chunk.height * (i - 1) + j - 1;
const uint32_t bottomVert = vertexOffset + chunk.height * (i - 1) + j;
// First triangle
outMesh.triangles.push_back(currentVert);
outMesh.triangles.push_back(leftVert);
outMesh.triangles.push_back(bottomLeftVert);
// Second triangle
outMesh.triangles.push_back(bottomLeftVert);
outMesh.triangles.push_back(bottomVert);
outMesh.triangles.push_back(currentVert);
}
}
}
vertexOffset += chunk.width * chunk.height;
}
}
bool WriteTerrainMeshToOBJ(const TerrainMesh& mesh, const std::string& path) {
try {
std::ofstream file(path);
if (!file.is_open()) {
LOG("Failed to open OBJ file for writing: %s", path.c_str());
return false;
}
for (const auto& v : mesh.vertices) {
const NiColor& color = SceneColor::Get(v.sceneID);
file << "v " << v.position.x << ' ' << v.position.y << ' ' << v.position.z
<< ' ' << color.m_Red << ' ' << color.m_Green << ' ' << color.m_Blue << '\n';
}
for (size_t i = 0; i < mesh.triangles.size(); i += 3) {
file << "f " << (mesh.triangles[i] + 1) << ' '
<< (mesh.triangles[i + 1] + 1) << ' '
<< (mesh.triangles[i + 2] + 1) << '\n';
}
file.close();
LOG("Successfully wrote terrain mesh to OBJ: %s (%zu vertices, %zu triangles)",
path.c_str(), mesh.vertices.size(), mesh.triangles.size() / 3);
return true;
} catch (const std::exception& e) {
LOG("Exception while writing OBJ file: %s", e.what());
return false;
}
}
} // namespace Raw

194
dZoneManager/Raw.h Normal file
View File

@@ -0,0 +1,194 @@
#pragma once
#ifndef __RAW_H__
#define __RAW_H__
#include <cstdint>
#include <optional>
#include <vector>
#include <string>
#include <istream>
#include "NiPoint3.h"
#include "dCommonVars.h"
namespace Raw {
/**
* @brief Flair attributes structure
* Represents decorative elements on the terrain
*/
struct FlairAttributes {
uint32_t id;
float scaleFactor;
NiPoint3 position;
NiPoint3 rotation;
uint8_t colorR;
uint8_t colorG;
uint8_t colorB;
// Padding: never set when a flair is placed, so it holds leftover memory; nothing reads it
uint8_t padding;
};
static_assert(sizeof(FlairAttributes) == 36, "a flair record in the .raw is 36 bytes");
/**
* @brief Mesh triangle structure
* Contains triangle indices for terrain mesh
*/
struct MeshTri {
uint16_t meshTriListSize;
std::vector<uint16_t> meshTriList;
};
/**
* @brief Vertex with scene ID
* Used for the generated terrain mesh to enable fast scene lookups
*/
struct SceneVertex {
NiPoint3 position;
uint8_t sceneID;
SceneVertex() : position(), sceneID(0) {}
SceneVertex(const NiPoint3& pos, uint8_t scene) : position(pos), sceneID(scene) {}
};
/**
* @brief Generated terrain mesh
* Contains vertices with scene IDs for fast scene lookups at arbitrary positions
*/
struct TerrainMesh {
std::vector<SceneVertex> vertices;
std::vector<uint32_t> triangles; // Indices into vertices array (groups of 3)
TerrainMesh() = default;
};
/**
* @brief Terrain chunk structure
* Represents a single chunk of terrain with heightmap, textures, flair, scene map and meshes
*/
struct Chunk {
uint32_t id;
uint32_t width;
uint32_t height;
float offsetX;
float offsetZ;
uint32_t shaderId = 0; // version < 32 only
// Texture IDs (4 textures per chunk)
std::vector<uint32_t> textureIds;
// Terrain scale factor (world units between two heightmap points)
float scaleFactor;
// Heightmap data (width * height floats)
std::vector<float> heightMap;
// Color map: colorMapResolution^2 RGBA pixels (version < 32: width^2 pixels, resolution width - 1)
uint32_t colorMapResolution = 0;
std::vector<uint8_t> colorMap;
std::vector<uint8_t> lightMap; // DDS, version 32+
uint32_t textureMapResolution = 0;
std::vector<uint8_t> textureMap; // textureMapResolution^2 RGBA pixels
uint8_t textureSettings = 0; // version 32+
std::vector<uint8_t> blendMap; // DDS, version 32+
// Flair data
std::vector<FlairAttributes> flairs;
// The scene map is colorMapResolution * colorMapResolution scene IDs, indexed [x cell * resolution + z cell]
std::vector<uint8_t> sceneMap;
// Version 31 and older: the file's (colorMapResolution + 1)^2 scene map. In version 31 sceneMap is its first
// colorMapResolution rows and columns; older versions don't use it and every cell is scene 0
std::vector<uint8_t> sceneMapLegacy;
// Mesh data (version 32+)
uint32_t vertSize = 0;
std::vector<uint16_t> meshVertUsage;
std::vector<uint16_t> meshVertSize;
std::vector<MeshTri> meshTri;
// Scene map cells per world unit, and the size of one cell, along X and Z (set once the chunk is read)
float sceneCellsPerUnitX = 0.0f;
float sceneCellsPerUnitZ = 0.0f;
float sceneCellSizeX = 0.0f;
float sceneCellSizeZ = 0.0f;
bool IsValidForSceneLookup() const;
float GetMaxX() const { return offsetX + static_cast<float>(width - 1) * scaleFactor; }
float GetMaxZ() const { return offsetZ + static_cast<float>(height - 1) * scaleFactor; }
// The scene ID at a position relative to the chunk's offset, as the client looks it up
uint8_t GetSceneIDAtLocal(float localX, float localZ) const;
uint8_t GetSceneIDAtGrid(uint32_t i, uint32_t j) const;
NiPoint3 GridToWorldPos(uint32_t i, uint32_t j) const;
};
/**
* @brief RAW terrain file structure
* Complete representation of a .raw terrain file
*/
struct Raw {
uint16_t version = 0;
uint8_t dev = 0;
uint32_t numChunks = 0;
uint32_t numChunksWidth = 0;
uint32_t numChunksHeight = 0;
std::vector<Chunk> chunks;
// Calculated bounds of the entire terrain
float minBoundsX = 0.0f;
float minBoundsZ = 0.0f;
float maxBoundsX = 0.0f;
float maxBoundsZ = 0.0f;
// Chunk lookup by position: the chunks of every client terrain form a regular grid, written X-major
// ([x * chunks along Z + z]). chunkGrid holds the index into chunks per grid cell (-1 for none); it is empty when
// the chunks don't form a grid, and lookups then check every chunk.
std::vector<int32_t> chunkGrid;
uint32_t chunkGridCountX = 0;
uint32_t chunkGridCountZ = 0;
float chunkSpanX = 0.0f;
float chunkSpanZ = 0.0f;
/**
* @brief The scene ID at a world position, looked up as the client's TerrainManager::GetSceneAtPos does
* (height is ignored). Constant time on a chunk grid.
*
* @return The scene ID, or nullopt when there is no terrain with a scene map there
*/
std::optional<uint8_t> GetSceneIDAt(float x, float z) const;
};
/**
* @brief Read a RAW terrain file from an input stream
*
* @param stream Input stream containing RAW file data
* @param outRaw Output RAW file structure
* @return true if successfully read, false otherwise
*/
bool ReadRaw(std::istream& stream, Raw& outRaw);
/**
* @brief Generate a terrain mesh from raw chunks
* Similar to dTerrain's GenerateFinalMeshFromChunks but creates a mesh with scene IDs
* per vertex for fast scene lookups at arbitrary positions.
*
* @param raw The RAW terrain data to generate mesh from
* @param outMesh Output terrain mesh with vertices and scene IDs
*/
void GenerateTerrainMesh(const Raw& raw, TerrainMesh& outMesh);
/**
* @brief Write terrain mesh to OBJ file for debugging/visualization
* Merged from dTerrain's WriteFinalMeshToOBJ functionality
* Vertices are colored based on their scene ID using a hash function
*
* @param mesh The terrain mesh to export
* @param path Output path for the OBJ file
* @return true if successfully written, false otherwise
*/
bool WriteTerrainMeshToOBJ(const TerrainMesh& mesh, const std::string& path);
} // namespace Raw
#endif // __RAW_H__

View File

@@ -8,6 +8,7 @@
#include "GeneralUtils.h"
#include "BinaryIO.h"
#include "LUTriggers.h"
#include "dConfig.h"
#include "AssetManager.h"
#include "CDClientManager.h"
@@ -20,6 +21,7 @@
#include "eTriggerEventType.h"
#include "eWaypointCommandType.h"
#include "dNavMesh.h"
#include "Raw.h"
Zone::Zone(const LWOZONEID zoneID) :
m_ZoneID(zoneID) {
@@ -68,7 +70,8 @@ void Zone::LoadZoneIntoMemory() {
BinaryIO::BinaryRead(file, m_SpawnpointRotation);
}
if (m_FileFormatVersion <= Zone::FileFormatVersion::LateAlpha) {
// A byte before version 37 (LateAlpha), a u32 from it
if (m_FileFormatVersion < Zone::FileFormatVersion::LateAlpha) {
uint8_t sceneCount;
BinaryIO::BinaryRead(file, sceneCount);
m_SceneCount = sceneCount;
@@ -78,11 +81,56 @@ void Zone::LoadZoneIntoMemory() {
LoadScene(file);
}
//Read generic zone info:
BinaryIO::ReadString<uint8_t>(file, m_ZonePath, BinaryIO::ReadType::String);
// Zone boundary lines: a count byte, then the lines
uint8_t numBoundaries = 0;
BinaryIO::BinaryRead(file, numBoundaries);
m_Boundaries.resize(numBoundaries);
for (auto& boundary : m_Boundaries) {
LWOMAPID destMapID = 0;
LWOINSTANCEID destInstanceID = 0;
BinaryIO::BinaryRead(file, boundary.normal);
BinaryIO::BinaryRead(file, boundary.point);
BinaryIO::BinaryRead(file, destMapID);
BinaryIO::BinaryRead(file, destInstanceID);
boundary.destinationZone = LWOZONEID(destMapID, destInstanceID, 0);
BinaryIO::BinaryRead(file, boundary.destinationSceneID);
BinaryIO::BinaryRead(file, boundary.spawnLocation);
}
BinaryIO::ReadString<uint8_t>(file, m_ZoneRawPath, BinaryIO::ReadType::String);
BinaryIO::ReadString<uint8_t>(file, m_ZoneName, BinaryIO::ReadType::String);
BinaryIO::ReadString<uint8_t>(file, m_ZoneDesc, BinaryIO::ReadType::String);
if (m_FileFormatVersion > Zone::FileFormatVersion::PrePreAlpha) {
BinaryIO::ReadString<uint8_t>(file, m_ZoneName, BinaryIO::ReadType::String);
BinaryIO::ReadString<uint8_t>(file, m_ZoneDesc, BinaryIO::ReadType::String);
}
// The terrain gives the scene at a position. Like the client, a missing or unreadable .raw is logged
// and the zone loads without it.
const auto rawPath = m_ZoneFilePath.substr(0, m_ZoneFilePath.rfind('/') + 1) + m_ZoneRawPath;
if (m_FileFormatVersion < Zone::FileFormatVersion::PrePreAlpha) {
LOG("Zone %s uses the legacy raw terrain format (version %u), which is not supported; no scene lookups", m_ZoneFilePath.c_str(), static_cast<uint32_t>(m_FileFormatVersion));
} else if (!Game::assetManager->HasFile(rawPath)) {
LOG("Failed to find %s; no scene lookups", rawPath.c_str());
} else {
auto rawFile = Game::assetManager->GetFile(rawPath);
if (Raw::ReadRaw(rawFile, m_Raw)) {
LOG_DEBUG("Successfully parsed %s", rawPath.c_str());
} else {
LOG("Failed to parse %s; no scene lookups", rawPath.c_str());
m_Raw = Raw::Raw{};
}
}
// Optionally export terrain mesh to OBJ for debugging/visualization
if (Game::config->GetValue("export_terrain_to_obj") == "1") {
Raw::TerrainMesh terrainMesh;
Raw::GenerateTerrainMesh(m_Raw, terrainMesh);
LOG("Generated terrain mesh with %zu vertices and %zu triangles", terrainMesh.vertices.size(), terrainMesh.triangles.size() / 3);
const std::string objFileName = "terrain_" + std::to_string(m_ZoneID.GetMapID()) + ".obj";
if (Raw::WriteTerrainMeshToOBJ(terrainMesh, objFileName)) {
LOG("Exported terrain mesh to %s", objFileName.c_str());
}
}
if (m_FileFormatVersion >= Zone::FileFormatVersion::PreAlpha) {
BinaryIO::BinaryRead(file, m_NumberOfSceneTransitionsLoaded);
@@ -236,7 +284,8 @@ void Zone::LoadScene(std::istream& file) {
}
if (m_FileFormatVersion >= Zone::FileFormatVersion::LatePreAlpha) {
BinaryIO::BinaryRead(file, scene.sceneType);
lwoSceneID.SetLayerID(scene.sceneType);
lwoSceneID.SetLayerID(static_cast<uint32_t>(scene.sceneType));
BinaryIO::ReadString<uint8_t>(file, scene.name, BinaryIO::ReadType::String);
}
@@ -247,9 +296,11 @@ void Zone::LoadScene(std::istream& file) {
}
if (m_FileFormatVersion >= Zone::FileFormatVersion::LatePreAlpha) {
BinaryIO::BinaryRead(file, scene.color_r);
BinaryIO::BinaryRead(file, scene.color_b);
BinaryIO::BinaryRead(file, scene.color_g);
uint8_t r, b, g;
BinaryIO::BinaryRead(file, r);
BinaryIO::BinaryRead(file, b);
BinaryIO::BinaryRead(file, g);
scene.color = NiColor(r / 255.0f, g / 255.0f, b / 255.0f);
}
m_Scenes[lwoSceneID] = std::move(scene);
@@ -350,7 +401,10 @@ void Zone::LoadSceneTransition(std::istream& file) {
SceneTransitionInfo Zone::LoadSceneTransitionInfo(std::istream& file) {
SceneTransitionInfo info;
BinaryIO::BinaryRead(file, info.sceneID);
uint32_t sceneID, layerID;
BinaryIO::BinaryRead(file, sceneID);
BinaryIO::BinaryRead(file, layerID);
info.sceneID = LWOSCENEID(sceneID, layerID);
BinaryIO::BinaryRead(file, info.position);
return info;
}
@@ -422,7 +476,6 @@ void Zone::LoadPath(std::istream& file) {
BinaryIO::BinaryRead(file, waypoint.position.y);
BinaryIO::BinaryRead(file, waypoint.position.z);
if (path.pathType == PathType::Spawner || path.pathType == PathType::MovingPlatform || path.pathType == PathType::Race || path.pathType == PathType::Camera || path.pathType == PathType::Rail) {
BinaryIO::BinaryRead(file, waypoint.rotation.w);
BinaryIO::BinaryRead(file, waypoint.rotation.x);
@@ -483,7 +536,7 @@ void Zone::LoadPath(std::istream& file) {
}
// We verify the waypoint heights against the navmesh because in many movement paths,
// the waypoint is located near 0 height,
// the waypoint is located near 0 height,
if (path.pathType == PathType::Movement) {
if (dpWorld::IsLoaded()) {
// 2000 should be large enough for every world.
@@ -494,3 +547,9 @@ void Zone::LoadPath(std::istream& file) {
}
m_Paths.push_back(path);
}
const SceneRef* Zone::GetScene(LWOSCENEID sceneID) const {
auto it = m_Scenes.find(sceneID);
if (it != m_Scenes.end()) return &it->second;
return nullptr;
}

View File

@@ -2,10 +2,12 @@
#include "dZMCommon.h"
#include "LDFFormat.h"
#include "NiColor.h"
#include "tinyxml2.h"
#include <string>
#include <vector>
#include <map>
#include "Raw.h"
namespace LUTriggers {
struct Trigger;
@@ -21,25 +23,37 @@ struct WaypointCommand {
};
enum class eSceneType : uint32_t {
General = 0,
Audio = 1,
};
struct SceneRef {
std::string filename;
uint32_t id{};
uint32_t sceneType{}; //0 = general, 1 = audio?
eSceneType sceneType{};
std::string name;
NiPoint3 unknown1;
float unknown2{};
uint8_t color_r{};
uint8_t color_g{};
uint8_t color_b{};
NiColor color;
std::unique_ptr<Level> level;
std::map<uint32_t, LUTriggers::Trigger*> triggers;
};
struct SceneTransitionInfo {
uint64_t sceneID{}; //id of the scene being transitioned to.
LWOSCENEID sceneID;
NiPoint3 position;
};
// A zone boundary line from the .luz
struct ZoneBoundary {
NiPoint3 normal;
NiPoint3 point;
LWOZONEID destinationZone; // the clone ID is always 0
uint32_t destinationSceneID{};
NiPoint3 spawnLocation;
};
struct SceneTransition {
std::string name;
std::vector<SceneTransitionInfo> points;
@@ -228,6 +242,12 @@ public:
void SetSpawnPos(const NiPoint3& pos) { m_Spawnpoint = pos; }
void SetSpawnRot(const NiQuaternion& rot) { m_SpawnpointRotation = rot; }
const Raw::Raw& GetZoneRaw() const { return m_Raw; }
const SceneRef* GetScene(LWOSCENEID sceneID) const;
const std::vector<SceneTransition>& GetSceneTransitions() const { return m_SceneTransitions; }
const std::vector<ZoneBoundary>& GetBoundaries() const { return m_Boundaries; }
const std::map<LWOSCENEID, SceneRef>& GetScenes() const { return m_Scenes; }
private:
LWOZONEID m_ZoneID;
std::string m_ZoneFilePath;
@@ -244,9 +264,11 @@ private:
std::string m_ZoneName; //Name given to the zone by a level designer
std::string m_ZoneDesc; //Description of the zone by a level designer
std::string m_ZoneRawPath; //Path to the .raw file of this zone.
Raw::Raw m_Raw; // The Raw data for this zone
std::map<LWOSCENEID, SceneRef> m_Scenes;
std::vector<SceneTransition> m_SceneTransitions;
std::vector<ZoneBoundary> m_Boundaries;
uint32_t m_PathDataLength;
uint32_t m_PathChunkVersion;

View File

@@ -10,9 +10,10 @@
#include "VanityUtilities.h"
#include "WorldConfig.h"
#include "CDZoneTableTable.h"
#include <algorithm>
#include <chrono>
#include <set>
#include "eObjectBits.h"
#include "CDZoneTableTable.h"
#include "AssetManager.h"
#include <ranges>
@@ -62,6 +63,9 @@ void dZoneManager::Initialize(const LWOZONEID& zoneID) {
m_pZone->Initalize();
// Build the scene graph after zone is loaded
BuildSceneGraph();
endTime = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
LoadWorldConfig();
@@ -298,3 +302,83 @@ void dZoneManager::LoadWorldConfig() {
LOG_DEBUG("Loaded WorldConfig into memory");
}
LWOSCENEID dZoneManager::GetSceneIDFromPosition(const NiPoint3& position) const {
if (!m_pZone) return LWOSCENEID_INVALID;
const auto sceneID = m_pZone->GetZoneRaw().GetSceneIDAt(position.x, position.z);
return sceneID ? LWOSCENEID(*sceneID, 0) : LWOSCENEID_INVALID;
}
void dZoneManager::BuildSceneGraph() {
if (!m_pZone) return;
// Clear any existing adjacency list
m_SceneAdjacencyList.clear();
// Initialize adjacency list with all scenes
const auto& scenes = m_pZone->GetScenes();
for (const auto& [sceneID, sceneRef] : scenes) {
if (sceneRef.sceneType != eSceneType::General) continue;
m_SceneAdjacencyList.try_emplace(sceneID, std::vector<LWOSCENEID>());
}
// Build adjacency list from scene transitions
const auto& transitions = m_pZone->GetSceneTransitions();
for (const auto& transition : transitions) {
// Each transition has multiple points, each pointing to a scene
// We need to determine which scenes this transition connects
// Group transition points by their scene IDs to find unique connections
std::set<LWOSCENEID> connectedScenes;
for (const auto& point : transition.points) {
if (point.sceneID != LWOSCENEID_INVALID && m_SceneAdjacencyList.contains(point.sceneID)) {
connectedScenes.insert(point.sceneID);
}
}
// Create bidirectional edges between all scenes in this transition
// (transitions typically connect two scenes, but can be more complex)
std::vector<LWOSCENEID> sceneList(connectedScenes.begin(), connectedScenes.end());
for (size_t i = 0; i < sceneList.size(); ++i) {
for (size_t j = 0; j < sceneList.size(); ++j) {
if (i != j) {
LWOSCENEID fromScene = sceneList[i];
LWOSCENEID toScene = sceneList[j];
// Add edge if it doesn't already exist
auto& adjacentScenes = m_SceneAdjacencyList[fromScene];
if (std::find(adjacentScenes.begin(), adjacentScenes.end(), toScene) == adjacentScenes.end()) {
adjacentScenes.push_back(toScene);
}
}
}
}
}
// Scene 0 (global scene) is always loaded and adjacent to all other scenes
LWOSCENEID globalScene = LWOSCENEID(0, 0);
for (auto& [sceneID, adjacentScenes] : m_SceneAdjacencyList) {
if (sceneID != globalScene) {
// Add global scene to this scene's adjacency list if not already present
if (std::find(adjacentScenes.begin(), adjacentScenes.end(), globalScene) == adjacentScenes.end()) {
adjacentScenes.push_back(globalScene);
}
// Add this scene to global scene's adjacency list if not already present
auto& globalAdjacent = m_SceneAdjacencyList[globalScene];
if (std::find(globalAdjacent.begin(), globalAdjacent.end(), sceneID) == globalAdjacent.end()) {
globalAdjacent.push_back(sceneID);
}
}
}
}
std::vector<LWOSCENEID> dZoneManager::GetAdjacentScenes(LWOSCENEID sceneID) const {
auto it = m_SceneAdjacencyList.find(sceneID);
if (it != m_SceneAdjacencyList.end()) {
return it->second;
}
return std::vector<LWOSCENEID>();
}

View File

@@ -53,6 +53,35 @@ public:
uint32_t GetUniqueMissionIdStartingValue();
bool CheckIfAccessibleZone(LWOMAPID zoneID);
/**
* @brief Get the scene ID at a given position. Scenes do not care about height (Y coordinate).
*
* @param position The position to query
* @return The scene ID at that position, or LWOSCENEID_INVALID if not found
*/
LWOSCENEID GetSceneIDFromPosition(const NiPoint3& position) const;
/**
* @brief Get the adjacency list for the scene graph.
* The adjacency list maps each scene ID to a list of scene IDs it can transition to.
*
* @return A reference to the scene adjacency list
*/
const std::map<LWOSCENEID, std::vector<LWOSCENEID>>& GetSceneAdjacencyList() const { return m_SceneAdjacencyList; }
/**
* @brief Get all scenes adjacent to (connected to) a given scene.
*
* @param sceneID The scene ID to query
* @return A vector of scene IDs that are directly connected to this scene, or empty vector if scene not found
*/
std::vector<LWOSCENEID> GetAdjacentScenes(LWOSCENEID sceneID) const;
/**
* @brief (Re)builds the scene graph adjacency list from the loaded zone's scene transitions.
*/
void BuildSceneGraph();
// The world config should not be modified by a caller.
const WorldConfig& GetWorldConfig() {
if (!m_WorldConfig) LoadWorldConfig();
@@ -75,4 +104,9 @@ private:
std::optional<WorldConfig> m_WorldConfig = std::nullopt;
Entity* m_ZoneControlObject = nullptr;
/**
* Scene graph adjacency list: maps each scene ID to a list of scenes it can transition to
*/
std::map<LWOSCENEID, std::vector<LWOSCENEID>> m_SceneAdjacencyList;
};

View File

@@ -143,6 +143,8 @@ These commands are primarily for development and testing. The usage of many of t
|freecam|`/freecam`|Toggles freecam mode|8|
|freemoney|`/freemoney <coins>`|Give yourself coins. Aliases: `/givemoney`, `/money`, `/givecoins`, `/coins`.|8|
|getnavmeshheight|`/getnavmeshheight`|Display the navmesh height at your current position|8|
|getscene|`/getscene`|Display the scene ID and name at your current position (alias `/scene`)|8|
|getadjacentscenes|`/getadjacentscenes`|List the scenes connected to your current scene by scene transitions (alias `/adjacentscenes`)|8|
|giveuscore|`/giveuscore <uscore>`|Gives uscore|8|
|gmadditem|`/gmadditem <id> (count)`|Adds the given item to your inventory by id. Aliases: `/give`.|8|
|inspect|`/inspect <component or ldf variable or player name> (-m <waypoint> | -a <animation> | -s | -p | -f (faction) | -t)`|Finds the closest entity with the given component or LNV variable (ignoring players and racing cars), printing its ID, distance from the player, and whether it is sleeping, as well as the IDs of all components the entity has. Use `localCharacter` or `zoneControl` to inspect your current character or the zone control object.|8|

View File

@@ -103,5 +103,9 @@ hardcore_disabled_worlds=
# Keeps this percentage of a players' coins on death in hardcore
hardcore_coin_keep=
# Export terrain meshes to OBJ files when zones load
# OBJ files will be saved as terrain_<zoneID>.obj in the server directory
export_terrain_to_obj=0
# save pre-split lxfmls to disk for debugging
save_lxfmls=0

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,381 @@
#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.padding, 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].sceneMapLegacy, chunk.sceneMap);
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, Version30SceneMapIsReadButEveryCellIsSceneZero) {
TestChunk chunk;
chunk.width = 3;
chunk.height = 3;
// resolution is width - 1 = 2; the file has a 3x3 scene map the client doesn't use
chunk.sceneMap = { 7, 7, 7, 7, 7, 7, 7, 7, 7 };
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_EQ(raw.chunks[1].sceneMapLegacy, chunk.sceneMap);
EXPECT_EQ(raw.chunks[1].sceneMap, (std::vector<uint8_t>{ 0, 0, 0, 0 }));
EXPECT_TRUE(raw.chunks[1].IsValidForSceneLookup());
EXPECT_EQ(raw.GetSceneIDAt(0.5f, 0.5f), 0);
}
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::vector<std::string> lines;
{
std::ifstream file(path);
for (std::string line; std::getline(file, line);) lines.push_back(line);
} // closed before removing it (Windows can't remove an open file)
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,193 @@
#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 and older); 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, RGB, padding byte (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);
}
// version 31 and older: one more row and column
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]);
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 .luz (version 36 to 41) 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, uint32_t version = 41) {
std::ostringstream stream;
Write(stream, version);
Write<uint32_t>(stream, 5); // revision
Write(stream, worldID);
if (version >= 38) {
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
}
// the scene count is a byte before version 37
if (version >= 37) Write<uint32_t>(stream, static_cast<uint32_t>(scenes.size()));
else Write<uint8_t>(stream, static_cast<uint8_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<uint16_t>(stream, 1100); // map
Write<uint16_t>(stream, i + 1); // instance
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) {
// before version 40 a transition has a name and a width
if (version < 40) {
WriteString8(stream, "transition");
Write(stream, 2.5f);
}
// five points from version 34 to 38 (the last three repeat the second here), two otherwise
const size_t pointCount = version >= 34 && version < 39 ? 5 : 2;
for (size_t i = 0; i < pointCount; ++i) {
const auto& point = i == 0 ? 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,243 @@
#include "GameDependencies.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include "AssetManager.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, uint32_t version = 41) 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) } },
}, version));
}
// 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 boundary lines
EXPECT_EQ(zone->GetZonePath(), "maps/testzone/");
const auto& boundaries = zone->GetBoundaries();
ASSERT_EQ(boundaries.size(), 2);
EXPECT_FLOAT_EQ(boundaries[1].normal.z, 1.0f);
EXPECT_FLOAT_EQ(boundaries[1].point.x, 10.0f);
EXPECT_FLOAT_EQ(boundaries[1].point.z, 1.0f);
EXPECT_EQ(boundaries[1].destinationZone.GetMapID(), 1100);
EXPECT_EQ(boundaries[1].destinationZone.GetInstanceID(), 2);
EXPECT_EQ(boundaries[1].destinationZone.GetCloneID(), 0);
EXPECT_EQ(boundaries[1].destinationSceneID, 2);
EXPECT_FLOAT_EQ(boundaries[1].spawnLocation.y, 6.0f);
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_TRUE(zone->GetBoundaries().empty());
EXPECT_EQ(zone->GetSceneTransitions().size(), 2);
}
// Version 37 has a u32 scene count (a byte before it) and no spawn point; 36 and 37 have named five point transitions
TEST_F(ZoneSceneTest, ReadsOlderZoneVersions) {
for (const uint32_t version : { 36u, 37u, 38u, 39u, 40u }) {
WriteZone("test.raw", 2, version);
WriteFile("test.raw", MakeRaw());
const Zone* zone = LoadZone();
EXPECT_EQ(zone->GetScenes().size(), 5) << version;
EXPECT_EQ(zone->GetBoundaries().size(), 2) << version;
EXPECT_EQ(zone->GetZoneRawPath(), "test.raw") << version;
EXPECT_EQ(zone->GetZoneName(), "Test Zone") << version;
ASSERT_EQ(zone->GetSceneTransitions().size(), 2) << version;
const auto& transition = zone->GetSceneTransitions()[1];
EXPECT_EQ(transition.points.size(), version < 39 ? 5 : 2) << version;
EXPECT_EQ(transition.points[1].sceneID, LWOSCENEID(3, 0)) << version;
if (version < 40) {
EXPECT_EQ(transition.name, "transition") << version;
EXPECT_FLOAT_EQ(transition.width, 2.5f) << version;
}
EXPECT_FALSE(zone->GetZoneRaw().chunks.empty()) << version;
}
}
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, SceneCommandsReport) {
WriteZone();
WriteFile("test.raw", MakeRaw());
LoadZone();
EntityInfo playerInfo = info;
playerInfo.pos = NiPoint3(0.5f, 0.0f, 0.5f); // scene 1
auto player = std::make_unique<Entity>(1, playerInfo);
// the scene commands only report; they must not fail
DEVGMCommands::GetScene(player.get(), UNASSIGNED_SYSTEM_ADDRESS, "");
DEVGMCommands::GetAdjacentScenes(player.get(), UNASSIGNED_SYSTEM_ADDRESS, "");
}