Files
DarkflameServer/tests/dGameTests/dZoneManagerTests/LevelTests.cpp
Aaron Kimbrell 8a1d278265 fix: the zone keeps its paths after the merge, and the tests follow the merged readers
- Zone::LoadZoneIntoMemory takes the paths the zone file reader read (lost in the merge of the raw terrain loading)
- the terrain reader is main's unchanged: files from before version 32 keep their colour and texture maps as the file
  has them, and the dashboard's terrain tests expect that (bounds by the span between the first and last point too)
- the model component test counts the model block only: the item block in front of it is the ItemComponent's
- scene file tests: a file from before chunks keeps only its header, editor settings are a count of colours, and the
  tests that log have a logger
- the settings catalog lists export_terrain_to_obj

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:14:23 -06:00

384 lines
15 KiB
C++

#include "LevelFile.h"
#include <gtest/gtest.h>
#include <cstring>
#include <sstream>
#include "GameDependencies.h"
namespace {
template<typename T>
void Write(std::ostream& stream, const T& value) {
stream.write(reinterpret_cast<const char*>(&value), sizeof(T));
}
void WriteFloats(std::ostream& stream, std::initializer_list<float> values) {
for (const float value : values) Write(stream, value);
}
// A u32 length, then that many bytes
void WriteString32(std::ostream& stream, const std::string& value) {
Write<uint32_t>(stream, static_cast<uint32_t>(value.size()));
stream.write(value.data(), value.size());
}
// A u32 length, then that many UTF-16 characters (ASCII only)
void WriteWString32(std::ostream& stream, const std::string& value) {
Write<uint32_t>(stream, static_cast<uint32_t>(value.size()));
for (const char c : value) Write<uint16_t>(stream, static_cast<uint16_t>(c));
}
void WriteName64(std::ostream& stream, const std::string& name) {
std::string bytes(64, '\0');
bytes.replace(0, name.size(), name);
stream.write(bytes.data(), bytes.size());
}
// The light environment as the given version has it
void WriteLighting(std::ostream& stream, uint32_t version) {
if (version >= 45) Write(stream, 2.5f); // blend time
WriteFloats(stream, { 0.1f, 0.2f, 0.3f }); // ambient
WriteFloats(stream, { 0.4f, 0.5f, 0.6f }); // specular
WriteFloats(stream, { 0.7f, 0.8f, 0.9f }); // upper hemisphere
WriteFloats(stream, { 10.0f, 20.0f, 30.0f }); // position
if (version >= 31) {
if (version < 39) {
WriteFloats(stream, { 50.0f, 60.0f }); // fog near and far
} else {
WriteFloats(stream, { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f }); // min draw distances
WriteFloats(stream, { 7.0f, 8.0f, 9.0f, 10.0f, 11.0f, 12.0f }); // max draw distances
if (version >= 40) {
Write<uint32_t>(stream, 1);
Write<uint32_t>(stream, 3); // group
WriteFloats(stream, { 100.0f, 200.0f });
}
}
WriteFloats(stream, { 0.25f, 0.5f, 0.75f }); // fog colour
}
if (version >= 36) WriteFloats(stream, { 1.0f, 0.5f, 0.0f }); // directional
if (version < 42) {
WriteFloats(stream, { 5.0f, 6.0f, 7.0f }); // spawn position
if (version >= 33) WriteFloats(stream, { 0.0f, 1.0f, 0.0f, 0.0f }); // spawn rotation, w first
}
}
void WriteSkydome(std::ostream& stream, uint32_t version) {
WriteString32(stream, "mesh/env/sky.nif");
if (version >= 34) {
for (const auto* name : { "a.nif", "b.nif", "c.nif", "d.nif", "e.nif" }) WriteString32(stream, name);
}
}
void WriteEditorSettings(std::ostream& stream) {
Write<uint32_t>(stream, 4 + 12 * 2); // size
Write<uint32_t>(stream, 2);
WriteFloats(stream, { 1.0f, 0.0f, 0.0f });
WriteFloats(stream, { 0.0f, 1.0f, 0.0f });
}
void WriteObject(std::ostream& stream, uint32_t version, LWOOBJID id, LOT lot, const std::string& config, uint32_t techniques = 0) {
Write(stream, id);
Write(stream, lot);
if (version >= 38) Write<uint32_t>(stream, 12); // node type out of range: the client uses 1
if (version >= 32) Write<uint32_t>(stream, 4); // glom ID
WriteFloats(stream, { 1.0f, 2.0f, 3.0f }); // position
WriteFloats(stream, { 1.0f, 0.0f, 0.0f, 0.0f }); // rotation, w first
Write(stream, 2.0f); // scale
WriteWString32(stream, config);
if (version >= 7) {
Write(stream, techniques);
if (techniques != 0) {
WriteName64(stream, "techniques");
for (uint32_t i = 0; i < techniques; ++i) {
WriteName64(stream, "technique" + std::to_string(i));
Write<uint32_t>(stream, 4);
Write<uint8_t>(stream, 1);
for (uint32_t j = 0; j < 16; ++j) Write(stream, static_cast<float>(j));
}
}
}
}
void WriteParticle(std::ostream& stream, uint32_t version, const std::string& names, const std::string& config) {
if (version >= 43) Write<int16_t>(stream, -2); // priority
WriteFloats(stream, { 4.0f, 5.0f, 6.0f }); // position
WriteFloats(stream, { 1.0f, 0.0f, 0.0f, 0.0f }); // rotation, w first
WriteWString32(stream, names);
if (version < 46) Write<uint16_t>(stream, 0); // the names' wide null
else WriteWString32(stream, config);
}
void WriteChunkHeader(std::ostream& stream, uint32_t id, uint32_t size, uint32_t dataStart) {
stream.write("CHNK", 4);
Write(stream, id);
Write<uint16_t>(stream, 1);
Write<uint16_t>(stream, 1);
Write(stream, size);
Write(stream, dataStart);
}
// Pads to the next 16 bytes with 0xcd, as the client's files are
void Pad(std::ostringstream& stream) {
while (stream.tellp() % 16 != 0) Write<uint8_t>(stream, 0xcd);
}
// A scene file from before chunks
std::string WriteFlat(uint32_t version, bool withParticles = true) {
std::ostringstream stream;
Write<uint16_t>(stream, static_cast<uint16_t>(version));
Write<uint16_t>(stream, 0);
if (version >= 32) Write<uint8_t>(stream, 0); // no editor settings flag
if (version > 36) Write<uint32_t>(stream, 77); // revision
WriteLighting(stream, version);
WriteSkydome(stream, version);
if (version >= 37) WriteEditorSettings(stream);
if (version >= 3) {
Write<uint32_t>(stream, 1);
WriteObject(stream, version, 1000, 2000, "respawn=5:20000\ncustom=0:a");
}
if (version >= 5) {
Write<uint32_t>(stream, 2);
WriteFloats(stream, { 8.0f, 9.0f, 10.0f });
if (version < 35) {
std::u16string name(260, u'\0');
name.replace(0, 5, u"birds");
stream.write(reinterpret_cast<const char*>(name.data()), name.size() * sizeof(char16_t));
} else {
WriteWString32(stream, "soundName=0:birds");
}
WriteFloats(stream, { 11.0f, 12.0f, 13.0f });
if (version < 35) stream.write(std::string(520, '\0').data(), 520);
else WriteWString32(stream, "");
if (withParticles) {
Write<uint32_t>(stream, 1);
WriteParticle(stream, version, "fx.psb", "");
}
}
return stream.str();
}
// A chunked scene file: file info, environment, objects and particles chunks, each padded as the client's are
std::string WriteChunked(uint32_t version) {
std::ostringstream stream;
WriteChunkHeader(stream, 1000, 64, 32);
Pad(stream);
Write(stream, version);
Write<uint32_t>(stream, 9); // revision
Write<uint32_t>(stream, 64); // environment
Write<uint32_t>(stream, 0); // objects, set below
Write<uint32_t>(stream, 0); // particles, set below
Pad(stream);
// Environment: its header, the three offsets, then each part
WriteChunkHeader(stream, 2000, 0, 96);
Pad(stream);
const auto offsets = stream.tellp();
WriteFloats(stream, { 0.0f, 0.0f, 0.0f });
Pad(stream);
const uint32_t lighting = static_cast<uint32_t>(stream.tellp());
WriteLighting(stream, version);
Pad(stream);
const uint32_t skydome = static_cast<uint32_t>(stream.tellp());
WriteSkydome(stream, version);
Pad(stream);
const uint32_t editor = static_cast<uint32_t>(stream.tellp());
WriteEditorSettings(stream);
Pad(stream);
const uint32_t objects = static_cast<uint32_t>(stream.tellp());
WriteChunkHeader(stream, 2001, 0, objects + 32);
Pad(stream);
Write<uint32_t>(stream, 3);
WriteObject(stream, version, 1, 176, "spawntemplate=1:14,subkey=9:55\nrespawn=3:250");
WriteObject(stream, version, 2, 1, "");
WriteObject(stream, version, 3, 4000, "sceneIDOverrideEnabled=7:1,novalue", 2);
Pad(stream);
const uint32_t particles = static_cast<uint32_t>(stream.tellp());
WriteChunkHeader(stream, 2002, 0, particles + 32);
Pad(stream);
Write<uint32_t>(stream, 3);
WriteParticle(stream, version, "fx1.psb;fx2.psb", "fxconfig=0:1");
WriteParticle(stream, version, "fx3.psb", "");
WriteParticle(stream, version, "", "");
auto data = stream.str();
const auto patch = [&data](size_t at, uint32_t value) { std::memcpy(data.data() + at, &value, sizeof(value)); };
patch(32 + 12, objects);
patch(32 + 16, particles);
patch(static_cast<size_t>(offsets), lighting);
patch(static_cast<size_t>(offsets) + 4, skydome);
patch(static_cast<size_t>(offsets) + 8, editor);
return data;
}
// Reads a scene file as the world and the dashboard do
LevelFile Read(const std::string& data) {
std::istringstream stream(data);
LevelFile level;
level.Read(stream);
return level;
}
}
class LevelTests : public GameDependenciesTest {
protected:
void SetUp() override { SetUpDependencies(); }
void TearDown() override { TearDownDependencies(); }
};
TEST_F(LevelTests, ReadsEveryPartOfAChunkedFile) {
const auto level = Read(WriteChunked(46));
EXPECT_EQ(level.version, 46);
EXPECT_EQ(level.revision, 9);
ASSERT_EQ(level.chunkHeaders.size(), 4);
EXPECT_EQ(level.chunkHeaders.at(1000).fileInfo.revision, 9);
ASSERT_TRUE(level.hasLighting);
EXPECT_FLOAT_EQ(level.lighting.blendTime, 2.5f);
EXPECT_FLOAT_EQ(level.lighting.ambient.m_Blue, 0.3f);
EXPECT_FLOAT_EQ(level.lighting.upperHemi.m_Red, 0.7f);
EXPECT_FLOAT_EQ(level.lighting.position.z, 30.0f);
EXPECT_FLOAT_EQ(level.lighting.minDrawDistances.dynamicObjectDistance, 6.0f);
EXPECT_FLOAT_EQ(level.lighting.maxDrawDistances.fogNear, 7.0f);
ASSERT_EQ(level.lighting.cullSettings.size(), 1);
EXPECT_EQ(level.lighting.cullSettings[0].group, 3);
EXPECT_FLOAT_EQ(level.lighting.cullSettings[0].maxDistance, 200.0f);
EXPECT_FLOAT_EQ(level.lighting.fogColor.m_Green, 0.5f);
EXPECT_FLOAT_EQ(level.lighting.directional.m_Red, 1.0f);
EXPECT_FALSE(level.lighting.hasSpawnPoint);
EXPECT_EQ(level.skydome.filename, "mesh/env/sky.nif");
EXPECT_EQ(level.skydome.extraFilenames[4], "e.nif");
ASSERT_EQ(level.editorColors.size(), 2);
EXPECT_FLOAT_EQ(level.editorColors[1].m_Green, 1.0f);
ASSERT_EQ(level.objects.size(), 3);
const auto& technique = level.objects[2];
EXPECT_EQ(technique.nodeType, 1);
EXPECT_EQ(technique.glomId, 4);
EXPECT_FLOAT_EQ(technique.scale, 2.0f);
EXPECT_EQ(technique.renderTechniquesName, "techniques");
ASSERT_EQ(technique.renderTechniques.size(), 2);
EXPECT_EQ(technique.renderTechniques[1].name, "technique1");
EXPECT_EQ(technique.renderTechniques[1].valueCount, 4);
EXPECT_EQ(technique.renderTechniques[1].isColor, 1);
EXPECT_FLOAT_EQ(technique.renderTechniques[1].values[15], 15.0f);
ASSERT_EQ(level.particles.size(), 2); // the client stops at the one without an effect
EXPECT_EQ(level.particles[0].priority, -2);
EXPECT_EQ(level.particles[0].effectNames, "fx1.psb;fx2.psb");
EXPECT_EQ(level.particles[0].config, "fxconfig=0:1");
EXPECT_FLOAT_EQ(level.particles[1].position.y, 5.0f);
EXPECT_TRUE(level.sounds.empty());
}
// LwoNameValue::FromString splits at commas and line breaks and leaves out entries without a '='; ReadLvlObjectData
// adjusts some values as it loads the object
TEST_F(LevelTests, ObjectConfigIsReadAndAdjustedAsTheClientDoes) {
const auto level = Read(WriteChunked(43));
ASSERT_EQ(level.objects.size(), 3);
const auto& spawner = level.objects[0].settings;
ASSERT_NE(spawner.find(u"blueprintid"), spawner.end());
EXPECT_EQ(spawner.find(u"blueprintid")->second->GetValueAsString(), "55");
EXPECT_EQ(spawner.find(u"DisableModelBehaviors")->second->GetValueAsString(), "1");
EXPECT_EQ(spawner.find(u"preventRenderWrapping")->second->GetValueAsString(), "1");
// A float respawn over 100 was milliseconds
EXPECT_EQ(spawner.find(u"respawn")->second->GetValueType(), LDF_TYPE_FLOAT);
EXPECT_FLOAT_EQ(static_cast<const LDFData<float>*>(spawner.find(u"respawn")->second.get())->GetValue(), 250.0f * 0.001f);
const auto& overridden = level.objects[2].settings;
EXPECT_EQ(overridden.values.size(), 2); // the entry without a '=' is left out
ASSERT_NE(overridden.find(u"sceneLayerIDOverride"), overridden.end());
EXPECT_EQ(overridden.find(u"sceneLayerIDOverride")->second->GetValueAsString(), "0");
// From version 47 a respawn is in seconds already, and from 44 the scene override stays
const auto newer = Read(WriteChunked(47));
EXPECT_FLOAT_EQ(static_cast<const LDFData<float>*>(newer.objects[0].settings.find(u"respawn")->second.get())->GetValue(), 250.0f);
EXPECT_EQ(newer.objects[2].settings.find(u"sceneLayerIDOverride"), newer.objects[2].settings.end());
}
TEST_F(LevelTests, ReadsAFileFromBeforeChunks) {
const auto level = Read(WriteFlat(40));
EXPECT_EQ(level.version, 40);
EXPECT_EQ(level.revision, 77);
ASSERT_EQ(level.chunkHeaders.size(), 1);
EXPECT_EQ(level.chunkHeaders.at(1000).fileInfo.revision, 77);
EXPECT_FLOAT_EQ(level.lighting.blendTime, 10.0f); // the client's default before version 45
ASSERT_EQ(level.lighting.cullSettings.size(), 1);
ASSERT_TRUE(level.lighting.hasSpawnPoint);
EXPECT_FLOAT_EQ(level.lighting.spawnPosition.y, 6.0f);
EXPECT_FLOAT_EQ(level.lighting.spawnRotation.x, 1.0f);
EXPECT_EQ(level.editorColors.size(), 2);
ASSERT_EQ(level.objects.size(), 1);
// Before version 47 a u32 respawn is milliseconds; it becomes seconds
const auto& respawn = level.objects[0].settings.find(u"respawn")->second;
EXPECT_EQ(respawn->GetValueType(), LDF_TYPE_FLOAT);
EXPECT_FLOAT_EQ(static_cast<const LDFData<float>*>(respawn.get())->GetValue(), 20.0f);
ASSERT_EQ(level.sounds.size(), 2);
EXPECT_FLOAT_EQ(level.sounds[1].position.z, 13.0f);
EXPECT_EQ(level.sounds[0].config.find(u"soundName")->second->GetValueAsString(), "birds");
ASSERT_EQ(level.particles.size(), 1);
EXPECT_EQ(level.particles[0].priority, 0); // only from version 43
}
// Older lighting: fog distances only before 39, no directional colour before 36, and before 33 a fixed spawn point
TEST_F(LevelTests, ReadsOlderLightingAndSounds) {
const auto v32 = Read(WriteFlat(32));
EXPECT_EQ(v32.revision, 0);
EXPECT_FLOAT_EQ(v32.lighting.minDrawDistances.fogFar, 60.0f);
EXPECT_FLOAT_EQ(v32.lighting.maxDrawDistances.fogNear, 50.0f);
EXPECT_FLOAT_EQ(v32.lighting.maxDrawDistances.staticObjectDistance, -1.0f);
EXPECT_FLOAT_EQ(v32.lighting.directional.m_Red, 0.0f);
EXPECT_FLOAT_EQ(v32.lighting.filePosition.x, 5.0f);
EXPECT_FLOAT_EQ(v32.lighting.spawnPosition.x, 347.0f);
EXPECT_TRUE(v32.skydome.extraFilenames[0].empty());
EXPECT_TRUE(v32.editorColors.empty());
ASSERT_EQ(v32.sounds.size(), 2);
EXPECT_EQ(v32.sounds[0].config.find(u"soundName")->second->GetValueAsString(), "birds");
ASSERT_EQ(v32.objects.size(), 1);
const auto v30 = Read(WriteFlat(30));
EXPECT_FLOAT_EQ(v30.lighting.fogColor.m_Red, 0.75f); // the client's default
EXPECT_FLOAT_EQ(v30.lighting.spawnRotation.y, 0.23979919f);
EXPECT_EQ(v30.objects.size(), 1);
EXPECT_EQ(v30.particles.size(), 1);
}
// Before version 44 a file may end before its particles; a file older than 3 has no objects, a newer one than 48
// isn't read
TEST_F(LevelTests, ReadsWhatTheClientReadsOfOtherFiles) {
const auto noParticles = Read(WriteFlat(43, false));
EXPECT_EQ(noParticles.objects.size(), 1);
EXPECT_EQ(noParticles.sounds.size(), 2);
EXPECT_TRUE(noParticles.particles.empty());
const auto old = Read(WriteFlat(2));
EXPECT_TRUE(old.hasLighting);
EXPECT_TRUE(old.objects.empty());
std::ostringstream newer;
Write<uint16_t>(newer, 49);
Write<uint16_t>(newer, 0);
Write<uint8_t>(newer, 0);
Write<uint32_t>(newer, 1);
const auto refused = Read(newer.str());
EXPECT_EQ(refused.version, 49);
EXPECT_FALSE(refused.hasLighting);
}
// A chunked file without lighting has none of the environment's parts read
TEST_F(LevelTests, NoEnvironmentWithoutLighting) {
auto data = WriteChunked(48);
std::memset(data.data() + 96, 0, 4); // lighting offset
const auto level = Read(data);
EXPECT_FALSE(level.hasLighting);
EXPECT_TRUE(level.skydome.filename.empty());
EXPECT_TRUE(level.editorColors.empty());
EXPECT_EQ(level.objects.size(), 3);
}