diff --git a/dCommon/LevelFile.cpp b/dCommon/LevelFile.cpp index 140c82b63..a0c5c91d1 100644 --- a/dCommon/LevelFile.cpp +++ b/dCommon/LevelFile.cpp @@ -6,104 +6,98 @@ #include "BinaryIO.h" #include "GeneralUtils.h" +#include "Logger.h" + +namespace { + constexpr uint32_t CHNK_HEADER = ('C' + ('H' << 8) + ('N' << 16) + ('K' << 24)); + + // A fixed size, zero padded name + std::string ReadName64(std::istream& file) { + std::string name(64, '\0'); + file.read(name.data(), static_cast(name.size())); + if (file.fail()) throw std::runtime_error("Failed to read from istream."); + name.resize(std::min(name.find('\0'), name.size())); + return name; + } + + // A u32 count of wide characters + std::string ReadWideString32(std::istream& file) { + std::u16string value; + BinaryIO::ReadString(file, value); + if (file.fail()) throw std::runtime_error("Failed to read from istream."); + return GeneralUtils::UTF16ToWTF8(value); + } +} void LevelFile::Read(std::istream& file) { - const uint32_t CHNK_HEADER = ('C' + ('H' << 8) + ('N' << 16) + ('K' << 24)); - uint32_t magic = 0; BinaryIO::BinaryRead(file, magic); if (magic == CHNK_HEADER) { - // SceneLoader::ReadLvlFile / DoLvlChunk: the file info chunk starts the file and gives where the environment, - // object and particle chunks start (0 for none); only the objects are read here + // SceneLoader::LoadLvlFile / LvlChunkFile::Read: the file info chunk starts the file and gives where the + // environment, object and particle chunks start (0 for none) ChunkHeader fileInfo = ReadChunkHeader(file, 0); file.seekg(fileInfo.startPosition); ReadFileInfoChunk(file, fileInfo); chunkHeaders.insert({ fileInfo.id, fileInfo }); - for (const auto start : { fileInfo.fileInfo.enviromentChunkStart, fileInfo.fileInfo.objectChunkStart, fileInfo.fileInfo.particleChunkStart }) { - if (start == 0) continue; - const auto header = ReadChunkHeader(file, start); + version = fileInfo.fileInfo.version; + revision = fileInfo.fileInfo.revision; + + if (fileInfo.fileInfo.enviromentChunkStart != 0) { + const auto header = ReadChunkHeader(file, fileInfo.fileInfo.enviromentChunkStart); chunkHeaders.insert({ header.id, header }); - if (start != fileInfo.fileInfo.objectChunkStart) continue; - // The objects are laid out by the file's version, from its file info chunk + // EnvironmentChunk::ReadLvlChunk2000: where the lighting, skydome and editor settings start file.seekg(header.startPosition); - ReadSceneObjectDataChunk(file, fileInfo.fileInfo.version); + BinaryIO::BinaryRead(file, enviroment.lightingStart); + BinaryIO::BinaryRead(file, enviroment.skydomeStart); + BinaryIO::BinaryRead(file, enviroment.editorSettingsStart); + // The client reads all three only when there is lighting + if (enviroment.lightingStart != 0) { + file.seekg(enviroment.lightingStart); + ReadLightingInfo(file); + file.seekg(enviroment.skydomeStart); + ReadSkydomeInfo(file); + file.seekg(enviroment.editorSettingsStart); + ReadEditorSettings(file); + } + } + + if (fileInfo.fileInfo.objectChunkStart != 0) { + const auto header = ReadChunkHeader(file, fileInfo.fileInfo.objectChunkStart); + chunkHeaders.insert({ header.id, header }); + file.seekg(header.startPosition); + ReadSceneObjectDataChunk(file); + } + + if (fileInfo.fileInfo.particleChunkStart != 0) { + const auto header = ReadChunkHeader(file, fileInfo.fileInfo.particleChunkStart); + chunkHeaders.insert({ header.id, header }); } return; } - // Scenes from before chunks (SceneLoader::ReadLvlSections): a header, then lighting, skydome and editor settings - // the world skips, then the objects + // Scenes from before chunks: a header (SceneLoader::ReadLvlHeader), then each part in turn file.seekg(0); ChunkHeader header; header.id = ChunkTypeID::FileInfo; BinaryIO::BinaryRead(file, header.chunkVersion); BinaryIO::BinaryRead(file, header.chunkType); - // Only from version 32 (SceneLoader::ReadLvlHeader) - uint8_t important = 0; - if (header.chunkVersion >= 32) BinaryIO::BinaryRead(file, important); - if (header.chunkVersion > 36) { - BinaryIO::BinaryRead(file, header.fileInfo.revision); - } - // Lighting (SceneLoader::ReadLightingInfo) - if (header.chunkVersion >= 45) file.ignore(4); - file.ignore(4 * (4 * 3)); + version = header.chunkVersion; + if (version >= 32) BinaryIO::BinaryRead(file, noEditorSettings); + if (version > 36) BinaryIO::BinaryRead(file, revision); + header.fileInfo.version = version; + header.fileInfo.revision = revision; + chunkHeaders.insert({ header.id, header }); - if (header.chunkVersion >= 31) { - if (header.chunkVersion >= 39) { - file.ignore(12 * 4); - - if (header.chunkVersion >= 40) { - uint32_t s = 0; - BinaryIO::BinaryRead(file, s); - for (uint32_t i = 0; i < s; ++i) { - file.ignore(4 * 3); //a uint and two floats - } - } - } else { - file.ignore(8); - } - - file.ignore(3 * 4); + // A newer file is refused ("newer than the client understands") + if (version > 48) { + LOG("Scene file version %u is newer than the client reads; nothing read from it", version); + return; } - if (header.chunkVersion >= 36) { - file.ignore(3 * 4); - } - - if (header.chunkVersion < 42) { - file.ignore(3 * 4); - - if (header.chunkVersion >= 33) { - file.ignore(4 * 4); - } - } - - // skydome info; five more strings from version 34 (SceneLoader::ReadSkydomeInfo) - uint32_t count = 0; - BinaryIO::BinaryRead(file, count); - file.ignore(count); - - if (header.chunkVersion >= 34) { - for (uint32_t i = 0; i < 5; ++i) { - uint32_t count = 0; - BinaryIO::BinaryRead(file, count); - file.ignore(count); - } - } - // editor settings: their size and then that many bytes (SceneLoader::ReadEditorSettings) - if (!important && header.chunkVersion >= 37) { - uint32_t size = 0; - BinaryIO::BinaryRead(file, size); - file.ignore(size); - } - - // The client has no objects for a file older than version 3 ("Level file is unsupported") - header.fileInfo.version = header.chunkVersion; - if (header.chunkVersion >= 3) { - header.id = ChunkTypeID::SceneObjectData; - ReadSceneObjectDataChunk(file, header.fileInfo.version); - } - chunkHeaders.insert(std::make_pair(header.id, header)); + ReadLightingInfo(file); + ReadSkydomeInfo(file); + ReadEditorSettings(file); + ReadSceneObjectDataChunk(file); } LevelFile::ChunkHeader LevelFile::ReadChunkHeader(std::istream& file, uint32_t start) { @@ -128,88 +122,127 @@ void LevelFile::ReadFileInfoChunk(std::istream& file, ChunkHeader& header) { BinaryIO::BinaryRead(file, header.fileInfo.particleChunkStart); } -void LevelFile::ApplyClientConfigFixups(SceneObject& obj, uint32_t version) { - // What ReadLvlObjectData (1.10.64, 0x0103ba20) changes in an object's config as it loads it - auto& settings = obj.settings; - const auto typeOf = [&settings](const std::u16string& key) { - const auto it = settings.find(key); - return it == settings.end() || !it->second ? LDF_TYPE_UNKNOWN : it->second->GetValueType(); - }; - const auto valueOf = [&settings](const std::u16string& key) { - return static_cast*>(settings.find(key)->second.get())->GetValue(); - }; +void LevelFile::ReadLightingInfo(std::istream& file) { + // SceneLoader::ReadLightingInfo (0x0102f8f0) + hasLighting = true; + if (version >= 45) BinaryIO::BinaryRead(file, lighting.blendTime); + BinaryIO::BinaryRead(file, lighting.ambient); + BinaryIO::BinaryRead(file, lighting.specular); + BinaryIO::BinaryRead(file, lighting.upperHemi); + BinaryIO::BinaryRead(file, lighting.position); - // Before version 47 a respawn time was in milliseconds: a float over 100, or any u32, becomes seconds (a float) - if (version < 47) { - if (typeOf(u"respawn") == LDF_TYPE_FLOAT) { - const auto respawn = valueOf.operator()(u"respawn"); - if (respawn > 100.0f) settings.Insert(u"respawn", respawn * 0.001f); - } else if (typeOf(u"respawn") == LDF_TYPE_U32) { - settings.Insert(u"respawn", static_cast(valueOf.operator()(u"respawn") * 0.001)); + if (version >= 31) { + if (version < 39) { + BinaryIO::BinaryRead(file, lighting.maxDrawDistances.fogNear); + BinaryIO::BinaryRead(file, lighting.maxDrawDistances.fogFar); + lighting.minDrawDistances.fogNear = lighting.maxDrawDistances.fogNear; + lighting.minDrawDistances.fogFar = lighting.maxDrawDistances.fogFar; + } else { + for (auto* distances : { &lighting.minDrawDistances, &lighting.maxDrawDistances }) { + BinaryIO::BinaryRead(file, distances->fogNear); + BinaryIO::BinaryRead(file, distances->fogFar); + BinaryIO::BinaryRead(file, distances->postFogSolid); + BinaryIO::BinaryRead(file, distances->postFogFade); + BinaryIO::BinaryRead(file, distances->staticObjectDistance); + BinaryIO::BinaryRead(file, distances->dynamicObjectDistance); + } + + if (version >= 40) { + uint32_t count = 0; + BinaryIO::BinaryRead(file, count); + for (uint32_t i = 0; i < count; ++i) { + SceneCullSetting setting; + BinaryIO::BinaryRead(file, setting.group); + BinaryIO::BinaryRead(file, setting.minDistance); + BinaryIO::BinaryRead(file, setting.maxDistance); + lighting.cullSettings.push_back(setting); + } + } } + BinaryIO::BinaryRead(file, lighting.fogColor); } - // A model spawner (LOT 176 spawning template 14): its blueprint is its subkey unless it has one, its behaviors are - // off unless it says otherwise, and it never wraps its render - if (obj.lot == 176 && typeOf(u"spawntemplate") == LDF_TYPE_S32 && valueOf.operator()(u"spawntemplate") == 14) { - if (typeOf(u"blueprintid") == LDF_TYPE_UNKNOWN) { - const LWOOBJID subkey = typeOf(u"subkey") == LDF_TYPE_OBJID ? valueOf.operator()(u"subkey") : LWOOBJID_EMPTY; - settings.Insert(u"blueprintid", subkey); - } - if (typeOf(u"DisableModelBehaviors") == LDF_TYPE_UNKNOWN) settings.Insert(u"DisableModelBehaviors", true); - settings.Insert(u"preventRenderWrapping", true); - } + if (version >= 36) BinaryIO::BinaryRead(file, lighting.directional); - // Before version 44 an object that overrides its scene goes to layer 0 of the scene it names - if (version < 44 && typeOf(u"sceneIDOverrideEnabled") != LDF_TYPE_UNKNOWN) settings.Insert(u"sceneLayerIDOverride", 0); + if (version < 42) { + lighting.hasSpawnPoint = true; + BinaryIO::BinaryRead(file, lighting.filePosition); + if (version < 33) { + lighting.spawnPosition = NiPoint3(347.0f, 284.0f, -325.0f); + lighting.spawnRotation = NiQuaternion(0.97082251f, 0.0f, 0.23979919f, 0.0f); + } else { + lighting.spawnPosition = lighting.filePosition; + BinaryIO::BinaryRead(file, lighting.spawnRotation); + } + } } -void LevelFile::ReadSceneObjectDataChunk(std::istream& file, uint32_t version) { +void LevelFile::ReadSkydomeInfo(std::istream& file) { + // SceneLoader::ReadSkydomeInfo (0x0102f550) + BinaryIO::ReadString(file, skydome.filename, BinaryIO::ReadType::String); + if (version >= 34) { + for (auto& filename : skydome.extraFilenames) BinaryIO::ReadString(file, filename, BinaryIO::ReadType::String); + } +} + +void LevelFile::ReadEditorSettings(std::istream& file) { + // SceneLoader::ReadEditorSettings (0x0101b090): their size, then a count of colours; the client keeps the first 32 + if (version < 37 || noEditorSettings != 0) return; + uint32_t size = 0; + BinaryIO::BinaryRead(file, size); + uint32_t count = 0; + BinaryIO::BinaryRead(file, count); + for (uint32_t i = 0; i < count; ++i) { + NiColor color; + BinaryIO::BinaryRead(file, color); + if (i < 32) editorColors.push_back(color); + } +} + +void LevelFile::ReadSceneObjectDataChunk(std::istream& file) { + // SceneLoader::ReadLvlGameObjects: the client has no objects for a file older than version 3 ("Level file is + // unsupported"), and before chunks reads nothing after them either + if (version < 3) return; + uint32_t objectsCount = 0; BinaryIO::BinaryRead(file, objectsCount); for (uint32_t i = 0; i < objectsCount; ++i) { - std::u16string ldfString; + // SceneLoader::ReadLvlObjectData (0x0103ba20) SceneObject obj; BinaryIO::BinaryRead(file, obj.id); BinaryIO::BinaryRead(file, obj.lot); if (version >= 38) { - int32_t tmp = 1; - BinaryIO::BinaryRead(file, tmp); - if (tmp > -1 && tmp < 11) obj.nodeType = tmp; + int32_t nodeType = 1; + BinaryIO::BinaryRead(file, nodeType); + if (nodeType > -1 && nodeType < 11) obj.nodeType = nodeType; } - if (version >= 32) { - BinaryIO::BinaryRead(file, obj.glomId); - } + if (version >= 32) BinaryIO::BinaryRead(file, obj.glomId); BinaryIO::BinaryRead(file, obj.position); BinaryIO::BinaryRead(file, obj.rotation); BinaryIO::BinaryRead(file, obj.scale); - BinaryIO::ReadString(file, ldfString); - // From version 7 the object's render techniques: a count, and when there are any a 64 byte header and 133 bytes - // each (64 byte name, u32, u8, 16 floats) (ReadLvlObjectData) - if (version > 6) { - BinaryIO::BinaryRead(file, obj.renderTechniqueCount); - if (obj.renderTechniqueCount != 0) { - std::string techniques(64 + static_cast(obj.renderTechniqueCount) * (64 + 4 + 1 + 64), '\0'); - file.read(techniques.data(), static_cast(techniques.size())); + const auto config = ReadWideString32(file); + + if (version >= 7) { + uint32_t techniqueCount = 0; + BinaryIO::BinaryRead(file, techniqueCount); + if (techniqueCount != 0) { + obj.renderTechniquesName = ReadName64(file); + obj.renderTechniques.resize(techniqueCount); + for (auto& technique : obj.renderTechniques) { + technique.name = ReadName64(file); + BinaryIO::BinaryRead(file, technique.valueCount); + BinaryIO::BinaryRead(file, technique.isColor); + for (auto& value : technique.values) BinaryIO::BinaryRead(file, value); + } } } if (file.fail()) throw std::runtime_error("Failed to read from istream."); - // LDF_FROM_STRING (0x010fa220): entries are split at every comma and line break, empty ones and ones without a - // '=' are left out - const auto config = GeneralUtils::UTF16ToWTF8(ldfString); - size_t start = 0; - while (start <= config.size()) { - const auto end = std::min(config.find_first_of(",\n", start), config.size()); - const auto entry = config.substr(start, end - start); - if (entry.find('=') != std::string::npos) obj.settings.ParseInsert(entry); - start = end + 1; - } - ApplyClientConfigFixups(obj, version); + for (const auto& token : GeneralUtils::SplitString(config, '\n')) obj.settings.ParseInsert(token); objects.push_back(std::move(obj)); } diff --git a/dCommon/LevelFile.h b/dCommon/LevelFile.h index 98f992b29..c83573ba6 100644 --- a/dCommon/LevelFile.h +++ b/dCommon/LevelFile.h @@ -1,31 +1,89 @@ #pragma once +#include #include #include #include +#include #include #include "dCommonVars.h" +#include "NiColor.h" #include "NiPoint3.h" #include "NiQuaternion.h" #include "LDFFormat.h" +// One of an object's render techniques (SceneLoader::ReadLvlObjectData, version 7 on) +struct LevelRenderTechnique { + std::string name; // 64 bytes in the file + uint32_t valueCount{}; + uint8_t isColor{}; + std::array values{}; +}; + struct SceneObject { - LWOOBJID id; - LOT lot; + LWOOBJID id{}; + LOT lot{}; uint32_t nodeType = 1; // the client's default when the file has none, or one outside 0-10 uint32_t glomId = 1; NiPoint3 position; NiQuaternion rotation = QuatUtils::IDENTITY; float scale = 1.0f; - uint32_t renderTechniqueCount{}; + std::string renderTechniquesName; // 64 bytes in the file, only there when there are render techniques + std::vector renderTechniques; LwoNameValue settings; }; +// Fog and object draw distances (SceneDrawDistances); -1 is the client's default for a value the file doesn't have +struct SceneDrawDistances { + float fogNear = -1.0f; + float fogFar = -1.0f; + float postFogSolid = -1.0f; + float postFogFade = -1.0f; + float staticObjectDistance = -1.0f; + float dynamicObjectDistance = -1.0f; +}; + +// The draw distances of a group of objects (version 40 on) +struct SceneCullSetting { + uint32_t group{}; + float minDistance{}; + float maxDistance{}; +}; + +// A scene's light environment (SceneLoader::ReadLightingInfo); the defaults are the client's for what older files don't have +struct LevelLighting { + float blendTime = 10.0f; // version 45 on + NiColor ambient; + NiColor specular; + NiColor upperHemi; + NiPoint3 position; + // Version 31 on; before 39 only the fog distances, the same in both + SceneDrawDistances minDrawDistances; + SceneDrawDistances maxDrawDistances; + std::vector cullSettings; // version 40 on + NiColor fogColor{ 0.75f, 0.88f, 0.91f }; // version 31 on + NiColor directional; // version 36 on + + // Before version 42 the zone's spawn point is here. Before 33 the file has only a position, which the client + // ignores: it uses a fixed spawn point instead + bool hasSpawnPoint = false; + NiPoint3 filePosition; + NiPoint3 spawnPosition; + NiQuaternion spawnRotation = QuatUtils::IDENTITY; +}; + +// The skydome's file and five more (version 34 on) the client reads and doesn't use (SceneLoader::ReadSkydomeInfo) +struct LevelSkydome { + std::string filename; + std::array extraFilenames; +}; + /** - * A scene file (.lvl) as the world server loads it (Level): its chunk headers and every object in it, with the object's - * settings parsed. Only reads; which objects the world spawns (feature gating, client-only objects, spawners) stays in - * dZoneManager, so the dashboard reads scenes with the same code. + * A scene file (.lvl), read as the client reads it (SceneLoader::LoadLvlFile): its chunk headers, environment, every + * object in it with the object's settings parsed. Only reads; which objects the world + * spawns (feature gating, client-only objects, spawners) stays in dZoneManager, so the dashboard reads scenes with the + * same code. */ struct LevelFile { enum ChunkTypeID : uint16_t { @@ -52,7 +110,26 @@ struct LevelFile { FileInfoChunk fileInfo; }; + // The environment chunk: where its parts start in the file (0 for none) + struct EnviromentChunk { + uint32_t lightingStart{}; + uint32_t skydomeStart{}; + uint32_t editorSettingsStart{}; + }; + std::map chunkHeaders; + + // The file's version: from the file info chunk, or the header of a file from before chunks + uint32_t version{}; + uint32_t revision{}; + // Before chunks, from version 32: set when the file has no editor settings + uint8_t noEditorSettings{}; + EnviromentChunk enviroment; + bool hasLighting = false; + LevelLighting lighting; + LevelSkydome skydome; + // Version 37 on: the editor's saved colours (the client keeps at most 32) + std::vector editorColors; std::vector objects; /** @@ -64,6 +141,8 @@ struct LevelFile { private: static ChunkHeader ReadChunkHeader(std::istream& file, uint32_t start); void ReadFileInfoChunk(std::istream& file, ChunkHeader& header); - void ReadSceneObjectDataChunk(std::istream& file, uint32_t version); - static void ApplyClientConfigFixups(SceneObject& obj, uint32_t version); + void ReadLightingInfo(std::istream& file); + void ReadSkydomeInfo(std::istream& file); + void ReadEditorSettings(std::istream& file); + void ReadSceneObjectDataChunk(std::istream& file); }; diff --git a/dZoneManager/Level.cpp b/dZoneManager/Level.cpp index d76a6753c..786b8e6a8 100644 --- a/dZoneManager/Level.cpp +++ b/dZoneManager/Level.cpp @@ -20,6 +20,7 @@ #include "PhysicsComponent.h" #include "dConfig.h" #include +#include Level::Level(Zone* parentZone, const std::string& filepath, int32_t sceneID) { m_ParentZone = parentZone; @@ -35,7 +36,12 @@ Level::Level(Zone* parentZone, const std::string& filepath, int32_t sceneID) { // The file itself is read by LevelFile (dCommon), which the dashboard uses too LevelFile levelFile; - levelFile.Read(stream); + try { + levelFile.Read(stream); + } catch (const std::runtime_error&) { + // The client reads past the end of such a file; what was read before it is kept + LOG("%s ends early; loading the %zu objects read before its end", filepath.c_str(), levelFile.objects.size()); + } for (const auto& [id, chunkHeader] : levelFile.chunkHeaders) { Header header; static_cast(header) = chunkHeader; diff --git a/tests/dGameTests/dZoneManagerTests/CMakeLists.txt b/tests/dGameTests/dZoneManagerTests/CMakeLists.txt index d22fdf554..79e573d36 100644 --- a/tests/dGameTests/dZoneManagerTests/CMakeLists.txt +++ b/tests/dGameTests/dZoneManagerTests/CMakeLists.txt @@ -1,4 +1,5 @@ SET(DZONEMANAGER_TESTS + "LevelTests.cpp" "RawTests.cpp" "ZoneFileVersionTests.cpp" "ZonePathTests.cpp" diff --git a/tests/dGameTests/dZoneManagerTests/LevelTests.cpp b/tests/dGameTests/dZoneManagerTests/LevelTests.cpp new file mode 100644 index 000000000..3f9d2c937 --- /dev/null +++ b/tests/dGameTests/dZoneManagerTests/LevelTests.cpp @@ -0,0 +1,323 @@ +#include "LevelFile.h" + +#include + +#include +#include + +namespace { + template + void Write(std::ostream& stream, const T& value) { + stream.write(reinterpret_cast(&value), sizeof(T)); + } + + void WriteFloats(std::ostream& stream, std::initializer_list 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(stream, static_cast(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(stream, static_cast(value.size())); + for (const char c : value) Write(stream, static_cast(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(stream, 1); + Write(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(stream, 4 + 12 * 2); // size + Write(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(stream, 12); // node type out of range: the client uses 1 + if (version >= 32) Write(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(stream, 4); + Write(stream, 1); + for (uint32_t j = 0; j < 16; ++j) Write(stream, static_cast(j)); + } + } + } + } + + void WriteParticle(std::ostream& stream, uint32_t version, const std::string& names, const std::string& config) { + if (version >= 43) Write(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(stream, 9); + 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(stream, 1); + Write(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(stream, 0xcd); + } + + // A scene file from before chunks + std::string WriteFlat(uint32_t version, bool withParticles = true) { + std::ostringstream stream; + Write(stream, static_cast(version)); + Write(stream, 0); + if (version >= 32) Write(stream, 0); // no editor settings flag + if (version > 36) Write(stream, 77); // revision + WriteLighting(stream, version); + WriteSkydome(stream, version); + if (version >= 37) WriteEditorSettings(stream); + if (version >= 3) { + Write(stream, 1); + WriteObject(stream, version, 1000, 2000, "respawn=5:20000\ncustom=0:a"); + } + if (version >= 5) { + Write(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(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(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(stream, 9); // revision + Write(stream, 64); // environment + Write(stream, 0); // objects, set below + Write(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(stream.tellp()); + WriteLighting(stream, version); + Pad(stream); + const uint32_t skydome = static_cast(stream.tellp()); + WriteSkydome(stream, version); + Pad(stream); + const uint32_t editor = static_cast(stream.tellp()); + WriteEditorSettings(stream); + Pad(stream); + + const uint32_t objects = static_cast(stream.tellp()); + WriteChunkHeader(stream, 2001, 0, objects + 32); + Pad(stream); + Write(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(stream.tellp()); + WriteChunkHeader(stream, 2002, 0, particles + 32); + Pad(stream); + Write(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(offsets), lighting); + patch(static_cast(offsets) + 4, skydome); + patch(static_cast(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; + } +} + +TEST(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); +} + +TEST(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); +} + +// Older lighting: fog distances only before 39, no directional colour before 36, and before 33 a fixed spawn point +TEST(LevelTests, ReadsOlderLighting) { + 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.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); +} + +// A file older than 3 has no objects, a newer one than 48 isn't read +TEST(LevelTests, ReadsWhatTheClientReadsOfOtherFiles) { + const auto old = Read(WriteFlat(2)); + EXPECT_TRUE(old.hasLighting); + EXPECT_TRUE(old.objects.empty()); + + std::ostringstream newer; + Write(newer, 49); + Write(newer, 0); + Write(newer, 0); + Write(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(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); +}