From da9ae3b3c45bcdacf38d4d7644bf87077f86c075 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 07:49:04 -0500 Subject: [PATCH] feat(zone): don't spawn carver_only objects, keep them as walls The client never loads a level object whose config has carver_only set (its resource manager skips the load; LWOBasePhysComponent::LoadConfigData 0x00c495c9 reads the flag next to navmesh_carver), and live never sent one: none of the 331 carver_only placements (mostly Crux Prime's trigger boxes) shows up among the live constructions, where their zones' other objects do. DLU spawned them as objects. They are skipped now. The ones that carve the navmesh (53, in six zones, among them the Avant Gardens Sentinel camp walls) still stop the server's movers: their shape goes into the world's movement blockers without an object, owned by the physics world (dpWorld::AddOwnedMovementBlocker) and freed when it shuts down. None of them has a script, trigger or group. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/PhysicsComponent.cpp | 50 ++++++++++++++++++++++++++ dGame/dComponents/PhysicsComponent.h | 16 +++++++++ dPhysics/dpWorld.cpp | 9 +++++ dPhysics/dpWorld.h | 5 +++ dZoneManager/Level.cpp | 8 +++++ 5 files changed, 88 insertions(+) diff --git a/dGame/dComponents/PhysicsComponent.cpp b/dGame/dComponents/PhysicsComponent.cpp index 65bb25f5a..0e4aa5fec 100644 --- a/dGame/dComponents/PhysicsComponent.cpp +++ b/dGame/dComponents/PhysicsComponent.cpp @@ -97,6 +97,56 @@ void PhysicsComponent::RegisterMovementBlocker(const eReplicaComponentType type, dpWorld::AddMovementBlocker(m_MovementBlocker, *filter); } +namespace { + bool SettingIsTrue(const LwoNameValue& settings, const std::u16string& key) { + const auto it = settings.find(key); + return it != settings.end() && it->second && GeneralUtils::TryParse(it->second->GetValueAsString()).value_or(0) != 0; + } +} + +bool PhysicsComponent::IsCarverOnly(const LwoNameValue& settings) { + return SettingIsTrue(settings, u"carver_only"); +} + +bool PhysicsComponent::AddLevelMovementBlocker(const LWOOBJID id, const LOT lot, const LwoNameValue& settings, const NiPoint3& position, const NiQuaternion& rotation, const float scale) { + auto* const registry = CDClientManager::GetTable(); + auto* const physicsTable = CDClientManager::GetTable(); + + // The physics component it would have: fixed simple physics is solid (no level object of this kind moves), a + // phantom volume is not + for (const auto type : { eReplicaComponentType::SIMPLE_PHYSICS, eReplicaComponentType::PHANTOM_PHYSICS }) { + const auto componentID = registry->GetByIDAndType(lot, type, -1); + if (componentID == -1) continue; + + int32_t collisionGroup = 0; + if (const auto* const info = physicsTable->GetByID(componentID)) collisionGroup = info->collisionGroup; + if (const auto it = settings.find(u"CollisionGroupID"); it != settings.end() && it->second) { + collisionGroup = GeneralUtils::TryParse(it->second->GetValueAsString()).value_or(collisionGroup); + } + + const bool solid = type == eReplicaComponentType::SIMPLE_PHYSICS; + const auto filter = dpMovementBlockers::BlockingFilter(SettingIsTrue(settings, u"navmesh_carver"), solid, static_cast(collisionGroup)); + if (!filter) return false; + + auto shapePosition = position; + bool isFallback = false; + auto* const blocker = CreateAssetShape(id, lot, type, collisionGroup, scale, rotation, shapePosition, &isFallback); + if (!blocker) return false; + if (isFallback) { + LOG_DEBUG("%llu (LOT %i) should block movement, but the server doesn't know its shape", id, lot); + delete blocker; + return false; + } + + blocker->SetScale(scale); + blocker->SetRotation(rotation); + blocker->SetPosition(shapePosition); + dpWorld::AddOwnedMovementBlocker(blocker, *filter); + return true; + } + return false; +} + bool PhysicsComponent::OnGetPosition(GameMessages::GetPosition& msg) { msg.pos = GetPosition(); return true; diff --git a/dGame/dComponents/PhysicsComponent.h b/dGame/dComponents/PhysicsComponent.h index a8f127403..ca6c30185 100644 --- a/dGame/dComponents/PhysicsComponent.h +++ b/dGame/dComponents/PhysicsComponent.h @@ -6,6 +6,8 @@ #include "NiQuaternion.h" #include "dCommonVars.h" +struct LwoNameValue; + namespace GameMessages { struct GetObjectReportInfo; struct GetPosition; @@ -34,6 +36,20 @@ public: int32_t GetCollisionGroup() const noexcept { return m_CollisionGroup; } void SetCollisionGroup(int32_t group) noexcept { m_CollisionGroup = group; } + + /** + * Whether a level object is carver_only, which the client never loads (LWOResMgr2Interface::Run skips its load + * when its config has carver_only set; LWOBasePhysComponent::LoadConfigData 0x00c495c9 reads it next to + * navmesh_carver). Live never sent one. + */ + [[nodiscard]] static bool IsCarverOnly(const LwoNameValue& settings); + + /** + * For a level object the server doesn't spawn (carver_only): adds its shape to the world's movement blockers, the + * way its physics component would have (RegisterMovementBlocker), owned by the world. + * @return whether it blocks movement (its data makes it a wall and the server knows its shape) + */ + static bool AddLevelMovementBlocker(LWOOBJID id, LOT lot, const LwoNameValue& settings, const NiPoint3& position, const NiQuaternion& rotation, float scale); protected: bool OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& msg); diff --git a/dPhysics/dpWorld.cpp b/dPhysics/dpWorld.cpp index 1aad9f0f9..47dc5d7c2 100644 --- a/dPhysics/dpWorld.cpp +++ b/dPhysics/dpWorld.cpp @@ -3,6 +3,7 @@ #include "dpGrid.h" #include "DetourCommon.h" +#include #include #include "Game.h" @@ -24,6 +25,7 @@ namespace { bool phys_spatial_partitioning = true; std::vector m_MovementBlockers; + std::vector> m_OwnedMovementBlockers; }; void dpWorld::Initialize(unsigned int zoneID, bool generateNewNavMesh) { @@ -88,6 +90,7 @@ void dpWorld::Shutdown() { } m_MovementBlockers.clear(); + m_OwnedMovementBlockers.clear(); } bool dpWorld::IsLoaded() { @@ -197,6 +200,12 @@ void dpWorld::AddMovementBlocker(const dpEntity* entity, const uint32_t filter) m_MovementBlockers.push_back({ entity, filter }); } +void dpWorld::AddOwnedMovementBlocker(dpEntity* entity, const uint32_t filter) { + if (!entity) return; + m_OwnedMovementBlockers.emplace_back(entity); + AddMovementBlocker(entity, filter); +} + void dpWorld::RemoveMovementBlocker(const dpEntity* entity) { std::erase_if(m_MovementBlockers, [entity](const dpMovementBlocker& blocker) { return blocker.entity == entity; }); } diff --git a/dPhysics/dpWorld.h b/dPhysics/dpWorld.h index f14931c85..37bdf10d1 100644 --- a/dPhysics/dpWorld.h +++ b/dPhysics/dpWorld.h @@ -34,6 +34,11 @@ namespace dpWorld { * the caller, who removes it before deleting it; it is not stepped with the world. */ void AddMovementBlocker(const dpEntity* entity, uint32_t filter); + /** + * A movement blocker with no object of its own (a wall from the level files the server doesn't spawn), owned by the + * world from now on and freed when it shuts down. + */ + void AddOwnedMovementBlocker(dpEntity* entity, uint32_t filter); void RemoveMovementBlocker(const dpEntity* entity); std::span GetMovementBlockers(); diff --git a/dZoneManager/Level.cpp b/dZoneManager/Level.cpp index dd870a42c..27e9aa712 100644 --- a/dZoneManager/Level.cpp +++ b/dZoneManager/Level.cpp @@ -17,6 +17,7 @@ #include "LevelFile.h" #include "ZoneFileLog.h" #include "ClientVersion.h" +#include "PhysicsComponent.h" #include "dConfig.h" #include @@ -166,6 +167,13 @@ void Level::LoadSceneObjects(const std::vector& objects) { continue; } + // The client never loads a carver_only object (and live never sent one), but it still carves the AI navmesh: + // keep it as a wall the server's movers can't walk through, without an object + if (obj.lot != 176 && PhysicsComponent::IsCarverOnly(obj.settings)) { + PhysicsComponent::AddLevelMovementBlocker(obj.id, obj.lot, obj.settings, obj.position, obj.rotation, obj.scale); + continue; + } + if (obj.lot == 176) { //Spawner MakeSpawner(obj, m_SceneID); } else { //Regular object