diff --git a/dZoneManager/Zone.cpp b/dZoneManager/Zone.cpp index 5dc92b525..794053d24 100644 --- a/dZoneManager/Zone.cpp +++ b/dZoneManager/Zone.cpp @@ -11,6 +11,7 @@ #include "dConfig.h" #include "AssetManager.h" +#include "CDClientDatabase.h" #include "CDClientManager.h" #include "CDZoneTableTable.h" #include "Spawner.h" @@ -30,6 +31,13 @@ Zone::Zone(const LWOZONEID zoneID) : m_CheckSum = 0; m_WorldID = 0; m_SceneCount = 0; + m_SceneTableLookup = [](uint32_t sceneTableID) -> std::optional { + auto query = CDClientDatabase::CreatePreppedStmt("SELECT sceneName FROM SceneTable WHERE sceneID = ?;"); + query.bind(1, static_cast(sceneTableID)); + auto result = query.execQuery(); + if (result.eof() || result.fieldIsNull("sceneName")) return std::nullopt; + return std::string(result.getStringField("sceneName")); + }; } Zone::~Zone() { @@ -56,6 +64,8 @@ void Zone::LoadZoneIntoMemory() { if (file) { BinaryIO::BinaryRead(file, m_FileFormatVersion); + // The client reads anything older than 20 as 20 (LuzFile::ReadLUZFile) + if (m_FileFormatVersion < Zone::FileFormatVersion::Oldest) m_FileFormatVersion = Zone::FileFormatVersion::Oldest; uint32_t mapRevision = 0; if (m_FileFormatVersion >= Zone::FileFormatVersion::Alpha) BinaryIO::BinaryRead(file, mapRevision); @@ -78,8 +88,9 @@ void Zone::LoadZoneIntoMemory() { } else BinaryIO::BinaryRead(file, m_SceneCount); for (uint32_t i = 0; i < m_SceneCount; ++i) { - LoadScene(file); + LoadScene(file, i); } + KeepLoadedScenes(); // Zone boundary lines: a count byte, then the lines uint8_t numBoundaries = 0; @@ -268,21 +279,29 @@ void Zone::AddRevision(LWOSCENEID sceneID, uint32_t revision) { } } -void Zone::LoadScene(std::istream& file) { +void Zone::LoadScene(std::istream& file, uint32_t index) { SceneRef scene; scene.level = nullptr; - LWOSCENEID lwoSceneID(LWOZONEID_INVALID, 0); + + // Before PrePreAlpha a scene is only its SceneTable ID; KeepLoadedScenes finds its file (LuzReader::ReadScenes) + if (m_FileFormatVersion < Zone::FileFormatVersion::PrePreAlpha) { + BinaryIO::BinaryRead(file, scene.sceneTableID); + scene.id = scene.sceneTableID; + m_Scenes[LWOSCENEID(scene.id, 0)] = std::move(scene); + return; + } BinaryIO::ReadString(file, scene.filename, BinaryIO::ReadType::String); std::string luTriggersPath = scene.filename.substr(0, scene.filename.size() - 4) + ".lutriggers"; if (Game::assetManager->HasFile((m_ZonePath + luTriggersPath).c_str())) LoadLUTriggers(luTriggersPath, scene); - if (m_FileFormatVersion >= Zone::FileFormatVersion::LatePreAlpha || m_FileFormatVersion < Zone::FileFormatVersion::PrePreAlpha) { + // Until LatePreAlpha the file has no scene IDs: a scene's ID is its index (-1 past 255) in the file + scene.id = index < 256 ? index : UINT32_MAX; + LWOSCENEID lwoSceneID(scene.id, 0); + if (m_FileFormatVersion >= Zone::FileFormatVersion::LatePreAlpha) { BinaryIO::BinaryRead(file, scene.id); lwoSceneID.SetSceneID(scene.id); - } - if (m_FileFormatVersion >= Zone::FileFormatVersion::LatePreAlpha) { BinaryIO::BinaryRead(file, scene.sceneType); lwoSceneID.SetLayerID(static_cast(scene.sceneType)); @@ -306,6 +325,37 @@ void Zone::LoadScene(std::istream& file) { m_Scenes[lwoSceneID] = std::move(scene); } +void Zone::KeepLoadedScenes() { + if (m_FileFormatVersion < Zone::FileFormatVersion::PrePreAlpha) { + // ZoneLoader::ReadZoneFile: the scenes go by their SceneTable ID; each one with a row there gets the next index + // (0, 1, ...; -1 past 255) as its ID and the row's sceneName as its file, the others are left out + std::map resolved; + uint32_t index = 0; + for (auto& scene : m_Scenes | std::views::values) { + const auto sceneName = m_SceneTableLookup(scene.sceneTableID); + if (!sceneName) continue; + scene.id = index < 256 ? index : UINT32_MAX; + scene.filename = *sceneName; + scene.name = *sceneName; + const std::string luTriggersPath = scene.filename.substr(0, scene.filename.size() - 4) + ".lutriggers"; + if (Game::assetManager->HasFile((m_ZonePath + luTriggersPath).c_str())) LoadLUTriggers(luTriggersPath, scene); + resolved[LWOSCENEID(scene.id, 0)] = std::move(scene); + index++; + } + m_Scenes = std::move(resolved); + return; + } + + // LuzReader::ReadScenes keeps the scenes by scene ID and layer, a later one replacing an earlier one (as m_Scenes + // does). ZoneLoader::ReadZoneFile then goes through them by scene ID: a scene ID with no layer 0 (General) scene is + // left out, and before Latest only the layer 0 scene is loaded + std::erase_if(m_Scenes, [this](const auto& entry) { + const auto& [sceneID, scene] = entry; + if (!m_Scenes.contains(LWOSCENEID(sceneID.GetSceneID(), 0))) return true; + return sceneID.GetLayerID() != 0 && m_FileFormatVersion < Zone::FileFormatVersion::Latest; + }); +} + void Zone::LoadLUTriggers(std::string triggerFile, SceneRef& scene) { auto file = Game::assetManager->GetFile((m_ZonePath + triggerFile).c_str()); diff --git a/dZoneManager/Zone.h b/dZoneManager/Zone.h index e469a5d63..7a0613b6f 100644 --- a/dZoneManager/Zone.h +++ b/dZoneManager/Zone.h @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include "Raw.h" namespace LUTriggers { @@ -31,6 +33,7 @@ enum class eSceneType : uint32_t { struct SceneRef { std::string filename; uint32_t id{}; + uint32_t sceneTableID{}; // before PrePreAlpha: the scene's row in the CDClient SceneTable eSceneType sceneType{}; std::string name; NiPoint3 unknown1; @@ -204,6 +207,7 @@ struct Path { class Zone { public: enum class FileFormatVersion : uint32_t { //Times are guessed. + Oldest = 20, // the client reads anything older as this PrePreAlpha = 30, PreAlpha = 32, LatePreAlpha = 33, @@ -231,6 +235,10 @@ public: LUTriggers::Trigger* GetTrigger(uint32_t sceneID, uint32_t triggerID) const; const Path* GetPath(std::string name) const; + // The CDClient SceneTable's sceneName for a scene of a file from before PrePreAlpha; the database's by default + using SceneTableLookup = std::function(uint32_t sceneTableID)>; + void SetSceneTableLookup(SceneTableLookup lookup) { m_SceneTableLookup = std::move(lookup); } + uint32_t GetWorldID() const { return m_WorldID; } [[nodiscard]] std::string GetZoneName() const { return m_ZoneName; } std::string GetZoneRawPath() const { return m_ZoneRawPath; } @@ -276,7 +284,9 @@ private: std::map m_MapRevisions; //rhs is the revision! //private ("helper") functions: - void LoadScene(std::istream& file); + SceneTableLookup m_SceneTableLookup; + void LoadScene(std::istream& file, uint32_t index); + void KeepLoadedScenes(); void LoadLUTriggers(std::string triggerFile, SceneRef& scene); void LoadSceneTransition(std::istream& file); SceneTransitionInfo LoadSceneTransitionInfo(std::istream& file); diff --git a/tests/dGameTests/dZoneManagerTests/CMakeLists.txt b/tests/dGameTests/dZoneManagerTests/CMakeLists.txt index 009ec7ddc..0cf999c72 100644 --- a/tests/dGameTests/dZoneManagerTests/CMakeLists.txt +++ b/tests/dGameTests/dZoneManagerTests/CMakeLists.txt @@ -1,5 +1,6 @@ SET(DZONEMANAGER_TESTS "RawTests.cpp" + "ZoneFileVersionTests.cpp" "ZoneSceneTests.cpp") # Get the folder name and prepend it to the files above diff --git a/tests/dGameTests/dZoneManagerTests/ZoneFileVersionTests.cpp b/tests/dGameTests/dZoneManagerTests/ZoneFileVersionTests.cpp new file mode 100644 index 000000000..faa58bb5a --- /dev/null +++ b/tests/dGameTests/dZoneManagerTests/ZoneFileVersionTests.cpp @@ -0,0 +1,157 @@ +#include "GameDependencies.h" + +#include +#include +#include + +#include "AssetManager.h" +#include "CDZoneTableTable.h" +#include "Zone.h" +#include "dZoneManager.h" + +// The zone table's storage (CDZoneTableTable.cpp), to add the test zone +namespace CDZoneTableTable { + extern Table entries; +} + +namespace { + template + void Write(std::ostream& stream, const T& value) { + stream.write(reinterpret_cast(&value), sizeof(T)); + } + + void WriteString8(std::ostream& stream, const std::string& value) { + Write(stream, static_cast(value.size())); + stream.write(value.data(), value.size()); + } + + struct TestScene { + std::string filename; + uint32_t id = 0; + uint32_t layer = 0; + }; + + // A zone file of a version from 31 on with the given scenes and no transitions or paths + std::string WriteZone(uint32_t version, const std::vector& scenes) { + std::ostringstream stream; + Write(stream, version); + if (version >= 36) Write(stream, 1); // revision + Write(stream, 1); + if (version >= 38) { + Write(stream, NiPoint3(0.0f, 0.0f, 0.0f)); // spawn position + for (const float value : { 1.0f, 0.0f, 0.0f, 0.0f }) Write(stream, value); // spawn rotation + } + if (version >= 38) Write(stream, static_cast(scenes.size())); + else Write(stream, static_cast(scenes.size())); + for (const auto& scene : scenes) { + WriteString8(stream, scene.filename); + if (version >= 33) { + Write(stream, scene.id); + Write(stream, scene.layer); + WriteString8(stream, scene.filename); + for (const uint8_t color : { 1, 2, 3 }) Write(stream, color); + } + } + Write(stream, 0); // boundary lines + WriteString8(stream, "test.raw"); + WriteString8(stream, "Test Zone"); + WriteString8(stream, "A zone for tests"); + if (version >= 32) Write(stream, 0); // transitions + if (version >= 35) { + Write(stream, 8); // path chunk length + Write(stream, 1); // path chunk version + Write(stream, 0); // paths + } + return stream.str(); + } +} + +// Loads a written .luz through the zone manager +class ZoneFileVersionTest : public GameDependenciesTest { +protected: + static constexpr LWOMAPID TEST_MAP = 9993; + std::filesystem::path root; + + void SetUp() override { + SetUpDependencies(); + root = std::filesystem::temp_directory_path() / (std::string("dlu_zone_version_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(); + } + + Zone* LoadZone(const std::string& data, Zone::SceneTableLookup lookup = nullptr) const { + std::ofstream(root / "res" / "maps" / "testzone" / "test.luz", std::ios::binary).write(data.data(), data.size()); + Game::zoneManager->LoadZone(LWOZONEID(TEST_MAP, 0, 0)); + Zone* zone = Game::zoneManager->GetZoneMut(); + if (lookup) zone->SetSceneTableLookup(std::move(lookup)); + zone->LoadZoneIntoMemory(); + return zone; + } + + static std::vector> Scenes(const Zone* zone) { + std::vector> scenes; + for (const auto& [id, scene] : zone->GetScenes()) scenes.emplace_back(id, scene.filename); + return scenes; + } +}; + +// Until version 33 a zone file has no scene IDs: each scene's ID is its index in the file +TEST_F(ZoneFileVersionTest, OldScenesAreNumberedByTheirIndex) { + const Zone* zone = LoadZone(WriteZone(31, { { "a.lvl" }, { "b.lvl" } })); + const std::vector> expected = { { LWOSCENEID(0, 0), "a.lvl" }, { LWOSCENEID(1, 0), "b.lvl" } }; + EXPECT_EQ(Scenes(zone), expected); + EXPECT_EQ(zone->GetZoneName(), "Test Zone"); +} + +// A later scene with the same ID and layer replaces an earlier one; a scene ID without a layer 0 scene isn't loaded, +// and before version 41 only the layer 0 scenes are +TEST_F(ZoneFileVersionTest, KeepsTheScenesTheClientLoads) { + const std::vector scenes = { + { "first.lvl", 1, 0 }, { "audio.lvl", 1, 1 }, { "orphan_audio.lvl", 2, 1 }, { "second.lvl", 1, 0 }, { "three.lvl", 3, 0 }, + }; + const std::vector> latest = { + { LWOSCENEID(1, 0), "second.lvl" }, { LWOSCENEID(1, 1), "audio.lvl" }, { LWOSCENEID(3, 0), "three.lvl" }, + }; + EXPECT_EQ(Scenes(LoadZone(WriteZone(41, scenes))), latest); + + const std::vector> older = { { LWOSCENEID(1, 0), "second.lvl" }, { LWOSCENEID(3, 0), "three.lvl" } }; + EXPECT_EQ(Scenes(LoadZone(WriteZone(40, scenes))), older); +} + +// Before version 30 a scene is a SceneTable ID: in ID order, each one with a row gets the next index as its ID and the +// row's scene name as its file; the others aren't loaded. A file older than version 20 is read as 20 +TEST_F(ZoneFileVersionTest, OldestScenesComeFromTheSceneTable) { + for (const uint32_t version : { 25u, 10u }) { + std::ostringstream stream; + Write(stream, version); + Write(stream, 1); // world ID + Write(stream, 3); + for (const uint32_t sceneTableID : { 7u, 9u, 3u }) Write(stream, sceneTableID); + Write(stream, 0); // boundary lines + WriteString8(stream, "test.raw"); + Write(stream, 0); // trailing bytes: older readers read a name and description here + const Zone* zone = LoadZone(stream.str(), [](uint32_t sceneTableID) -> std::optional { + if (sceneTableID == 3) return "three.lvl"; + if (sceneTableID == 7) return "seven.lvl"; + return std::nullopt; + }); + + const std::vector> expected = { { LWOSCENEID(0, 0), "three.lvl" }, { LWOSCENEID(1, 0), "seven.lvl" } }; + EXPECT_EQ(Scenes(zone), expected) << version; + EXPECT_EQ(zone->GetZoneRawPath(), "test.raw") << version; + } +} diff --git a/tests/dGameTests/dZoneManagerTests/ZoneSceneTests.cpp b/tests/dGameTests/dZoneManagerTests/ZoneSceneTests.cpp index 12d161552..8341d430c 100644 --- a/tests/dGameTests/dZoneManagerTests/ZoneSceneTests.cpp +++ b/tests/dGameTests/dZoneManagerTests/ZoneSceneTests.cpp @@ -51,14 +51,14 @@ protected: 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 + // Scenes 0 (global), 1, 2 and 3 (general) and 3 (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 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 }, + { "audio.lvl", 3, 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) } }, @@ -123,7 +123,7 @@ TEST_F(ZoneSceneTest, ReadsScenesBoundariesAndTransitions) { 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)); + const SceneRef* audio = zone->GetScene(LWOSCENEID(3, 1)); ASSERT_NE(audio, nullptr); EXPECT_EQ(audio->sceneType, eSceneType::Audio); EXPECT_EQ(zone->GetScene(LWOSCENEID(4, 0)), nullptr); @@ -151,7 +151,7 @@ TEST_F(ZoneSceneTest, ReadsOlderZoneVersions) { WriteZone("test.raw", 2, version); WriteFile("test.raw", MakeRaw()); const Zone* zone = LoadZone(); - EXPECT_EQ(zone->GetScenes().size(), 5) << version; + EXPECT_EQ(zone->GetScenes().size(), 4) << version; // before Latest only layer 0 scenes are loaded EXPECT_EQ(zone->GetBoundaries().size(), 2) << version; EXPECT_EQ(zone->GetZoneRawPath(), "test.raw") << version; EXPECT_EQ(zone->GetZoneName(), "Test Zone") << version; @@ -185,7 +185,7 @@ TEST_F(ZoneSceneTest, SceneGraphFromTransitions) { const auto& graph = Game::zoneManager->GetSceneAdjacencyList(); EXPECT_EQ(graph.size(), 4); // the audio scene is left out - EXPECT_FALSE(graph.contains(LWOSCENEID(5, 1))); + EXPECT_FALSE(graph.contains(LWOSCENEID(3, 1))); const auto one = Game::zoneManager->GetAdjacentScenes(LWOSCENEID(1, 0)); EXPECT_EQ(one.size(), 2);