From 3e4232f90b1dd787abcbe184ed87b20c8fe5b1b6 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Mon, 26 Oct 2026 21:29:49 -0500 Subject: [PATCH] fix: read a zone's paths as the client does LuzFile::ReadLUZFile copies the path chunk by its size and ReadLUZPaths reads it with LevelPath::FromBuffer (1.10.64). Reading as they do: - the paths are read from their chunk, never past its end; - a chunk version of 2 or more, 10000 paths or more, or a path the client refuses (version 19 or more, 10000 waypoints or more, more than 99 name/value pairs on a waypoint) leaves the zone with no paths, logged; the rest of the zone file still reads; - before version 3 a path's type is a name ("platform" or a movement path) and every waypoint has a rotation, the moving platform values and a name/value list, whatever its type; - property headers are there from version 3, spawner and camera headers from version 4; - a camera waypoint's FOV before version 11 is divided by 1.25; - a property path's reputation multiplier outside 0-1000 becomes 0. Every path chunk of every client's zone files on disk (858) reads to its end. Co-Authored-By: Claude Opus 5.5 --- dZoneManager/Zone.cpp | 75 ++++-- dZoneManager/Zone.h | 3 +- .../dZoneManagerTests/CMakeLists.txt | 1 + .../dZoneManagerTests/ZonePathTests.cpp | 228 ++++++++++++++++++ 4 files changed, 289 insertions(+), 18 deletions(-) create mode 100644 tests/dGameTests/dZoneManagerTests/ZonePathTests.cpp diff --git a/dZoneManager/Zone.cpp b/dZoneManager/Zone.cpp index 5dc92b525..6325b828f 100644 --- a/dZoneManager/Zone.cpp +++ b/dZoneManager/Zone.cpp @@ -140,14 +140,17 @@ void Zone::LoadZoneIntoMemory() { } if (m_FileFormatVersion >= Zone::FileFormatVersion::EarlyAlpha) { + // The paths are a chunk of their own (LuzFile::ReadLUZFile copies it, ReadLUZPaths reads it) BinaryIO::BinaryRead(file, m_PathDataLength); - BinaryIO::BinaryRead(file, m_PathChunkVersion); // always should be 1 - - uint32_t pathCount; - BinaryIO::BinaryRead(file, pathCount); - - m_Paths.reserve(pathCount); - for (uint32_t i = 0; i < pathCount; ++i) LoadPath(file); + std::string chunk(m_PathDataLength, '\0'); + file.read(chunk.data(), static_cast(chunk.size())); + if (!file) throw std::runtime_error("Failed to read from istream."); + std::istringstream pathChunk(chunk); + // Like the client, a path chunk it can't read gives the zone no paths + if (!LoadPaths(pathChunk)) { + LOG("Zone %s has a path chunk the client doesn't read; no paths loaded", m_ZoneFilePath.c_str()); + m_Paths.clear(); + } for (const Path& path : m_Paths) { if (path.pathType != PathType::Spawner) continue; @@ -409,14 +412,43 @@ SceneTransitionInfo Zone::LoadSceneTransitionInfo(std::istream& file) { return info; } -void Zone::LoadPath(std::istream& file) { +bool Zone::LoadPaths(std::istream& file) { + // ReadLUZPaths: a chunk version from 2 on, 10000 paths or more, or any path LevelPath::FromBuffer refuses (it reads + // no further than the chunk's end here) leaves the zone with no paths + try { + BinaryIO::BinaryRead(file, m_PathChunkVersion); + if (m_PathChunkVersion >= 2) return false; + + uint32_t pathCount; + BinaryIO::BinaryRead(file, pathCount); + if (pathCount >= 10000) return false; + + m_Paths.reserve(pathCount); + for (uint32_t i = 0; i < pathCount; ++i) { + if (!LoadPath(file)) return false; + } + } catch (const std::runtime_error&) { + return false; + } + return true; +} + +bool Zone::LoadPath(std::istream& file) { Path path = Path(); BinaryIO::BinaryRead(file, path.pathVersion); + if (path.pathVersion >= 19) return false; BinaryIO::ReadString(file, path.pathName, BinaryIO::ReadType::WideString); - BinaryIO::BinaryRead(file, path.pathType); + // Before version 3 the type is a name: "platform" or a movement path + if (path.pathVersion < 3) { + std::string type; + BinaryIO::ReadString(file, type, BinaryIO::ReadType::WideString); + path.pathType = type == "platform" ? PathType::MovingPlatform : PathType::Movement; + } else { + BinaryIO::BinaryRead(file, path.pathType); + } BinaryIO::BinaryRead(file, path.flags); BinaryIO::BinaryRead(file, path.pathBehavior); @@ -426,7 +458,7 @@ void Zone::LoadPath(std::istream& file) { } else if (path.pathVersion >= 13) { BinaryIO::ReadString(file, path.movingPlatform.platformTravelSound, BinaryIO::ReadType::WideString); } - } else if (path.pathType == PathType::Property) { + } else if (path.pathType == PathType::Property && path.pathVersion >= 3) { BinaryIO::BinaryRead(file, path.property.pathType); BinaryIO::BinaryRead(file, path.property.price); BinaryIO::BinaryRead(file, path.property.rentalTime); @@ -442,6 +474,8 @@ void Zone::LoadPath(std::istream& file) { if (path.pathVersion >= 7) { BinaryIO::BinaryRead(file, path.property.cloneLimit); BinaryIO::BinaryRead(file, path.property.repMultiplier); + // The client takes a multiplier outside 0-1000 as 0 + if (path.property.repMultiplier < 0.0f || path.property.repMultiplier > 1000.0f) path.property.repMultiplier = 0.0f; BinaryIO::BinaryRead(file, path.property.rentalPeriod); } @@ -450,25 +484,28 @@ void Zone::LoadPath(std::istream& file) { BinaryIO::BinaryRead(file, path.property.playerZoneCoords); BinaryIO::BinaryRead(file, path.property.maxBuildHeight); } - } else if (path.pathType == PathType::Camera) { + } else if (path.pathType == PathType::Camera && path.pathVersion >= 4) { BinaryIO::ReadString(file, path.camera.nextPath, BinaryIO::ReadType::WideString); if (path.pathVersion >= 14) { BinaryIO::BinaryRead(file, path.camera.rotatePlayer); } - } else if (path.pathType == PathType::Spawner) { + } else if (path.pathType == PathType::Spawner && path.pathVersion >= 4) { BinaryIO::BinaryRead(file, path.spawner.spawnedLOT); BinaryIO::BinaryRead(file, path.spawner.respawnTime); BinaryIO::BinaryRead(file, path.spawner.maxToSpawn); BinaryIO::BinaryRead(file, path.spawner.amountMaintained); BinaryIO::BinaryRead(file, path.spawner.spawnerObjID); - BinaryIO::BinaryRead(file, path.spawner.spawnerNetActive); + if (path.pathVersion >= 9) BinaryIO::BinaryRead(file, path.spawner.spawnerNetActive); } // Read waypoints BinaryIO::BinaryRead(file, path.waypointCount); + if (path.waypointCount >= 10000) return false; path.pathWaypoints.reserve(path.waypointCount); + // Before version 3 every waypoint has the moving platform values and a command list, whatever the type + const bool allWaypointValues = path.pathVersion < 3; for (uint32_t i = 0; i < path.waypointCount; ++i) { PathWaypoint waypoint = PathWaypoint(); @@ -476,14 +513,14 @@ 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) { + if (allWaypointValues || 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); BinaryIO::BinaryRead(file, waypoint.rotation.y); BinaryIO::BinaryRead(file, waypoint.rotation.z); } - if (path.pathType == PathType::MovingPlatform) { + if (allWaypointValues || path.pathType == PathType::MovingPlatform) { BinaryIO::BinaryRead(file, waypoint.movingPlatform.lockPlayer); BinaryIO::BinaryRead(file, waypoint.speed); BinaryIO::BinaryRead(file, waypoint.movingPlatform.wait); @@ -494,6 +531,8 @@ void Zone::LoadPath(std::istream& file) { } else if (path.pathType == PathType::Camera) { BinaryIO::BinaryRead(file, waypoint.camera.time); BinaryIO::BinaryRead(file, waypoint.camera.fov); + // Before version 11 the field of view is 1.25 times larger + if (path.pathVersion < 11) waypoint.camera.fov /= 1.25f; BinaryIO::BinaryRead(file, waypoint.camera.tension); BinaryIO::BinaryRead(file, waypoint.camera.continuity); BinaryIO::BinaryRead(file, waypoint.camera.bias); @@ -508,9 +547,10 @@ void Zone::LoadPath(std::istream& file) { } // object LDF configs - if (path.pathType == PathType::Movement || path.pathType == PathType::Spawner || path.pathType == PathType::Rail) { + if (allWaypointValues || path.pathType == PathType::Movement || path.pathType == PathType::Spawner || path.pathType == PathType::Rail) { uint32_t count; BinaryIO::BinaryRead(file, count); + if (count > 99) return false; for (uint32_t i = 0; i < count; ++i) { std::string parameter; BinaryIO::ReadString(file, parameter, BinaryIO::ReadType::WideString); @@ -518,7 +558,7 @@ void Zone::LoadPath(std::istream& file) { std::string value; BinaryIO::ReadString(file, value, BinaryIO::ReadType::WideString); - if (path.pathType == PathType::Movement || path.pathType == PathType::Rail) { + if (path.pathType != PathType::Spawner) { // cause NetDevil puts spaces in things that don't need spaces parameter.erase(std::remove_if(parameter.begin(), parameter.end(), ::isspace), parameter.end()); auto waypointCommand = WaypointCommandType::StringToWaypointCommandType(parameter); @@ -546,6 +586,7 @@ void Zone::LoadPath(std::istream& file) { path.pathWaypoints.push_back(waypoint); } m_Paths.push_back(path); + return true; } const SceneRef* Zone::GetScene(LWOSCENEID sceneID) const { diff --git a/dZoneManager/Zone.h b/dZoneManager/Zone.h index e469a5d63..70cd87fca 100644 --- a/dZoneManager/Zone.h +++ b/dZoneManager/Zone.h @@ -280,5 +280,6 @@ private: void LoadLUTriggers(std::string triggerFile, SceneRef& scene); void LoadSceneTransition(std::istream& file); SceneTransitionInfo LoadSceneTransitionInfo(std::istream& file); - void LoadPath(std::istream& file); + bool LoadPaths(std::istream& file); + bool LoadPath(std::istream& file); }; diff --git a/tests/dGameTests/dZoneManagerTests/CMakeLists.txt b/tests/dGameTests/dZoneManagerTests/CMakeLists.txt index 009ec7ddc..b84975e2d 100644 --- a/tests/dGameTests/dZoneManagerTests/CMakeLists.txt +++ b/tests/dGameTests/dZoneManagerTests/CMakeLists.txt @@ -1,5 +1,6 @@ SET(DZONEMANAGER_TESTS "RawTests.cpp" + "ZonePathTests.cpp" "ZoneSceneTests.cpp") # Get the folder name and prepend it to the files above diff --git a/tests/dGameTests/dZoneManagerTests/ZonePathTests.cpp b/tests/dGameTests/dZoneManagerTests/ZonePathTests.cpp new file mode 100644 index 000000000..060c42a47 --- /dev/null +++ b/tests/dGameTests/dZoneManagerTests/ZonePathTests.cpp @@ -0,0 +1,228 @@ +#include "GameDependencies.h" + +#include +#include +#include + +#include "AssetManager.h" +#include "CDZoneTableTable.h" +#include "Zone.h" +#include "dZoneManager.h" +#include "eWaypointCommandType.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()); + } + + // A u8 length, then that many UTF-16 characters (ASCII only) + void WriteWString8(std::ostream& stream, const std::string& value) { + Write(stream, static_cast(value.size())); + for (const char c : value) Write(stream, static_cast(c)); + } + + // The waypoint every version 2 path has: rotation, the moving platform values and one command + void WriteOldWaypoint(std::ostream& stream, float x) { + Write(stream, NiPoint3(x, 0.0f, 0.0f)); + for (const float value : { 1.0f, 0.0f, 0.0f, 0.0f }) Write(stream, value); // rotation + Write(stream, 1); // lock player + Write(stream, 3.0f); // speed + Write(stream, 4.0f); // wait + Write(stream, 1); + WriteWString8(stream, "delay"); + WriteWString8(stream, "2"); + } + + void WritePathHeader(std::ostream& stream, uint32_t version, const std::string& name, PathType type) { + Write(stream, version); + WriteWString8(stream, name); + Write(stream, type); + Write(stream, 0); // flags + Write(stream, 0); // behavior + } +} + +// Loads a written .luz (version 41, no scenes) with the given path chunk through the zone manager +class ZonePathTest : public GameDependenciesTest { +protected: + static constexpr LWOMAPID TEST_MAP = 9992; + std::filesystem::path root; + + void SetUp() override { + SetUpDependencies(); + root = std::filesystem::temp_directory_path() / (std::string("dlu_zone_path_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(); + } + + const Zone* LoadZoneWithPaths(uint32_t pathCount, const std::string& paths) const { + std::ostringstream stream; + Write(stream, 41); // version + Write(stream, 1); // revision + Write(stream, TEST_MAP); + 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 + Write(stream, 0); // scenes + Write(stream, 0); // boundary lines + WriteString8(stream, "test.raw"); + WriteString8(stream, "Test Zone"); + WriteString8(stream, "A zone for tests"); + Write(stream, 0); // transitions + Write(stream, static_cast(8 + paths.size())); // path data length + Write(stream, 1); // path chunk version + Write(stream, pathCount); + stream.write(paths.data(), paths.size()); + const auto data = stream.str(); + 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(); + zone->LoadZoneIntoMemory(); + return zone; + } +}; + +// Before version 3 a path names its type and every waypoint has the moving platform values and a command list +TEST_F(ZonePathTest, ReadsVersion2Paths) { + std::ostringstream paths; + Write(paths, 2); + WriteWString8(paths, "Lava"); + WriteWString8(paths, "npc"); + Write(paths, 0); // flags + Write(paths, 0); // behavior + Write(paths, 2); + WriteOldWaypoint(paths, 1.0f); + WriteOldWaypoint(paths, 2.0f); + Write(paths, 2); + WriteWString8(paths, "plat"); + WriteWString8(paths, "platform"); + Write(paths, 0); + Write(paths, 0); + Write(paths, 1); + WriteOldWaypoint(paths, 5.0f); + const Zone* zone = LoadZoneWithPaths(2, paths.str()); + + const Path* lava = zone->GetPath("Lava"); + ASSERT_NE(lava, nullptr); + EXPECT_EQ(lava->pathType, PathType::Movement); + ASSERT_EQ(lava->pathWaypoints.size(), 2); + EXPECT_FLOAT_EQ(lava->pathWaypoints[1].position.x, 2.0f); + EXPECT_FLOAT_EQ(lava->pathWaypoints[1].speed, 3.0f); + ASSERT_EQ(lava->pathWaypoints[1].commands.size(), 1); + EXPECT_EQ(lava->pathWaypoints[1].commands[0].command, eWaypointCommandType::DELAY); + + const Path* platform = zone->GetPath("plat"); + ASSERT_NE(platform, nullptr); + EXPECT_EQ(platform->pathType, PathType::MovingPlatform); + ASSERT_EQ(platform->pathWaypoints.size(), 1); + EXPECT_FLOAT_EQ(platform->pathWaypoints[0].movingPlatform.wait, 4.0f); +} + +// Spawner and camera paths have no header before version 4; the spawner's network flag starts at version 9 +TEST_F(ZonePathTest, SpawnerAndCameraHeadersStartAtVersion4) { + std::ostringstream paths; + WritePathHeader(paths, 3, "spawn3", PathType::Spawner); + Write(paths, 1); + Write(paths, NiPoint3(7.0f, 0.0f, 0.0f)); + for (const float value : { 1.0f, 0.0f, 0.0f, 0.0f }) Write(paths, value); + Write(paths, 0); // config + WritePathHeader(paths, 8, "spawn8", PathType::Spawner); + Write(paths, 1234); // LOT + Write(paths, 10); // respawn time + Write(paths, 5); // max to spawn + Write(paths, 2); // amount maintained + Write(paths, 99); // spawner object ID + Write(paths, 0); // no waypoints + WritePathHeader(paths, 3, "cam3", PathType::Camera); + Write(paths, 0); + const Zone* zone = LoadZoneWithPaths(3, paths.str()); + + const Path* spawn3 = zone->GetPath("spawn3"); + ASSERT_NE(spawn3, nullptr); + EXPECT_EQ(spawn3->spawner.spawnedLOT, 0); + ASSERT_EQ(spawn3->pathWaypoints.size(), 1); + EXPECT_FLOAT_EQ(spawn3->pathWaypoints[0].position.x, 7.0f); + + const Path* spawn8 = zone->GetPath("spawn8"); + ASSERT_NE(spawn8, nullptr); + EXPECT_EQ(spawn8->spawner.spawnedLOT, 1234); + EXPECT_EQ(spawn8->spawner.spawnerObjID, 99); + + const Path* cam3 = zone->GetPath("cam3"); + ASSERT_NE(cam3, nullptr); + EXPECT_TRUE(cam3->camera.nextPath.empty()); +} + +// Before version 11 a camera waypoint's field of view is 1.25 times larger +TEST_F(ZonePathTest, OldCameraFieldOfViewIsScaled) { + std::ostringstream paths; + for (const auto& [version, name] : { std::pair{ 10, "cam10" }, { 11, "cam11" } }) { + WritePathHeader(paths, version, name, PathType::Camera); + WriteWString8(paths, "next"); + Write(paths, 1); + Write(paths, NiPoint3(9.0f, 0.0f, 0.0f)); + for (const float value : { 1.0f, 0.0f, 0.0f, 0.0f }) Write(paths, value); + for (const float value : { 1.0f, 50.0f, 0.1f, 0.2f, 0.3f }) Write(paths, value); // time, fov, tension, continuity, bias + } + const Zone* zone = LoadZoneWithPaths(2, paths.str()); + + const Path* cam10 = zone->GetPath("cam10"); + ASSERT_NE(cam10, nullptr); + EXPECT_EQ(cam10->camera.nextPath, "next"); + ASSERT_EQ(cam10->pathWaypoints.size(), 1); + EXPECT_FLOAT_EQ(cam10->pathWaypoints[0].camera.fov, 40.0f); + EXPECT_FLOAT_EQ(cam10->pathWaypoints[0].camera.bias, 0.3f); + const Path* cam11 = zone->GetPath("cam11"); + ASSERT_NE(cam11, nullptr); + EXPECT_FLOAT_EQ(cam11->pathWaypoints[0].camera.fov, 50.0f); +} + +// Like the client, a path it can't read leaves the zone with no paths +TEST_F(ZonePathTest, UnreadablePathLeavesNoPaths) { + for (const uint32_t badVersion : { 19u, 25u }) { + std::ostringstream paths; + WritePathHeader(paths, 18, "ok", PathType::Movement); + Write(paths, 0); // no waypoints + Write(paths, badVersion); + const Zone* zone = LoadZoneWithPaths(2, paths.str()); + EXPECT_EQ(zone->GetPath("ok"), nullptr) << badVersion; + EXPECT_EQ(zone->GetZoneName(), "Test Zone") << badVersion; + } + + std::ostringstream tooManyCommands; + WritePathHeader(tooManyCommands, 18, "commands", PathType::Movement); + Write(tooManyCommands, 1); + Write(tooManyCommands, NiPoint3(0.0f, 0.0f, 0.0f)); + Write(tooManyCommands, 100); + EXPECT_EQ(LoadZoneWithPaths(1, tooManyCommands.str())->GetPath("commands"), nullptr); + + std::ostringstream tooManyWaypoints; + WritePathHeader(tooManyWaypoints, 18, "waypoints", PathType::Movement); + Write(tooManyWaypoints, 10000); + EXPECT_EQ(LoadZoneWithPaths(1, tooManyWaypoints.str())->GetPath("waypoints"), nullptr); +}