From 83b87744c173254b4a33aac867e5424239a84e0b Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 22:02:20 -0500 Subject: [PATCH] feat: scene ghosting, as the client streams a zone's scenes The client keeps the scene under the player loaded, the scenes the zone file's transitions connect to it, and the global scene (Zone::StreamScenesAroundPosition 0x0108a3f0, TerrainManager::GetSceneAtPos 0x01069010, the connected scenes at 0x01066500; transitions naming a missing scene dropped as Zone::FixupInvalidTransitions 0x010842e0 does). - ZoneScenes (dCommon): the terrain's scene map lookup and the scene graph, shared by the world server and the dashboard. - Objects remember the scene they were placed in (spawners pass theirs on). - ghosting_scenes=1 (world config, off by default): players get the objects of their loaded scenes instead of the ones within the ghosting distances; objects from no scene go by the scene under them. Zones without a scene map keep distance ghosting. Co-Authored-By: Claude Opus 5.5 --- dCommon/CMakeLists.txt | 1 + dCommon/ZoneScenes.cpp | 85 +++++++++++++++++++++ dCommon/ZoneScenes.h | 76 ++++++++++++++++++ dDashboardServer/routes/SettingsCatalog.cpp | 1 + dGame/Entity.cpp | 1 + dGame/Entity.h | 4 + dGame/EntityManager.cpp | 54 ++++++++++--- dGame/EntityManager.h | 4 + dGame/dEntity/EntityInfo.h | 2 + dZoneManager/Level.cpp | 9 ++- dZoneManager/Level.h | 8 +- dZoneManager/Spawner.cpp | 1 + dZoneManager/Spawner.h | 1 + dZoneManager/Zone.cpp | 19 ++++- dZoneManager/Zone.h | 9 +++ resources/worldconfig.ini | 5 ++ tests/dCommonTests/CMakeLists.txt | 1 + tests/dCommonTests/ZoneScenesTests.cpp | 73 ++++++++++++++++++ tests/dGameTests/CMakeLists.txt | 1 + tests/dGameTests/SceneGhostingTests.cpp | 27 +++++++ 20 files changed, 363 insertions(+), 19 deletions(-) create mode 100644 dCommon/ZoneScenes.cpp create mode 100644 dCommon/ZoneScenes.h create mode 100644 tests/dCommonTests/ZoneScenesTests.cpp create mode 100644 tests/dGameTests/SceneGhostingTests.cpp diff --git a/dCommon/CMakeLists.txt b/dCommon/CMakeLists.txt index 62cca5638..d419525cd 100644 --- a/dCommon/CMakeLists.txt +++ b/dCommon/CMakeLists.txt @@ -30,6 +30,7 @@ set(DCOMMON_SOURCES "ZoneFile.cpp" "LevelFile.cpp" "Raw.cpp" + "ZoneScenes.cpp" "Process.cpp" "Permissions.cpp" "NifFile.cpp" diff --git a/dCommon/ZoneScenes.cpp b/dCommon/ZoneScenes.cpp new file mode 100644 index 000000000..9f2b1f32d --- /dev/null +++ b/dCommon/ZoneScenes.cpp @@ -0,0 +1,85 @@ +#include "ZoneScenes.h" + +#include +#include + +namespace ZoneScenes { + namespace { + // The client's margin on chunk edges (FLOAT_01479f24) + constexpr float EDGE = 0.001f; + } + + SceneMap::SceneMap(const Raw::Raw& raw) { + bool first = true; + for (const auto& chunk : raw.chunks) { + if (!chunk.IsValidForSceneLookup() || chunk.sceneMap.size() < static_cast(chunk.colorMapResolution) * chunk.colorMapResolution) continue; + Chunk out; + out.minX = chunk.offsetX; + out.minZ = chunk.offsetZ; + out.maxX = chunk.offsetX + static_cast(chunk.width - 1) * chunk.scaleFactor; + out.maxZ = chunk.offsetZ + static_cast(chunk.height - 1) * chunk.scaleFactor; + out.resolution = chunk.colorMapResolution; + out.cellsPerUnitX = static_cast(chunk.colorMapResolution) / (static_cast(chunk.width - 1) * chunk.scaleFactor); + out.cellsPerUnitZ = static_cast(chunk.colorMapResolution) / (static_cast(chunk.height - 1) * chunk.scaleFactor); + out.scenes = chunk.sceneMap; + m_MinX = first ? out.minX : std::min(m_MinX, out.minX); + m_MinZ = first ? out.minZ : std::min(m_MinZ, out.minZ); + m_MaxX = first ? out.maxX : std::max(m_MaxX, out.maxX); + m_MaxZ = first ? out.maxZ : std::max(m_MaxZ, out.maxZ); + first = false; + m_Chunks.push_back(std::move(out)); + } + } + + uint32_t SceneMap::SceneAt(float x, float z) const { + if (m_Chunks.empty() || !std::isfinite(x) || !std::isfinite(z)) return GLOBAL_SCENE; + // As TerrainManager's chunk lookup (0x01065c00): clamped to the terrain, then to one cell inside its far edge + x = std::clamp(x, m_MinX, m_MaxX); + z = std::clamp(z, m_MinZ, m_MaxZ); + for (const auto& chunk : m_Chunks) { + const auto px = std::min(x, m_MaxX - 1.0f / chunk.cellsPerUnitX); + const auto pz = std::min(z, m_MaxZ - 1.0f / chunk.cellsPerUnitZ); + if (px < chunk.minX - EDGE || px >= chunk.maxX - EDGE || pz < chunk.minZ - EDGE || pz >= chunk.maxZ - EDGE) continue; + const auto last = static_cast(chunk.resolution) - 1; + const auto cellX = std::clamp(static_cast(std::floor(chunk.cellsPerUnitX * (px - chunk.minX) + 0.5f)), int64_t{ 0 }, last); + const auto cellZ = std::clamp(static_cast(std::floor(chunk.cellsPerUnitZ * (pz - chunk.minZ) + 0.5f)), int64_t{ 0 }, last); + const auto scene = chunk.scenes[static_cast(cellX * chunk.resolution + cellZ)]; + return scene == NO_SCENE ? GLOBAL_SCENE : scene; + } + return GLOBAL_SCENE; + } + + SceneGraph::SceneGraph(const std::vector& scenes, const std::vector& transitions) { + for (const auto& scene : scenes) m_Scenes.insert(scene.id); + const auto link = [this](uint32_t from, uint32_t to) { + auto it = std::find_if(m_Neighbours.begin(), m_Neighbours.end(), [from](const auto& entry) { return entry.first == from; }); + if (it == m_Neighbours.end()) it = m_Neighbours.insert(m_Neighbours.end(), { from, {} }); + it->second.insert(to); + }; + for (const auto& transition : transitions) { + if (transition.points.size() < 2) continue; + // The low half of a point's LWOSCENEID is the scene, the high half its layer + const auto a = static_cast(transition.points[0].sceneID & 0xFFFFFFFF); + const auto b = static_cast(transition.points[1].sceneID & 0xFFFFFFFF); + if (a == b || !m_Scenes.contains(a) || !m_Scenes.contains(b)) continue; + link(a, b); + link(b, a); + } + } + + const std::set& SceneGraph::Neighbours(uint32_t scene) const { + static const std::set NONE; + if (scene == GLOBAL_SCENE) return NONE; + const auto it = std::find_if(m_Neighbours.begin(), m_Neighbours.end(), [scene](const auto& entry) { return entry.first == scene; }); + return it == m_Neighbours.end() ? NONE : it->second; + } + + std::set SceneGraph::Loaded(uint32_t scene) const { + std::set loaded{ GLOBAL_SCENE }; + if (scene == GLOBAL_SCENE) return loaded; + loaded.insert(scene); + const auto& neighbours = Neighbours(scene); + loaded.insert(neighbours.begin(), neighbours.end()); + return loaded; + } +} diff --git a/dCommon/ZoneScenes.h b/dCommon/ZoneScenes.h new file mode 100644 index 000000000..3dbb9a5ee --- /dev/null +++ b/dCommon/ZoneScenes.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include + +#include "Raw.h" +#include "ZoneFile.h" + +/** + * Which of a zone's scenes the game client keeps loaded, as it decides it (Zone::StreamScenesAroundPosition, + * 0x0108a3f0 in client 1.10.64): the scene under the player, found in the terrain's scene map + * (TerrainManager::GetSceneAtPos, 0x01069010), and the scenes connected to it by the zone file's scene transitions + * (LevelManager's connected scenes, 0x01066500); the global scene (0) always stays. A scene is a scene id: its layers + * (the audio scenes share their scene's id) come and go with it. Pure, so the world server and the dashboard use the + * same rules and both can be unit tested. + */ +namespace ZoneScenes { + constexpr uint32_t GLOBAL_SCENE = 0; + // What the scene map holds where it names no scene; the client reads it as the global scene + constexpr uint8_t NO_SCENE = 0xFF; + + // The terrain's scene map, kept apart from the rest of the .raw file (heights, textures and flairs aren't needed) + class SceneMap { + public: + SceneMap() = default; + explicit SceneMap(const Raw::Raw& raw); + + // The scene at world position (x, z), as the client finds it: the global scene outside the map or where it has none + uint32_t SceneAt(float x, float z) const; + bool Empty() const { return m_Chunks.empty(); } + + private: + struct Chunk { + float minX{}, minZ{}, maxX{}, maxZ{}; + float cellsPerUnitX{}, cellsPerUnitZ{}; // colorMapResolution / the chunk's width in world units + uint32_t resolution{}; + std::vector scenes; // resolution * resolution, x major + }; + std::vector m_Chunks; + float m_MinX{}, m_MinZ{}, m_MaxX{}, m_MaxZ{}; + }; + + // The scenes each scene is connected to, from the zone file's transitions + class SceneGraph { + public: + SceneGraph() = default; + /** + * From a zone's scene transitions: each connects its first two points' scenes (the client's zone keeps them as + * scene pairs). Transitions naming a scene the zone doesn't have are dropped, as the client's + * Zone::FixupInvalidTransitions (0x010842e0) does. + */ + SceneGraph(const std::vector& scenes, const std::vector& transitions); + + // The scenes connected to `scene` (not itself); none for the global scene + const std::set& Neighbours(uint32_t scene) const; + + // What the client keeps loaded with the player in `scene`: the global scene, `scene` and its neighbours + std::set Loaded(uint32_t scene) const; + + const std::set& Scenes() const { return m_Scenes; } + + private: + std::set m_Scenes; + std::vector>> m_Neighbours; + }; + + /** + * Whether a player with `loaded` scenes (SceneGraph::Loaded of the scene under them) gets an object: when its scene + * is loaded. An object's scene is the scene it was placed in (`placedScene`, -1 for none: spawned at run time or a + * player), else the scene under it (`sceneUnder`). + */ + inline bool InLoadedScene(int32_t placedScene, uint32_t sceneUnder, const std::set& loaded) { + return loaded.contains(placedScene >= 0 ? static_cast(placedScene) : sceneUnder); + } +} diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 360ac096c..033b4a71d 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -150,6 +150,7 @@ namespace { c.Add(Format(Text(SHARED, "dump_folder", "Crash dump folder", "Where crash logs go. Empty turns them off.", "", true), eFormat::PATH)); c.Add(Bool(WORLD, "generate_dump", "Crash dumps from world servers", "Write a dump when a world server crashes (needs the crash dump folder).", false, true)); c.Add(Bool(WORLD, "save_lxfmls", "Save model files", "Save players' models (LXFML) to disk before they are split, for debugging.", false)); + c.Add(Bool(WORLD, "ghosting_scenes", "Scene ghosting", "Players get the objects of the scenes their game keeps loaded (the scene under them, the scenes connected to it and the global scene), as the client streams scenes, instead of the ones within 100 to 150 units. Needs the zone's terrain scene map; zones without one keep distance ghosting.", false, true)); c.Add(Bool(WORLD, "bbb_consume_bricks", "Brick building uses bricks", "Saving a brick by brick model uses up the bricks in it, as live did. Off: the bricks go back to the backpack.", false)); c.Add(Bool(SHARED, "dont_generate_dcf", "Don't build the chat filter file", "Skip compiling the chat word list to a file.", false, true)); diff --git a/dGame/Entity.cpp b/dGame/Entity.cpp index 99218896c..02eb83e8a 100644 --- a/dGame/Entity.cpp +++ b/dGame/Entity.cpp @@ -138,6 +138,7 @@ Entity::Entity(const LWOOBJID& objectID, const EntityInfo& info, User* parentUse m_DefaultPosition = info.pos; m_DefaultRotation = info.rot; m_Scale = info.scale; + m_Scene = info.scene; m_Spawner = info.spawner; m_SpawnerID = info.spawnerID; m_HasSpawnerNodeID = info.hasSpawnerNodeID; diff --git a/dGame/Entity.h b/dGame/Entity.h index 946bc335a..35b187274 100644 --- a/dGame/Entity.h +++ b/dGame/Entity.h @@ -349,6 +349,9 @@ public: // Scale will only be communicated to the client when the construction packet is sent void SetScale(const float scale) { m_Scale = scale; }; + // The zone scene the object was placed in (EntityInfo::scene), -1 when it came from none + int32_t GetScene() const { return m_Scene; } + void RegisterMsg(const MessageType::Game msgId, std::function handler); bool HandleMsg(GameMessages::GameMsg& msg) const; @@ -392,6 +395,7 @@ private: NiPoint3 m_DefaultPosition; NiQuaternion m_DefaultRotation = QuatUtils::IDENTITY; float m_Scale; + int32_t m_Scene = -1; Spawner* m_Spawner; LWOOBJID m_SpawnerID; diff --git a/dGame/EntityManager.cpp b/dGame/EntityManager.cpp index fee2b66ad..6dbcd6932 100644 --- a/dGame/EntityManager.cpp +++ b/dGame/EntityManager.cpp @@ -11,6 +11,8 @@ #include "SwitchComponent.h" #include "UserManager.h" #include "dZoneManager.h" +#include "Zone.h" +#include "ZoneScenes.h" #include "MissionComponent.h" #include "Game.h" #include "Logger.h" @@ -98,6 +100,13 @@ void EntityManager::Initialize() { Game::zoneManager->GetZoneID().GetMapID() ) == m_GhostingExcludedZones.end(); + // Scene ghosting needs the terrain's scene map; without one it stays off + if (m_GhostingEnabled && GeneralUtils::TryParse(Game::config->GetValue("ghosting_scenes")).value_or(false)) { + auto* zone = Game::zoneManager->GetZoneMut(); + m_SceneGhosting = zone && zone->LoadSceneMap(); + LOG("Scene ghosting is %s", m_SceneGhosting ? "on" : "off: the zone has no scene map"); + } + // grab hardcore mode settings and load them with sane defaults Game::config->AddConfigHandler([]() {Game::entityManager->ReloadConfig();}); Game::entityManager->ReloadConfig(); @@ -505,6 +514,8 @@ void EntityManager::UpdateGhosting(Entity* player) { const auto& referencePoint = ghostComponent->GetGhostReferencePoint(); const auto isOverride = ghostComponent->GetGhostOverride(); + const auto* zone = m_SceneGhosting ? Game::zoneManager->GetZone() : nullptr; + const auto loaded = zone ? zone->GetSceneGraph().Loaded(zone->GetSceneMap().SceneAt(referencePoint.x, referencePoint.z)) : std::set{}; for (auto* entity : m_EntitiesToGhost) { const auto& entityPoint = entity->GetPosition(); @@ -513,24 +524,32 @@ void EntityManager::UpdateGhosting(Entity* player) { const auto observed = ghostComponent->IsObserved(id); - const auto distance = NiPoint3::DistanceSquared(referencePoint, entityPoint); + bool tooFar = false, nearEnough = false; + if (zone) { + // As the client streams scenes: the objects of the scenes loaded around the player + nearEnough = ZoneScenes::InLoadedScene(entity->GetScene(), zone->GetSceneMap().SceneAt(entityPoint.x, entityPoint.z), loaded); + tooFar = !nearEnough; + } else { + const auto distance = NiPoint3::DistanceSquared(referencePoint, entityPoint); + auto ghostingDistanceMax = m_GhostDistanceMaxSquared; + auto ghostingDistanceMin = m_GhostDistanceMinSqaured; - auto ghostingDistanceMax = m_GhostDistanceMaxSquared; - auto ghostingDistanceMin = m_GhostDistanceMinSqaured; - - const auto isAudioEmitter = entity->GetLOT() == 6368; // https://explorer.lu/objects/6368 - if (isAudioEmitter) { - ghostingDistanceMax = ghostingDistanceMin; + const auto isAudioEmitter = entity->GetLOT() == 6368; // https://explorer.lu/objects/6368 + if (isAudioEmitter) { + ghostingDistanceMax = ghostingDistanceMin; + } + tooFar = distance > ghostingDistanceMax; + nearEnough = ghostingDistanceMin > distance; } - if (observed && distance > ghostingDistanceMax && !isOverride) { + if (observed && tooFar && !isOverride) { ghostComponent->GhostEntity(id); DestructEntity(entity, player->GetSystemAddress()); entity->SetObservers(entity->GetObservers() - 1); - } else if (!observed && ghostingDistanceMin > distance) { + } else if (!observed && nearEnough) { // Check collectables, don't construct if it has been collected uint32_t collectionId = entity->GetCollectibleID(); @@ -555,6 +574,8 @@ void EntityManager::CheckGhosting(Entity* entity) { if (!entity) return; const auto& referencePoint = entity->GetPosition(); + const auto* zone = m_SceneGhosting ? Game::zoneManager->GetZone() : nullptr; + const auto entityScene = zone ? zone->GetSceneMap().SceneAt(referencePoint.x, referencePoint.z) : 0; for (auto* player : PlayerManager::GetAllPlayers()) { auto* ghostComponent = player->GetComponent(); if (!ghostComponent) continue; @@ -562,13 +583,22 @@ void EntityManager::CheckGhosting(Entity* entity) { const auto& entityPoint = ghostComponent->GetGhostReferencePoint(); const auto id = entity->GetObjectID(); const auto observed = ghostComponent->IsObserved(id); - const auto distance = NiPoint3::DistanceSquared(referencePoint, entityPoint); + bool tooFar = false, nearEnough = false; + if (zone) { + const auto loaded = zone->GetSceneGraph().Loaded(zone->GetSceneMap().SceneAt(entityPoint.x, entityPoint.z)); + nearEnough = ZoneScenes::InLoadedScene(entity->GetScene(), entityScene, loaded); + tooFar = !nearEnough; + } else { + const auto distance = NiPoint3::DistanceSquared(referencePoint, entityPoint); + tooFar = distance > m_GhostDistanceMaxSquared; + nearEnough = m_GhostDistanceMinSqaured > distance; + } - if (observed && distance > m_GhostDistanceMaxSquared) { + if (observed && tooFar) { ghostComponent->GhostEntity(id); DestructEntity(entity, player->GetSystemAddress()); entity->SetObservers(entity->GetObservers() - 1); - } else if (!observed && m_GhostDistanceMinSqaured > distance) { + } else if (!observed && nearEnough) { ghostComponent->ObserveEntity(id); ConstructEntity(entity, player->GetSystemAddress()); entity->SetObservers(entity->GetObservers() + 1); diff --git a/dGame/EntityManager.h b/dGame/EntityManager.h index 26daf7508..437247920 100644 --- a/dGame/EntityManager.h +++ b/dGame/EntityManager.h @@ -62,6 +62,7 @@ public: void CheckGhosting(Entity* entity); Entity* GetGhostCandidate(LWOOBJID id) const; bool GetGhostingEnabled() const; + bool GetSceneGhosting() const { return m_SceneGhosting; } void ScheduleForKill(Entity* entity); @@ -109,6 +110,9 @@ private: float m_GhostDistanceMinSqaured = 100 * 100; float m_GhostDistanceMaxSquared = 150 * 150; bool m_GhostingEnabled = true; + // ghosting_scenes: players get the objects of the scenes their client keeps loaded (ZoneScenes) instead of the + // ones within the ghosting distances + bool m_SceneGhosting = false; std::stack m_LostNetworkIds; diff --git a/dGame/dEntity/EntityInfo.h b/dGame/dEntity/EntityInfo.h index 68eaa95d2..13efe3634 100644 --- a/dGame/dEntity/EntityInfo.h +++ b/dGame/dEntity/EntityInfo.h @@ -37,4 +37,6 @@ struct EntityInfo { LwoNameValue settings; LwoNameValue networkSettings; float scale; + // The zone scene (its scene id) the object was placed in, for scene ghosting; -1: none (spawned at run time) + int32_t scene = -1; }; diff --git a/dZoneManager/Level.cpp b/dZoneManager/Level.cpp index 6b3ff379e..b72d7e484 100644 --- a/dZoneManager/Level.cpp +++ b/dZoneManager/Level.cpp @@ -19,8 +19,9 @@ #include "dConfig.h" #include -Level::Level(Zone* parentZone, const std::string& filepath) { +Level::Level(Zone* parentZone, const std::string& filepath, int32_t sceneID) { m_ParentZone = parentZone; + m_SceneID = sceneID; auto stream = Game::assetManager->GetFile(filepath.c_str()); @@ -40,8 +41,9 @@ Level::Level(Zone* parentZone, const std::string& filepath) { LoadSceneObjects(levelFile.objects); } -void Level::MakeSpawner(const SceneObject& obj) { +void Level::MakeSpawner(const SceneObject& obj, int32_t sceneID) { SpawnerInfo spawnInfo = SpawnerInfo(); + spawnInfo.scene = sceneID; SpawnerNode* node = new SpawnerNode(); spawnInfo.templateID = obj.lot; spawnInfo.spawnerID = obj.id; @@ -162,7 +164,7 @@ void Level::LoadSceneObjects(const std::vector& objects) { } if (obj.lot == 176) { //Spawner - MakeSpawner(obj); + MakeSpawner(obj, m_SceneID); } else { //Regular object EntityInfo info; info.spawnerID = 0; @@ -172,6 +174,7 @@ void Level::LoadSceneObjects(const std::vector& objects) { info.rot = obj.rotation; info.settings = obj.settings; info.scale = obj.scale; + info.scene = m_SceneID; Game::entityManager->CreateEntity(info); } } diff --git a/dZoneManager/Level.h b/dZoneManager/Level.h index 6f5907a71..1814aed47 100644 --- a/dZoneManager/Level.h +++ b/dZoneManager/Level.h @@ -15,13 +15,15 @@ public: }; public: - Level(Zone* parentZone, const std::string& filepath); - - static void MakeSpawner(const SceneObject& obj); + // sceneID: the scene id of the zone scene the file is (EntityInfo::scene of its objects) + Level(Zone* parentZone, const std::string& filepath, int32_t sceneID = -1); + + static void MakeSpawner(const SceneObject& obj, int32_t sceneID = -1); std::map m_ChunkHeaders; private: Zone* m_ParentZone; + int32_t m_SceneID = -1; //private functions: void LoadSceneObjects(const std::vector& objects); diff --git a/dZoneManager/Spawner.cpp b/dZoneManager/Spawner.cpp index e70458424..5399d21b4 100644 --- a/dZoneManager/Spawner.cpp +++ b/dZoneManager/Spawner.cpp @@ -24,6 +24,7 @@ Spawner::Spawner(const SpawnerInfo& info) { m_EntityInfo.lot = m_Info.templateID; m_EntityInfo.scale = m_Info.templateScale; + m_EntityInfo.scene = m_Info.scene; m_Start = m_Info.noTimedSpawn; diff --git a/dZoneManager/Spawner.h b/dZoneManager/Spawner.h index a5c9f3555..0b50d9587 100644 --- a/dZoneManager/Spawner.h +++ b/dZoneManager/Spawner.h @@ -70,6 +70,7 @@ struct SpawnerInfo { bool emulated = false; LWOOBJID emulator = LWOOBJID_EMPTY; + int32_t scene = -1; // the zone scene the spawner was placed in, passed to what it spawns (EntityInfo::scene) }; class Spawner { diff --git a/dZoneManager/Zone.cpp b/dZoneManager/Zone.cpp index af12a5cec..53e5ed5fa 100644 --- a/dZoneManager/Zone.cpp +++ b/dZoneManager/Zone.cpp @@ -66,6 +66,7 @@ void Zone::LoadZoneIntoMemory() { m_SpawnpointRotation = zoneFile.spawnpointRotation; m_SceneCount = zoneFile.scenes.size(); + m_SceneGraph = ZoneScenes::SceneGraph(zoneFile.scenes, zoneFile.sceneTransitions); for (auto& scene : zoneFile.scenes) { LoadScene(std::move(scene)); } @@ -198,7 +199,7 @@ uint32_t Zone::CalculateChecksum() const { void Zone::LoadLevelsIntoMemory() { for (auto& [sceneID, scene] : m_Scenes) { if (scene.level) continue; - scene.level = std::make_unique(this, m_ZonePath + scene.filename); + scene.level = std::make_unique(this, m_ZonePath + scene.filename, sceneID.GetSceneID()); if (scene.level->m_ChunkHeaders.empty()) continue; @@ -207,6 +208,22 @@ void Zone::LoadLevelsIntoMemory() { } } +bool Zone::LoadSceneMap() { + if (m_ZoneRawPath.empty()) return false; + auto file = Game::assetManager->GetFile((m_ZonePath + m_ZoneRawPath).c_str()); + if (!file) { + LOG("Could not open the terrain file %s for its scene map", (m_ZonePath + m_ZoneRawPath).c_str()); + return false; + } + Raw::Raw raw; + if (!Raw::ReadRaw(file, raw)) { + LOG("Could not read the terrain file %s for its scene map", (m_ZonePath + m_ZoneRawPath).c_str()); + return false; + } + m_SceneMap = ZoneScenes::SceneMap(raw); + return !m_SceneMap.Empty(); +} + void Zone::AddRevision(LWOSCENEID sceneID, uint32_t revision) { if (m_MapRevisions.find(sceneID) == m_MapRevisions.end()) { m_MapRevisions.insert(std::make_pair(sceneID, revision)); diff --git a/dZoneManager/Zone.h b/dZoneManager/Zone.h index d83c0c4d1..503d4cd3a 100644 --- a/dZoneManager/Zone.h +++ b/dZoneManager/Zone.h @@ -3,6 +3,7 @@ #include "dZMCommon.h" #include "LDFFormat.h" #include "ZoneFile.h" +#include "ZoneScenes.h" #include "tinyxml2.h" #include #include @@ -48,6 +49,12 @@ public: const NiPoint3& GetSpawnPos() const { return m_Spawnpoint; } const NiQuaternion& GetSpawnRot() const { return m_SpawnpointRotation; } + // Which scenes the client keeps loaded where (ZoneScenes); the scene map is empty unless LoadSceneMap read it + const ZoneScenes::SceneGraph& GetSceneGraph() const { return m_SceneGraph; } + const ZoneScenes::SceneMap& GetSceneMap() const { return m_SceneMap; } + // Reads the terrain file's scene map (for scene ghosting); false when the zone has none + bool LoadSceneMap(); + void SetSpawnPos(const NiPoint3& pos) { m_Spawnpoint = pos; } void SetSpawnRot(const NiQuaternion& rot) { m_SpawnpointRotation = rot; } @@ -70,6 +77,8 @@ private: std::map m_Scenes; std::vector m_SceneTransitions; + ZoneScenes::SceneGraph m_SceneGraph; + ZoneScenes::SceneMap m_SceneMap; uint32_t m_PathDataLength; uint32_t m_PathChunkVersion; diff --git a/resources/worldconfig.ini b/resources/worldconfig.ini index 3e3a5ce39..693d050e9 100644 --- a/resources/worldconfig.ini +++ b/resources/worldconfig.ini @@ -108,3 +108,8 @@ save_lxfmls=0 # 1: saving a brick by brick model uses up the bricks in it, as live did. 0: they go back to the backpack. bbb_consume_bricks=0 + +# 1: players get the objects of the scenes their game keeps loaded (the scene under them, the scenes connected to it +# by the zone's scene transitions, and the global scene) instead of the objects within the ghosting distances. +# Needs the zone's terrain scene map; zones without one keep distance ghosting. +ghosting_scenes=0 diff --git a/tests/dCommonTests/CMakeLists.txt b/tests/dCommonTests/CMakeLists.txt index 6c7377aaa..905cab625 100644 --- a/tests/dCommonTests/CMakeLists.txt +++ b/tests/dCommonTests/CMakeLists.txt @@ -22,6 +22,7 @@ set(DCOMMONTEST_SOURCES "ScheduleRulesTests.cpp" "VanityEventsTests.cpp" "ZoneFileTests.cpp" + "ZoneScenesTests.cpp" "LiveOpsRulesTests.cpp" "ProcessAndLimitsTests.cpp" "HotPropertySlotsTests.cpp" diff --git a/tests/dCommonTests/ZoneScenesTests.cpp b/tests/dCommonTests/ZoneScenesTests.cpp new file mode 100644 index 000000000..734b4b2db --- /dev/null +++ b/tests/dCommonTests/ZoneScenesTests.cpp @@ -0,0 +1,73 @@ +#include + +#include "ZoneScenes.h" + +namespace { + // A chunk `cells` scene map cells wide covering (x, z) .. (x + 64, z + 64): 65 height samples, 1 unit apart + Raw::Chunk MakeChunk(float x, float z, uint32_t cells, std::vector scenes) { + Raw::Chunk chunk{}; + chunk.width = chunk.height = 65; + chunk.offsetX = x; + chunk.offsetZ = z; + chunk.scaleFactor = 1.0f; + chunk.heightMap.assign(65 * 65, 0.0f); + chunk.colorMapResolution = cells; + chunk.sceneMap = std::move(scenes); + return chunk; + } + + SceneTransition Transition(uint32_t a, uint32_t b, uint32_t layer = 0) { + SceneTransition transition; + transition.points.push_back({ (static_cast(layer) << 32) | a, {} }); + transition.points.push_back({ b, {} }); + return transition; + } + + std::vector Scenes(std::initializer_list ids) { + std::vector scenes; + for (const auto id : ids) { + ZoneScene scene; + scene.id = id; + scenes.push_back(scene); + } + return scenes; + } +} + +// Cells are x major (index x * resolution + z) and a position takes the nearest cell, as the client's lookup does +TEST(ZoneScenesTests, FindsTheSceneUnderAPosition) { + Raw::Raw raw{}; + // 2x2 cells, each 32 units: x = 0 has scenes 1 (z low) and 2, x = 1 has 3 and no scene + raw.chunks.push_back(MakeChunk(0, 0, 2, { 1, 2, 3, ZoneScenes::NO_SCENE })); + raw.chunks.push_back(MakeChunk(64, 0, 1, { 7 })); + const ZoneScenes::SceneMap map(raw); + ASSERT_FALSE(map.Empty()); + EXPECT_EQ(map.SceneAt(1, 1), 1u); + EXPECT_EQ(map.SceneAt(1, 17), 2u); // past half a cell: the next cell + EXPECT_EQ(map.SceneAt(17, 1), 3u); + EXPECT_EQ(map.SceneAt(17, 17), ZoneScenes::GLOBAL_SCENE); // no scene there + EXPECT_EQ(map.SceneAt(100, 10), 7u); + // Off the terrain: clamped to its edge + EXPECT_EQ(map.SceneAt(-50, -50), 1u); + EXPECT_EQ(map.SceneAt(500, 10), 7u); + EXPECT_EQ(ZoneScenes::SceneMap().SceneAt(1, 1), ZoneScenes::GLOBAL_SCENE); +} + +TEST(ZoneScenesTests, LoadsTheConnectedScenes) { + // 1-2 and 2-3 connect; 9 isn't in the zone, so its transition is dropped + const ZoneScenes::SceneGraph graph(Scenes({ 0, 1, 2, 3, 4 }), { Transition(1, 2), Transition(3, 2, 1), Transition(2, 9), Transition(4, 4) }); + EXPECT_EQ(graph.Loaded(1), (std::set{ 0, 1, 2 })); + EXPECT_EQ(graph.Loaded(2), (std::set{ 0, 1, 2, 3 })); + EXPECT_EQ(graph.Loaded(4), (std::set{ 0, 4 })); + EXPECT_EQ(graph.Loaded(ZoneScenes::GLOBAL_SCENE), (std::set{ 0 })); + EXPECT_TRUE(graph.Neighbours(9).empty()); +} + +TEST(ZoneScenesTests, GhostsObjectsByTheirScene) { + const std::set loaded{ 0, 1, 2 }; + EXPECT_TRUE(ZoneScenes::InLoadedScene(2, 7, loaded)); // placed in a loaded scene, wherever it is now + EXPECT_FALSE(ZoneScenes::InLoadedScene(3, 1, loaded)); // placed in one that isn't + EXPECT_TRUE(ZoneScenes::InLoadedScene(0, 9, loaded)); // the global scene's objects always + EXPECT_TRUE(ZoneScenes::InLoadedScene(-1, 1, loaded)); // spawned at run time: the scene under it + EXPECT_FALSE(ZoneScenes::InLoadedScene(-1, 5, loaded)); +} diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index c263a03bd..8d226b6c9 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -14,6 +14,7 @@ set(DGAMETEST_SOURCES "BrickByBrickTests.cpp" "ModularBuildTableTests.cpp" "UgcManifestTests.cpp" + "SceneGhostingTests.cpp" ) add_subdirectory(dComponentsTests) diff --git a/tests/dGameTests/SceneGhostingTests.cpp b/tests/dGameTests/SceneGhostingTests.cpp new file mode 100644 index 000000000..8df8909c8 --- /dev/null +++ b/tests/dGameTests/SceneGhostingTests.cpp @@ -0,0 +1,27 @@ +#include "GameDependencies.h" +#include + +#include "Entity.h" +#include "ZoneScenes.h" + +class SceneGhostingTest : public GameDependenciesTest { +protected: + void SetUp() override { SetUpDependencies(); } + void TearDown() override { TearDownDependencies(); } +}; + +// Objects keep the scene they were placed in, which scene ghosting goes by; objects from no scene go by their position +TEST_F(SceneGhostingTest, EntitiesKeepTheirScene) { + EntityInfo placed = info; + placed.scene = 12; + const auto fromScene = std::make_unique(20, placed); + EXPECT_EQ(fromScene->GetScene(), 12); + const auto spawned = std::make_unique(21, info); + EXPECT_EQ(spawned->GetScene(), -1); + + // A player standing in scene 3, whose client keeps 0, 3 and 5 loaded + const std::set loaded{ 0, 3, 5 }; + EXPECT_FALSE(ZoneScenes::InLoadedScene(fromScene->GetScene(), 3, loaded)); // placed in scene 12, though it stands in 3 + EXPECT_TRUE(ZoneScenes::InLoadedScene(spawned->GetScene(), 5, loaded)); + EXPECT_FALSE(ZoneScenes::InLoadedScene(spawned->GetScene(), 12, loaded)); +}