#include "Raw.h" #include "BinaryIO.h" #include "Logger.h" #include "SceneColor.h" #include #include #include #include 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(std::clamp(static_cast(std::floor(cell + 0.5f)), 0, static_cast(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(i) * scaleFactor, static_cast(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(i) * scaleFactor, y, offsetZ + static_cast(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(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(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(chunk.colorMap.data()), static_cast(colorMapPixelCount)); } else { const size_t legacyColorBytes = static_cast(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(chunk.colorMap.data()), static_cast(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(chunk.lightMap.data()), static_cast(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(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(chunk.textureMap.data()), static_cast(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(chunk.blendMap.data()), static_cast(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(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(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(chunk.sceneMap.data()), static_cast(sceneMapSize)); } else { const size_t sceneMapCells = static_cast(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(chunk.sceneMapLegacy.data()), static_cast(sceneMapCells)); // Like the client: version 31 uses the first colorMapResolution rows and columns, older versions are all scene 0 chunk.sceneMap.assign(static_cast(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(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(chunk.width - 1) * chunk.scaleFactor / static_cast(chunk.colorMapResolution); chunk.sceneCellSizeZ = static_cast(chunk.height - 1) * chunk.scaleFactor / static_cast(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(std::lround((raw.maxBoundsX - raw.minBoundsX) / spanX)); const auto countZ = static_cast(std::lround((raw.maxBoundsZ - raw.minBoundsZ) / spanZ)); if (countX == 0 || countZ == 0 || static_cast(countX) * countZ > kMaxChunks) return; std::vector grid(static_cast(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(countX) || z >= static_cast(countZ)) { LOG("Raw chunks are not a regular grid (chunk %u); scene lookups check every chunk", chunk.id); return; } auto& cell = grid[static_cast(x) * countZ + z]; if (cell == -1) cell = static_cast(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::max(); outRaw.minBoundsZ = std::numeric_limits::max(); outRaw.maxBoundsX = std::numeric_limits::lowest(); outRaw.maxBoundsZ = std::numeric_limits::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 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 { 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(std::floor((pos + kEdgeEpsilon - min) / span)); return std::clamp(cell, 0, static_cast(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(chunkGridCountX) || z >= static_cast(chunkGridCountZ)) continue; const int32_t index = chunkGrid[static_cast(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