From 3176c363ab1aad99670240630b14ab541bd8c756 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:28:33 +0200 Subject: [PATCH 01/23] Converted SimulationOwnership to file-scoped namespace --- NitroxClient/GameLogic/SimulationOwnership.cs | 291 +++++++++--------- 1 file changed, 145 insertions(+), 146 deletions(-) diff --git a/NitroxClient/GameLogic/SimulationOwnership.cs b/NitroxClient/GameLogic/SimulationOwnership.cs index b3b2f76989..f1d0b2ed74 100644 --- a/NitroxClient/GameLogic/SimulationOwnership.cs +++ b/NitroxClient/GameLogic/SimulationOwnership.cs @@ -6,204 +6,203 @@ using Nitrox.Model.Subnautica.Packets; using UnityEngine; -namespace NitroxClient.GameLogic +namespace NitroxClient.GameLogic; + +public class SimulationOwnership { - public class SimulationOwnership - { - private readonly IMultiplayerSession multiplayerSession; - private readonly IPacketSender packetSender; - private readonly Dictionary simulatedIdsByLockType = []; - private readonly Dictionary lockRequestsById = []; + private readonly IMultiplayerSession multiplayerSession; + private readonly IPacketSender packetSender; + private readonly Dictionary simulatedIdsByLockType = []; + private readonly Dictionary lockRequestsById = []; - private readonly Dictionary newerSimulationById = []; + private readonly Dictionary newerSimulationById = []; - public SimulationOwnership(IMultiplayerSession muliplayerSession, IPacketSender packetSender) - { - this.multiplayerSession = muliplayerSession; - this.packetSender = packetSender; - } - public bool PlayerHasMinLockType(NitroxId id, SimulationLockType lockType) + public SimulationOwnership(IMultiplayerSession muliplayerSession, IPacketSender packetSender) + { + this.multiplayerSession = muliplayerSession; + this.packetSender = packetSender; + } + public bool PlayerHasMinLockType(NitroxId id, SimulationLockType lockType) + { + if (id != null && simulatedIdsByLockType.TryGetValue(id, out SimulationLockType playerLock)) { - if (id != null && simulatedIdsByLockType.TryGetValue(id, out SimulationLockType playerLock)) - { - return playerLock <= lockType; - } - return false; + return playerLock <= lockType; } + return false; + } - public bool HasAnyLockType(NitroxId id) - { - return PlayerHasMinLockType(id, SimulationLockType.TRANSIENT); - } + public bool HasAnyLockType(NitroxId id) + { + return PlayerHasMinLockType(id, SimulationLockType.TRANSIENT); + } - public bool HasExclusiveLock(NitroxId id) - { - return PlayerHasMinLockType(id, SimulationLockType.EXCLUSIVE); - } + public bool HasExclusiveLock(NitroxId id) + { + return PlayerHasMinLockType(id, SimulationLockType.EXCLUSIVE); + } - public void RequestSimulationLock(NitroxId id, SimulationLockType lockType) - { - SimulationOwnershipRequest ownershipRequest = new SimulationOwnershipRequest(multiplayerSession.Reservation.SessionId, id, lockType); - packetSender.Send(ownershipRequest); - } + public void RequestSimulationLock(NitroxId id, SimulationLockType lockType) + { + SimulationOwnershipRequest ownershipRequest = new SimulationOwnershipRequest(multiplayerSession.Reservation.SessionId, id, lockType); + packetSender.Send(ownershipRequest); + } + + public void RequestSimulationLock(LockRequestBase lockRequest) + { + lockRequestsById[lockRequest.Id] = lockRequest; + RequestSimulationLock(lockRequest.Id, lockRequest.LockType); + } + + public void ReceivedSimulationLockResponse(NitroxId id, bool lockAquired, SimulationLockType lockType) + { + Log.Info($"Received lock response, id: {id} {lockAquired} {lockType}"); - public void RequestSimulationLock(LockRequestBase lockRequest) + if (lockAquired) { - lockRequestsById[lockRequest.Id] = lockRequest; - RequestSimulationLock(lockRequest.Id, lockRequest.LockType); + SimulateEntity(id, lockType); + TreatVehicleEntity(id, true, lockType); } - public void ReceivedSimulationLockResponse(NitroxId id, bool lockAquired, SimulationLockType lockType) + if (lockRequestsById.TryGetValue(id, out LockRequestBase lockRequest)) { - Log.Info($"Received lock response, id: {id} {lockAquired} {lockType}"); + lockRequest.LockRequestComplete(id, lockAquired); + lockRequestsById.Remove(id); + } + } - if (lockAquired) - { - SimulateEntity(id, lockType); - TreatVehicleEntity(id, true, lockType); - } + public void SimulateEntity(NitroxId id, SimulationLockType lockType) + { + simulatedIdsByLockType[id] = lockType; + } - if (lockRequestsById.TryGetValue(id, out LockRequestBase lockRequest)) - { - lockRequest.LockRequestComplete(id, lockAquired); - lockRequestsById.Remove(id); - } - } + public void StopSimulatingEntity(NitroxId id) + { + simulatedIdsByLockType.Remove(id); + } - public void SimulateEntity(NitroxId id, SimulationLockType lockType) - { - simulatedIdsByLockType[id] = lockType; - } + public void TreatSimulatedEntity(SimulatedEntity simulatedEntity) + { + bool isLocalPlayerNewOwner = multiplayerSession.Reservation.SessionId == simulatedEntity.SessionId; - public void StopSimulatingEntity(NitroxId id) + if (TreatVehicleEntity(simulatedEntity.Id, isLocalPlayerNewOwner, simulatedEntity.LockType) || + newerSimulationById.ContainsKey(simulatedEntity.Id)) { - simulatedIdsByLockType.Remove(id); + return; } - public void TreatSimulatedEntity(SimulatedEntity simulatedEntity) + if (isLocalPlayerNewOwner) { - bool isLocalPlayerNewOwner = multiplayerSession.Reservation.SessionId == simulatedEntity.SessionId; - - if (TreatVehicleEntity(simulatedEntity.Id, isLocalPlayerNewOwner, simulatedEntity.LockType) || - newerSimulationById.ContainsKey(simulatedEntity.Id)) + if (simulatedEntity.ChangesPosition) { - return; + EntityPositionBroadcaster.WatchEntity(simulatedEntity.Id); } - if (isLocalPlayerNewOwner) - { - if (simulatedEntity.ChangesPosition) - { - EntityPositionBroadcaster.WatchEntity(simulatedEntity.Id); - } + SimulateEntity(simulatedEntity.Id, simulatedEntity.LockType); + } + else if (HasAnyLockType(simulatedEntity.Id)) + { + // The server has forcibly removed this lock from the client. This is generally fine for + // transient locks because it is only broadcasting position. However, exclusive locks may + // need additional cleanup (such as a person piloting a vehicle - they need to be kicked out) + // We can later add a forcibly removed callback but as of right now we have no use-cases for + // forcibly removing an exclusive lock. Just log it if it happens.... - SimulateEntity(simulatedEntity.Id, simulatedEntity.LockType); - } - else if (HasAnyLockType(simulatedEntity.Id)) + if (HasExclusiveLock(simulatedEntity.Id)) { - // The server has forcibly removed this lock from the client. This is generally fine for - // transient locks because it is only broadcasting position. However, exclusive locks may - // need additional cleanup (such as a person piloting a vehicle - they need to be kicked out) - // We can later add a forcibly removed callback but as of right now we have no use-cases for - // forcibly removing an exclusive lock. Just log it if it happens.... - - if (HasExclusiveLock(simulatedEntity.Id)) - { - Log.Warn($"The server has forcibly revoked an exclusive lock - this may cause undefined behaviour. GUID: {simulatedEntity.Id}"); - } - - StopSimulatingEntity(simulatedEntity.Id); - EntityPositionBroadcaster.StopWatchingEntity(simulatedEntity.Id); + Log.Warn($"The server has forcibly revoked an exclusive lock - this may cause undefined behaviour. GUID: {simulatedEntity.Id}"); } - // Avoid keeping artifacts of the entity's previous ChangesPosition state - if (!simulatedEntity.ChangesPosition && NitroxEntity.TryGetComponentFrom(simulatedEntity.Id, out RemotelyControlled remotelyControlled)) - { - Object.Destroy(remotelyControlled); - } + StopSimulatingEntity(simulatedEntity.Id); + EntityPositionBroadcaster.StopWatchingEntity(simulatedEntity.Id); } - public void DropSimulationFrom(NitroxId entityId) + // Avoid keeping artifacts of the entity's previous ChangesPosition state + if (!simulatedEntity.ChangesPosition && NitroxEntity.TryGetComponentFrom(simulatedEntity.Id, out RemotelyControlled remotelyControlled)) { - StopSimulatingEntity(entityId); - EntityPositionBroadcaster.StopWatchingEntity(entityId); - if (!NitroxEntity.TryGetObjectFrom(entityId, out GameObject gameObject)) - { - return; - } - - if (gameObject.TryGetComponent(out RemotelyControlled remotelyControlled)) - { - Object.Destroy(remotelyControlled); - } + Object.Destroy(remotelyControlled); } + } - public bool TryGetLockType(NitroxId nitroxId, out SimulationLockType simulationLockType) + public void DropSimulationFrom(NitroxId entityId) + { + StopSimulatingEntity(entityId); + EntityPositionBroadcaster.StopWatchingEntity(entityId); + if (!NitroxEntity.TryGetObjectFrom(entityId, out GameObject gameObject)) { - return simulatedIdsByLockType.TryGetValue(nitroxId, out simulationLockType); + return; } - public bool TreatVehicleEntity(NitroxId entityId, bool isLocalPlayerNewOwner, SimulationLockType simulationLockType) + if (gameObject.TryGetComponent(out RemotelyControlled remotelyControlled)) { - if (!NitroxEntity.TryGetObjectFrom(entityId, out GameObject gameObject) || !IsVehicle(gameObject)) - { - return false; - } - - MovementReplicator movementReplicator = gameObject.GetComponent(); - if (isLocalPlayerNewOwner) - { - if (movementReplicator) - { - Object.Destroy(movementReplicator); - } - MovementBroadcaster.RegisterWatched(gameObject, entityId); - SimulateEntity(entityId, simulationLockType); - } - else - { - if (!movementReplicator) - { - MovementReplicator.AddReplicatorToObject(gameObject); - } - MovementBroadcaster.UnregisterWatched(entityId); - StopSimulatingEntity(entityId); - } - - return true; + Object.Destroy(remotelyControlled); } + } - public bool IsVehicle(GameObject gameObject) + public bool TryGetLockType(NitroxId nitroxId, out SimulationLockType simulationLockType) + { + return simulatedIdsByLockType.TryGetValue(nitroxId, out simulationLockType); + } + + public bool TreatVehicleEntity(NitroxId entityId, bool isLocalPlayerNewOwner, SimulationLockType simulationLockType) + { + if (!NitroxEntity.TryGetObjectFrom(entityId, out GameObject gameObject) || !IsVehicle(gameObject)) { - if (gameObject.GetComponent()) + return false; + } + + MovementReplicator movementReplicator = gameObject.GetComponent(); + if (isLocalPlayerNewOwner) + { + if (movementReplicator) { - return true; + Object.Destroy(movementReplicator); } - if (gameObject.TryGetComponent(out SubRoot subRoot) && !subRoot.isBase) + MovementBroadcaster.RegisterWatched(gameObject, entityId); + SimulateEntity(entityId, simulationLockType); + } + else + { + if (!movementReplicator) { - return true; + MovementReplicator.AddReplicatorToObject(gameObject); } - - return false; + MovementBroadcaster.UnregisterWatched(entityId); + StopSimulatingEntity(entityId); } - public void RegisterNewerSimulation(NitroxId entityId, SimulatedEntity simulatedEntity) + return true; + } + + public bool IsVehicle(GameObject gameObject) + { + if (gameObject.GetComponent()) { - newerSimulationById[entityId] = simulatedEntity; + return true; } - - public void ApplyNewerSimulation(NitroxId nitroxId) + if (gameObject.TryGetComponent(out SubRoot subRoot) && !subRoot.isBase) { - if (newerSimulationById.TryGetValue(nitroxId, out SimulatedEntity simulatedEntity)) - { - newerSimulationById.Remove(nitroxId); - TreatSimulatedEntity(simulatedEntity); - } + return true; } - public void ClearNewerSimulations() + return false; + } + + public void RegisterNewerSimulation(NitroxId entityId, SimulatedEntity simulatedEntity) + { + newerSimulationById[entityId] = simulatedEntity; + } + + public void ApplyNewerSimulation(NitroxId nitroxId) + { + if (newerSimulationById.TryGetValue(nitroxId, out SimulatedEntity simulatedEntity)) { - newerSimulationById.Clear(); + newerSimulationById.Remove(nitroxId); + TreatSimulatedEntity(simulatedEntity); } } + + public void ClearNewerSimulations() + { + newerSimulationById.Clear(); + } } From 10dc63eb80f369f7ea79166607780f9826e4e450 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:27:18 +0200 Subject: [PATCH 02/23] Reworked RemotelyControlled and EntityPositionBroadcaster --- .../Extensions/DictionaryExtensions.cs | 24 +++ .../EntityTransformUpdatesProcessor.cs | 1 - .../RemoveCreatureCorpseProcessor.cs | 2 +- NitroxClient/GameLogic/Items.cs | 2 +- NitroxClient/GameLogic/SimulationOwnership.cs | 6 +- .../EntityPositionBroadcaster.cs | 175 +++++++++++++----- .../MonoBehaviours/RemotelyControlled.cs | 79 ++++++-- .../MonoBehaviours/Vehicles/WatchedEntry.cs | 8 +- .../Unity/Smoothing/SmoothRotation.cs | 35 ++-- NitroxClient/Unity/Smoothing/SmoothVector.cs | 35 ++-- .../CreatureDeath_OnKillAsync_Patch.cs | 2 +- .../PipeSurfaceFloater_FixedUpdate_Patch.cs | 2 +- .../PropulsionCannon_GrabObject_Patch.cs | 2 +- .../Dynamic/SplineFollowing_GoTo_Patch.cs | 7 +- 14 files changed, 266 insertions(+), 114 deletions(-) diff --git a/Nitrox.Model/Extensions/DictionaryExtensions.cs b/Nitrox.Model/Extensions/DictionaryExtensions.cs index 8171f2cae3..7903c6dc28 100644 --- a/Nitrox.Model/Extensions/DictionaryExtensions.cs +++ b/Nitrox.Model/Extensions/DictionaryExtensions.cs @@ -30,6 +30,30 @@ public static void RemoveWhere(this IDictionary(this IDictionary dictionary, Func, bool> predicate) + { + int toRemoveIndex = 0; + TKey[] toRemove = ArrayPool.Shared.Rent(dictionary.Count); + try + { + foreach (KeyValuePair item in dictionary) + { + if (predicate.Invoke(item)) + { + toRemove[toRemoveIndex++] = item.Key; + } + } + for (int i = 0; i < toRemoveIndex; i++) + { + dictionary.Remove(toRemove[i]); + } + } + finally + { + ArrayPool.Shared.Return(toRemove, true); + } + } + #if NETFRAMEWORK public static bool TryAdd(this IDictionary dict, TKey key, TValue value) { diff --git a/NitroxClient/Communication/Packets/Processors/EntityTransformUpdatesProcessor.cs b/NitroxClient/Communication/Packets/Processors/EntityTransformUpdatesProcessor.cs index ca110f698e..3ff1aa0490 100644 --- a/NitroxClient/Communication/Packets/Processors/EntityTransformUpdatesProcessor.cs +++ b/NitroxClient/Communication/Packets/Processors/EntityTransformUpdatesProcessor.cs @@ -23,7 +23,6 @@ public Task Process(ClientProcessorContext context, EntityTransformUpdates packe } RemotelyControlled remotelyControlled = RemotelyControlled.Ensure(gameObject); - ; Vector3 position = update.Position.ToUnity(); Quaternion rotation = update.Rotation.ToUnity(); diff --git a/NitroxClient/Communication/Packets/Processors/RemoveCreatureCorpseProcessor.cs b/NitroxClient/Communication/Packets/Processors/RemoveCreatureCorpseProcessor.cs index 5e83d13dbc..e5244dba0f 100644 --- a/NitroxClient/Communication/Packets/Processors/RemoveCreatureCorpseProcessor.cs +++ b/NitroxClient/Communication/Packets/Processors/RemoveCreatureCorpseProcessor.cs @@ -23,7 +23,7 @@ public static void SafeOnKillAsync(CreatureDeath creatureDeath, NitroxId creatur simulationOwnership.StopSimulatingEntity(creatureId); // Remove the position broadcasting stuff from it - EntityPositionBroadcaster.RemoveEntityMovementControl(creatureDeath.gameObject, creatureId); + EntityPositionBroadcaster.Instance.RemoveEntityMovementControl(creatureDeath.gameObject, creatureId); // To avoid SpawnRespawner to be called creatureDeath.respawn = false; diff --git a/NitroxClient/GameLogic/Items.cs b/NitroxClient/GameLogic/Items.cs index 99e7304636..53a4d7c88c 100644 --- a/NitroxClient/GameLogic/Items.cs +++ b/NitroxClient/GameLogic/Items.cs @@ -246,7 +246,7 @@ private InventoryItemEntity ConvertToInventoryEntityUntracked(GameObject gameObj // We want to remove any remote tracking immediately on pickup as it can cause weird behavior like holding a ghost item still in the world. RemoveAnyRemoteControl(gameObject); - EntityPositionBroadcaster.StopWatchingEntity(inventoryItemEntity.Id); + EntityPositionBroadcaster.Instance.StopWatchingEntity(inventoryItemEntity.Id); return inventoryItemEntity; } diff --git a/NitroxClient/GameLogic/SimulationOwnership.cs b/NitroxClient/GameLogic/SimulationOwnership.cs index f1d0b2ed74..8795a84bd2 100644 --- a/NitroxClient/GameLogic/SimulationOwnership.cs +++ b/NitroxClient/GameLogic/SimulationOwnership.cs @@ -94,7 +94,7 @@ public void TreatSimulatedEntity(SimulatedEntity simulatedEntity) { if (simulatedEntity.ChangesPosition) { - EntityPositionBroadcaster.WatchEntity(simulatedEntity.Id); + EntityPositionBroadcaster.Instance.WatchEntity(simulatedEntity.Id); } SimulateEntity(simulatedEntity.Id, simulatedEntity.LockType); @@ -113,7 +113,7 @@ public void TreatSimulatedEntity(SimulatedEntity simulatedEntity) } StopSimulatingEntity(simulatedEntity.Id); - EntityPositionBroadcaster.StopWatchingEntity(simulatedEntity.Id); + EntityPositionBroadcaster.Instance.StopWatchingEntity(simulatedEntity.Id); } // Avoid keeping artifacts of the entity's previous ChangesPosition state @@ -126,7 +126,7 @@ public void TreatSimulatedEntity(SimulatedEntity simulatedEntity) public void DropSimulationFrom(NitroxId entityId) { StopSimulatingEntity(entityId); - EntityPositionBroadcaster.StopWatchingEntity(entityId); + EntityPositionBroadcaster.Instance.StopWatchingEntity(entityId); if (!NitroxEntity.TryGetObjectFrom(entityId, out GameObject gameObject)) { return; diff --git a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs index 6fcdf2fd02..3a92dc225b 100644 --- a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs +++ b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs @@ -1,12 +1,8 @@ using System.Collections.Generic; -using System.Linq; -using NitroxClient.Communication.Abstract; -using NitroxClient.GameLogic; -using Nitrox.Model.Core; using Nitrox.Model.DataStructures; -using Nitrox.Model.Packets; -using Nitrox.Model.Subnautica.DataStructures; using Nitrox.Model.Subnautica.Packets; +using NitroxClient.Communication.Abstract; +using NitroxClient.GameLogic; using UnityEngine; using static Nitrox.Model.Subnautica.Packets.EntityTransformUpdates; @@ -14,19 +10,51 @@ namespace NitroxClient.MonoBehaviours; public class EntityPositionBroadcaster : MonoBehaviour { - public static readonly float BROADCAST_INTERVAL = 0.25f; + public static EntityPositionBroadcaster Instance; - private static HashSet watchingEntityIds = new(); + public static readonly float BROADCAST_INTERVAL = 0.25f; - private static Dictionary splineUpdatesById = new(); + /// + /// Dictionary of watched entities that don't follow spline movements. + /// + private readonly Dictionary regularEntities = []; + /// + /// Dictionary of watched entities that follow spline movements. + /// + private readonly Dictionary splineEntities = []; + /// + /// Set of watched entities that weren't spawned yet. + /// + private readonly HashSet notSpawnedEntityIds = []; + /// + /// Latest registered spline updates from SplineFollowing.GoTo + /// + private readonly Dictionary splineUpdatesById = []; + /// + /// Reusable list of s to avoid reallocating a new list at each broadcast. + /// + /// + /// This only works because immediately serialiazes the list. + /// + private readonly List updates = new(50); private IPacketSender packetSender; + private SimulationOwnership simulationOwnership; private float time; public void Awake() { - packetSender = NitroxServiceLocator.LocateService(); + if (Instance) + { + Log.Error($"There's already a {nameof(EntityPositionBroadcaster)} Instance alive, destroying the new one."); + Destroy(this); + return; + } + Instance = this; + + packetSender = this.Resolve(); + simulationOwnership = this.Resolve(); } public void Update() @@ -38,73 +66,132 @@ public void Update() { time = 0; - if (watchingEntityIds.Count > 0) + CheckEntities(); + BuildUpdates(); + + if (updates.Count > 0) { - Dictionary nonSplineEntitiesById = NitroxEntity.GetObjectsFrom(watchingEntityIds) - .Where(item => !item.Value.GetComponent() && - !item.Value.GetComponent()) - .ToDictionary(item => item.Key, item => item.Value); - - List updates = BuildUpdates(nonSplineEntitiesById); - - if (updates.Count > 0) - { - packetSender.Send(new EntityTransformUpdates(updates)); - } + packetSender.Send(new EntityTransformUpdates(updates)); } } } - private List BuildUpdates(Dictionary nonSplineEntitiesById) + private void BuildUpdates() { - List updates = new(); + // Avoid any GC allocation + updates.Clear(); - foreach (KeyValuePair gameObjectWithId in nonSplineEntitiesById) + foreach (KeyValuePair entityPair in regularEntities) { - if (gameObjectWithId.Value) - { - updates.Add(new RawTransformUpdate(gameObjectWithId.Key, gameObjectWithId.Value.transform.position.ToDto(), gameObjectWithId.Value.transform.rotation.ToDto())); - } + Transform entityTransform = entityPair.Value.transform; + updates.Add(new RawTransformUpdate(entityPair.Key, entityTransform.position.ToDto(), entityTransform.rotation.ToDto())); } // Only send data for entities still simulated by the local player - updates.AddRange(splineUpdatesById.Values.Where( - splineUpdate => this.Resolve().HasAnyLockType(splineUpdate.Id) - )); + foreach (SplineTransformUpdate splineUpdate in splineUpdatesById.Values) + { + if (simulationOwnership.HasAnyLockType(splineUpdate.Id)) + { + updates.Add(splineUpdate); + } + } splineUpdatesById.Clear(); - - return updates; } - public static void WatchEntity(NitroxId id) + public void WatchEntity(NitroxId id) { - watchingEntityIds.Add(id); - // The game object may not exist at this very moment (due to being spawned in async). This is OK as we will // automatically start sending updates when we finally get it in the world. This behavior will also allow us // to resync or respawn entities while still have broadcasting enabled without doing anything extra. - - if (NitroxEntity.TryGetComponentFrom(id, out RemotelyControlled remotelyControlled)) + + if (NitroxEntity.TryGetObjectFrom(id, out GameObject entityObject)) + { + SortEntity(id, entityObject); + } + else + { + notSpawnedEntityIds.Add(id); + } + } + + private void SortEntity(NitroxId nitroxId, GameObject entityObject) + { + if (entityObject.TryGetComponent(out SwimBehaviour swimBehaviour) && swimBehaviour.enabled) + { + splineEntities[nitroxId] = swimBehaviour; + } + else + { + regularEntities[nitroxId] = entityObject; + } + + if (entityObject.TryGetComponent(out RemotelyControlled remotelyControlled)) { Object.Destroy(remotelyControlled); } } - public static void StopWatchingEntity(NitroxId id) + private void CheckEntities() + { + // when fishes die, they're only a corpse and their swim behaviour stops functioning + splineEntities.RemoveWhere(pair => + { + SwimBehaviour swimBehaviour = pair.Value; + + if (!swimBehaviour) + { + notSpawnedEntityIds.Add(pair.Key); + return true; + } + + if (!swimBehaviour.enabled) + { + regularEntities[pair.Key] = swimBehaviour.gameObject; + return true; + } + return false; + }); + + regularEntities.RemoveWhere(pair => + { + if (!pair.Value) + { + notSpawnedEntityIds.Add(pair.Key); + return true; + } + return false; + }); + + // in case a fish was removed from splineEntities (from the above loop), it can be added back in here as a regular entity if required + // NB: keep this section below the other RemoveWhere sections so it can eventually collect fresh references from the NitroxIds + notSpawnedEntityIds.RemoveWhere(id => + { + if (NitroxEntity.TryGetObjectFrom(id, out GameObject entityObject)) + { + SortEntity(id, entityObject); + return true; + } + return false; + }); + } + + public void StopWatchingEntity(NitroxId id) { - watchingEntityIds.Remove(id); + splineEntities.Remove(id); + regularEntities.Remove(id); + notSpawnedEntityIds.Remove(id); } - public static void RegisterSplineMovementChange(NitroxId id, GameObject gameObject, Vector3 targetPos, Vector3 targetDir, float velocity) + public void RegisterSplineMovementChange(NitroxId id, GameObject gameObject, Vector3 targetPos, Vector3 targetDir, float velocity) { - if (watchingEntityIds.Contains(id)) + if (splineEntities.ContainsKey(id)) { splineUpdatesById[id] = new(id, gameObject.transform.position.ToDto(), gameObject.transform.rotation.ToDto(), targetPos.ToDto(), targetDir.ToDto(), velocity); } } - public static void RemoveEntityMovementControl(GameObject gameObject, NitroxId entityId) + public void RemoveEntityMovementControl(GameObject gameObject, NitroxId entityId) { if (gameObject.TryGetComponent(out RemotelyControlled remotelyControlled)) { diff --git a/NitroxClient/MonoBehaviours/RemotelyControlled.cs b/NitroxClient/MonoBehaviours/RemotelyControlled.cs index b3b0f36590..b8e02aa833 100644 --- a/NitroxClient/MonoBehaviours/RemotelyControlled.cs +++ b/NitroxClient/MonoBehaviours/RemotelyControlled.cs @@ -12,17 +12,38 @@ public class RemotelyControlled : MonoBehaviour private SwimBehaviour swimBehaviour; private WalkBehaviour walkBehaviour; private Rigidbody rigidbody; + private WorldForces worldForces; public void Awake() { swimBehaviour = gameObject.GetComponent(); walkBehaviour = gameObject.GetComponent(); rigidbody = gameObject.GetComponent(); + worldForces = gameObject.GetComponent(); + + if (rigidbody) + { + rigidbody.interpolation = RigidbodyInterpolation.Interpolate; + } + + if (worldForces) + { + worldForces.enabled = false; + } + } + + public void OnDestroy() + { + if (worldForces) + { + worldForces.enabled = true; + } } public void FixedUpdate() { - if (swimBehaviour || walkBehaviour || !rigidbody) + // (WalkBehaviour inherits from SwimBehaviour) + if (swimBehaviour && swimBehaviour.enabled) { return; } @@ -30,25 +51,40 @@ public void FixedUpdate() smoothPosition.FixedUpdate(); smoothRotation.FixedUpdate(); - rigidbody.isKinematic = false; - rigidbody.velocity = MovementHelper.GetCorrectedVelocity(smoothPosition.Current, Vector3.zero, gameObject, EntityPositionBroadcaster.BROADCAST_INTERVAL); - rigidbody.angularVelocity = MovementHelper.GetCorrectedAngularVelocity(smoothRotation.Current, Vector3.zero, gameObject, EntityPositionBroadcaster.BROADCAST_INTERVAL); + if (rigidbody) + { + if (rigidbody.isKinematic) + { + rigidbody.isKinematic = false; + } + rigidbody.velocity = MovementHelper.GetCorrectedVelocity(smoothPosition.Current, Vector3.zero, gameObject, EntityPositionBroadcaster.BROADCAST_INTERVAL); + rigidbody.angularVelocity = MovementHelper.GetCorrectedAngularVelocity(smoothRotation.Current, Vector3.zero, gameObject, EntityPositionBroadcaster.BROADCAST_INTERVAL); + } + else + { + transform.position = smoothPosition.Current; + transform.rotation = smoothRotation.Current; + } } public void UpdateOrientation(Vector3 position, Quaternion rotation) { - TeleportIfTooFar(position, rotation); + bool teleported = TeleportIfTooFar(position, rotation); - if (swimBehaviour) + if (swimBehaviour && swimBehaviour.enabled) { swimBehaviour.SwimTo(position, 3f); - } - Transform selfTransform = transform; + smoothPosition.Current = transform.position; + smoothRotation.Current = transform.rotation; + } + else if (teleported) + { + smoothPosition.Current = position; + smoothRotation.Current = rotation; + } // Entities can lose their swimBehavior (such as if they get killed). Keep these up-to-date incase that happens. - smoothPosition.Current = selfTransform.position; - smoothRotation.Current = selfTransform.rotation; smoothPosition.Target = position; smoothRotation.Target = rotation; } @@ -57,7 +93,7 @@ public void UpdateKnownSplineUser(Vector3 currentPosition, Quaternion currentRot { TeleportIfTooFar(currentPosition, currentRotation); - if (swimBehaviour) + if (swimBehaviour && swimBehaviour.enabled) { // First lines of SwimBehaviour.SwimToInternal swimBehaviour.originalTargetPosition = destination; @@ -67,21 +103,30 @@ public void UpdateKnownSplineUser(Vector3 currentPosition, Quaternion currentRot swimBehaviour.splineFollowing.GoTo(destination, destinationDirection, velocity); } - if (walkBehaviour) + if (walkBehaviour && walkBehaviour.enabled) { walkBehaviour.GoToInternal(destination, destinationDirection, velocity); } } - private void TeleportIfTooFar(Vector3 position, Quaternion rotation) + private bool TeleportIfTooFar(Vector3 position, Quaternion rotation) { - Transform selfTransform = transform; + if ((transform.position - position).sqrMagnitude <= 25) // Optimized 5m distance test + { + return false; + } - if ((selfTransform.position - position).sqrMagnitude > 25) // Optimized 5m distance test + if (rigidbody) + { + rigidbody.position = position; + rigidbody.rotation = rotation; + } + else { - selfTransform.position = position; - selfTransform.rotation = rotation; + transform.position = position; + transform.rotation = rotation; } + return true; } public static RemotelyControlled Ensure(GameObject gameObject) diff --git a/NitroxClient/MonoBehaviours/Vehicles/WatchedEntry.cs b/NitroxClient/MonoBehaviours/Vehicles/WatchedEntry.cs index 1c61549a23..103cb62e22 100644 --- a/NitroxClient/MonoBehaviours/Vehicles/WatchedEntry.cs +++ b/NitroxClient/MonoBehaviours/Vehicles/WatchedEntry.cs @@ -22,7 +22,7 @@ public class WatchedEntry /// private const float SAFETY_BROADCAST_WINDOW = 0.2f; - private readonly NitroxId Id; + private readonly NitroxId id; private readonly Transform transform; private readonly Vehicle vehicle; private readonly SubControl subControl; @@ -31,9 +31,9 @@ public class WatchedEntry private Vector3 latestLocalPositionSent; private Quaternion latestLocalRotationSent; - public WatchedEntry(NitroxId Id, Transform transform) + public WatchedEntry(NitroxId id, Transform transform) { - this.Id = Id; + this.id = id; this.transform = transform; vehicle = transform.GetComponent(); subControl = transform.GetComponent(); @@ -124,7 +124,7 @@ public bool ShouldBroadcastMovement() // Watched entry validity check (e.g. for vehicle death) if (!transform) { - MovementBroadcaster.UnregisterWatched(Id); + MovementBroadcaster.UnregisterWatched(id); return false; } diff --git a/NitroxClient/Unity/Smoothing/SmoothRotation.cs b/NitroxClient/Unity/Smoothing/SmoothRotation.cs index cc7b0c4c60..f4e44d35ce 100644 --- a/NitroxClient/Unity/Smoothing/SmoothRotation.cs +++ b/NitroxClient/Unity/Smoothing/SmoothRotation.cs @@ -1,25 +1,24 @@ -using UnityEngine; +using UnityEngine; -namespace NitroxClient.Unity.Smoothing +namespace NitroxClient.Unity.Smoothing; + +public class SmoothRotation { - public class SmoothRotation - { - private const float SMOOTHING_SPEED = 10f; - public Quaternion Target { get; set; } - public Quaternion Current { get; set; } + private const float SMOOTHING_SPEED = 10f; + public Quaternion Target { get; set; } + public Quaternion Current { get; set; } - public SmoothRotation(Quaternion initial) - { - Target = Current = initial; - } + public SmoothRotation(Quaternion initial) + { + Target = Current = initial; + } - public SmoothRotation() - { - } + public SmoothRotation() + { + } - public void FixedUpdate() - { - Current = Quaternion.Slerp(Current, Target, SMOOTHING_SPEED * Time.fixedDeltaTime); - } + public void FixedUpdate() + { + Current = Quaternion.Slerp(Current, Target, SMOOTHING_SPEED * Time.fixedDeltaTime); } } diff --git a/NitroxClient/Unity/Smoothing/SmoothVector.cs b/NitroxClient/Unity/Smoothing/SmoothVector.cs index 2f3d36b67d..dccaf4a0b9 100644 --- a/NitroxClient/Unity/Smoothing/SmoothVector.cs +++ b/NitroxClient/Unity/Smoothing/SmoothVector.cs @@ -1,25 +1,24 @@ -using UnityEngine; +using UnityEngine; -namespace NitroxClient.Unity.Smoothing +namespace NitroxClient.Unity.Smoothing; + +public class SmoothVector { - public class SmoothVector - { - private const float SMOOTHING_SPEED = 10f; - public Vector3 Target { get; set; } - public Vector3 Current { get; set; } + private const float SMOOTHING_SPEED = 10f; + public Vector3 Target { get; set; } + public Vector3 Current { get; set; } - public SmoothVector(Vector3 initial) - { - Target = Current = initial; - } + public SmoothVector(Vector3 initial) + { + Target = Current = initial; + } - public SmoothVector() - { - } + public SmoothVector() + { + } - public void FixedUpdate() - { - Current = UWE.Utils.SlerpVector(Current, Target, (Target - Current).normalized * SMOOTHING_SPEED * Time.fixedDeltaTime); - } + public void FixedUpdate() + { + Current = UWE.Utils.SlerpVector(Current, Target, (Target - Current).normalized * SMOOTHING_SPEED * Time.fixedDeltaTime); } } diff --git a/NitroxPatcher/Patches/Dynamic/CreatureDeath_OnKillAsync_Patch.cs b/NitroxPatcher/Patches/Dynamic/CreatureDeath_OnKillAsync_Patch.cs index afb2c661d8..379fe8a357 100644 --- a/NitroxPatcher/Patches/Dynamic/CreatureDeath_OnKillAsync_Patch.cs +++ b/NitroxPatcher/Patches/Dynamic/CreatureDeath_OnKillAsync_Patch.cs @@ -88,7 +88,7 @@ public static void BroadcastRemoveCorpse(CreatureDeath creatureDeath) } Resolve().StopSimulatingEntity(creatureId); - EntityPositionBroadcaster.RemoveEntityMovementControl(creatureDeath.gameObject, creatureId); + EntityPositionBroadcaster.Instance.RemoveEntityMovementControl(creatureDeath.gameObject, creatureId); if (!IsRemotelyCalled) { diff --git a/NitroxPatcher/Patches/Dynamic/PipeSurfaceFloater_FixedUpdate_Patch.cs b/NitroxPatcher/Patches/Dynamic/PipeSurfaceFloater_FixedUpdate_Patch.cs index cb99082903..2f56fce33b 100644 --- a/NitroxPatcher/Patches/Dynamic/PipeSurfaceFloater_FixedUpdate_Patch.cs +++ b/NitroxPatcher/Patches/Dynamic/PipeSurfaceFloater_FixedUpdate_Patch.cs @@ -35,7 +35,7 @@ public static void StopWatching(PipeSurfaceFloater pipeSurfaceFloater) if (pipeSurfaceFloater.TryGetIdOrWarn(out NitroxId pipeFloaterId) && Resolve().HasAnyLockType(pipeFloaterId)) { Resolve().StopSimulatingEntity(pipeFloaterId); - EntityPositionBroadcaster.StopWatchingEntity(pipeFloaterId); + EntityPositionBroadcaster.Instance.StopWatchingEntity(pipeFloaterId); } } } diff --git a/NitroxPatcher/Patches/Dynamic/PropulsionCannon_GrabObject_Patch.cs b/NitroxPatcher/Patches/Dynamic/PropulsionCannon_GrabObject_Patch.cs index ca4f9df74a..7d09454b3d 100644 --- a/NitroxPatcher/Patches/Dynamic/PropulsionCannon_GrabObject_Patch.cs +++ b/NitroxPatcher/Patches/Dynamic/PropulsionCannon_GrabObject_Patch.cs @@ -53,7 +53,7 @@ private static void ReceivedSimulationLockResponse(NitroxId id, bool lockAquired // In case what we grabbed wasn't a vehicle, we'll be watching it with the regular entity position broadcast system if (!Resolve().TreatVehicleEntity(id, true, SimulationLockType.EXCLUSIVE)) { - EntityPositionBroadcaster.WatchEntity(id); + EntityPositionBroadcaster.Instance.WatchEntity(id); } skipPrefixPatch = true; diff --git a/NitroxPatcher/Patches/Dynamic/SplineFollowing_GoTo_Patch.cs b/NitroxPatcher/Patches/Dynamic/SplineFollowing_GoTo_Patch.cs index 6988425fde..5934cb1126 100644 --- a/NitroxPatcher/Patches/Dynamic/SplineFollowing_GoTo_Patch.cs +++ b/NitroxPatcher/Patches/Dynamic/SplineFollowing_GoTo_Patch.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Nitrox.Model.DataStructures; using NitroxClient.MonoBehaviours; using UnityEngine; @@ -15,11 +16,9 @@ public sealed partial class SplineFollowing_GoTo_Patch : NitroxPatch, IDynamicPa public static void Prefix(SplineFollowing __instance, Vector3 targetPos, Vector3 targetDir, float velocity) { - NitroxEntity nitroxEntity = __instance.GetComponent(); - - if (nitroxEntity) + if (__instance.TryGetIdOrWarn(out NitroxId nitroxId)) { - EntityPositionBroadcaster.RegisterSplineMovementChange(nitroxEntity.Id, __instance.gameObject, targetPos, targetDir, velocity); + EntityPositionBroadcaster.Instance.RegisterSplineMovementChange(nitroxId, __instance.gameObject, targetPos, targetDir, velocity); } } } From 022cc0ce4247510b77b93afc0741068a1e92fca5 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:58:24 +0200 Subject: [PATCH 03/23] Reworked RemotelyControlled to fix Pipe Surface Floater's movements --- .../GameLogic/Entities/EntitySimulation.cs | 11 +++ .../EntitySpawnedByClientProcessor.cs | 5 +- NitroxClient/GameLogic/Items.cs | 7 +- NitroxClient/GameLogic/MovementHelper.cs | 20 ++--- NitroxClient/GameLogic/SimulationOwnership.cs | 25 ++++-- .../MonoBehaviours/RemotelyControlled.cs | 84 +++++++++++++------ .../RemotelyControlledPipeFloater.cs | 20 +++-- .../PipeSurfaceFloater_FixedUpdate_Patch.cs | 36 ++++---- 8 files changed, 125 insertions(+), 83 deletions(-) diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs index 868f068568..6818864ea0 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs +++ b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs @@ -130,6 +130,17 @@ public bool TryAssignEntityToPlayer(Entity entity, Player player, bool shouldEnt return false; } + /// + /// Forcefully assign an entity to a player, revoking any previous ownership. + /// + public SimulatedEntity AssignEntityToPlayer(Entity entity, Player player, bool shouldEntityMove) + { + simulationOwnershipData.RevokeOwnerOfId(entity.Id); + simulationOwnershipData.TryToAcquire(entity.Id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); + bool doesEntityMove = shouldEntityMove && entity is WorldEntity worldEntity && ShouldSimulateEntityMovement(worldEntity); + return new(entity.Id, player.SessionId, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); + } + public List AssignGlobalRootEntitiesAndGetData(Player player) { List simulatedEntities = new(); diff --git a/Nitrox.Server.Subnautica/Models/Packets/Processors/EntitySpawnedByClientProcessor.cs b/Nitrox.Server.Subnautica/Models/Packets/Processors/EntitySpawnedByClientProcessor.cs index c13fec0ccd..fbee69905c 100644 --- a/Nitrox.Server.Subnautica/Models/Packets/Processors/EntitySpawnedByClientProcessor.cs +++ b/Nitrox.Server.Subnautica/Models/Packets/Processors/EntitySpawnedByClientProcessor.cs @@ -29,10 +29,11 @@ public async Task Process(AuthProcessorContext context, EntitySpawnedByClient pa worldEntityManager.TrackEntityInTheWorld(worldEntity); } - if (packet.RequireSimulation && entitySimulation.TryAssignEntityToPlayer(entity, context.Sender, true, out simulatedEntity)) + if (packet.RequireSimulation) { + simulatedEntity = entitySimulation.AssignEntityToPlayer(entity, context.Sender, true); SimulationOwnershipChange ownershipChangePacket = new(simulatedEntity); - await context.SendToAllAsync(ownershipChangePacket); + await context.SendToOthersAsync(ownershipChangePacket); } SpawnEntities spawnEntities = new(entity, simulatedEntity, packet.RequireRespawn); diff --git a/NitroxClient/GameLogic/Items.cs b/NitroxClient/GameLogic/Items.cs index 53a4d7c88c..6812f85abb 100644 --- a/NitroxClient/GameLogic/Items.cs +++ b/NitroxClient/GameLogic/Items.cs @@ -21,6 +21,7 @@ public class Items private readonly Entities entities; public static GameObject? PickingUpObject { get; private set; } private readonly EntityMetadataManager entityMetadataManager; + private readonly SimulationOwnership simulationOwnership; /// /// Whether or not or a similar method is running (if greater than 0). @@ -29,11 +30,12 @@ public class Items public int PickingUpCount; - public Items(IPacketSender packetSender, Entities entities, EntityMetadataManager entityMetadataManager) + public Items(IPacketSender packetSender, Entities entities, EntityMetadataManager entityMetadataManager, SimulationOwnership simulationOwnership) { this.packetSender = packetSender; this.entities = entities; this.entityMetadataManager = entityMetadataManager; + this.simulationOwnership = simulationOwnership; } public void PickedUpByPlayer(GameObject gameObject, TechType techType) @@ -158,7 +160,8 @@ public void Dropped(GameObject gameObject, TechType? techType = null) droppedItem = new(gameObject.transform.ToWorldDto(), level, classId, false, id, techType.Value.ToDto(), metadata.OrNull(), null, childrenEntities); } - if (packetSender.Send(new EntitySpawnedByClient(droppedItem, true))) + simulationOwnership.TakeOwnership(id, SimulationLockType.TRANSIENT, true); + if (packetSender.Send(new EntitySpawnedByClient(droppedItem, true, true))) { Log.Debug($"Dropping item: {droppedItem}"); } diff --git a/NitroxClient/GameLogic/MovementHelper.cs b/NitroxClient/GameLogic/MovementHelper.cs index 0dc5880bb7..b2d1c994d1 100644 --- a/NitroxClient/GameLogic/MovementHelper.cs +++ b/NitroxClient/GameLogic/MovementHelper.cs @@ -1,4 +1,4 @@ -using UnityEngine; +using UnityEngine; namespace NitroxClient.GameLogic { @@ -32,22 +32,13 @@ public static void Stop(GameObject go) public static Vector3 GetCorrectedVelocity(Vector3 remotePosition, Vector3 remoteVelocity, GameObject gameObject, float correctionTime) { Vector3 difference = remotePosition - gameObject.transform.position; - Vector3 velocityToMakeUpDifference = difference / correctionTime; - float distance = difference.magnitude; - - // NaN guard to recover if distance becomes invalid - if (float.IsNaN(distance) || distance > 20f) - { - // This should be a one-off teleport. - gameObject.transform.position = remotePosition; - } - else + if (float.IsNaN(difference.x) || float.IsNaN(difference.y) || float.IsNaN(difference.z) || correctionTime == 0f) { - remoteVelocity = velocityToMakeUpDifference; + return Vector3.zero; } - return remoteVelocity; + return difference / correctionTime; } public static Vector3 GetCorrectedAngularVelocity(Quaternion remoteRotation, Vector3 angularVelocty, GameObject gameObject, float correctionTime) @@ -63,9 +54,8 @@ public static Vector3 GetCorrectedAngularVelocity(Quaternion remoteRotation, Vec } // Guard for NaN when remoteRotation and gameobjects rotation are parallel (witnessed during macOS habitat transition) - if (float.IsNaN(angle) || float.IsNaN(axis.x)) + if (float.IsNaN(angle) || float.IsNaN(axis.x) || float.IsNaN(axis.y) || float.IsNaN(axis.z)) { - gameObject.transform.rotation = remoteRotation; return angularVelocty; } diff --git a/NitroxClient/GameLogic/SimulationOwnership.cs b/NitroxClient/GameLogic/SimulationOwnership.cs index 8795a84bd2..b7c2162d53 100644 --- a/NitroxClient/GameLogic/SimulationOwnership.cs +++ b/NitroxClient/GameLogic/SimulationOwnership.cs @@ -17,9 +17,9 @@ public class SimulationOwnership private readonly Dictionary newerSimulationById = []; - public SimulationOwnership(IMultiplayerSession muliplayerSession, IPacketSender packetSender) + public SimulationOwnership(IMultiplayerSession multiplayerSession, IPacketSender packetSender) { - this.multiplayerSession = muliplayerSession; + this.multiplayerSession = multiplayerSession; this.packetSender = packetSender; } public bool PlayerHasMinLockType(NitroxId id, SimulationLockType lockType) @@ -92,12 +92,7 @@ public void TreatSimulatedEntity(SimulatedEntity simulatedEntity) if (isLocalPlayerNewOwner) { - if (simulatedEntity.ChangesPosition) - { - EntityPositionBroadcaster.Instance.WatchEntity(simulatedEntity.Id); - } - - SimulateEntity(simulatedEntity.Id, simulatedEntity.LockType); + TakeOwnership(simulatedEntity.Id, simulatedEntity.LockType, simulatedEntity.ChangesPosition); } else if (HasAnyLockType(simulatedEntity.Id)) { @@ -123,6 +118,20 @@ public void TreatSimulatedEntity(SimulatedEntity simulatedEntity) } } + /// + /// Call this when the Local player is absolutely sure of taking ownership of an entity. + /// For example, when spawning an entity, you know it's "yours" so you can take ownership of it. + /// + public void TakeOwnership(NitroxId entityId, SimulationLockType lockType, bool changesPosition) + { + if (changesPosition) + { + EntityPositionBroadcaster.Instance.WatchEntity(entityId); + } + + SimulateEntity(entityId, lockType); + } + public void DropSimulationFrom(NitroxId entityId) { StopSimulatingEntity(entityId); diff --git a/NitroxClient/MonoBehaviours/RemotelyControlled.cs b/NitroxClient/MonoBehaviours/RemotelyControlled.cs index b8e02aa833..fa2ac56c2a 100644 --- a/NitroxClient/MonoBehaviours/RemotelyControlled.cs +++ b/NitroxClient/MonoBehaviours/RemotelyControlled.cs @@ -6,43 +6,43 @@ namespace NitroxClient.MonoBehaviours; public class RemotelyControlled : MonoBehaviour { - private readonly SmoothVector smoothPosition = new SmoothVector(); - private readonly SmoothRotation smoothRotation = new SmoothRotation(); + protected SmoothVector smoothPosition; + protected SmoothRotation smoothRotation; private SwimBehaviour swimBehaviour; - private WalkBehaviour walkBehaviour; - private Rigidbody rigidbody; + protected Rigidbody rigidbody; private WorldForces worldForces; + private bool disabledWorldForces; + public void Awake() { swimBehaviour = gameObject.GetComponent(); - walkBehaviour = gameObject.GetComponent(); rigidbody = gameObject.GetComponent(); worldForces = gameObject.GetComponent(); + bool followsSpline = swimBehaviour && swimBehaviour.enabled; if (rigidbody) { rigidbody.interpolation = RigidbodyInterpolation.Interpolate; + if (followsSpline) + { + rigidbody.isKinematic = false; + } } - if (worldForces) + if (worldForces && !followsSpline) { + disabledWorldForces = worldForces.enabled; worldForces.enabled = false; } - } - public void OnDestroy() - { - if (worldForces) - { - worldForces.enabled = true; - } + smoothPosition = new(transform.position); + smoothRotation = new(transform.rotation); } public void FixedUpdate() { - // (WalkBehaviour inherits from SwimBehaviour) if (swimBehaviour && swimBehaviour.enabled) { return; @@ -67,9 +67,20 @@ public void FixedUpdate() } } + public void OnDestroy() + { + // there might be other instances calling worldForces.enabled = false during its lifetime but we can't really detect those easily + // so we just hope nothing major breaks + if (worldForces && disabledWorldForces) + { + worldForces.enabled = true; + } + } + public void UpdateOrientation(Vector3 position, Quaternion rotation) { - bool teleported = TeleportIfTooFar(position, rotation); + float velocity = rigidbody ? rigidbody.velocity.magnitude : 0f; + bool teleported = TeleportIfTooFar(position, rotation, GetTeleportThreshold(velocity)); if (swimBehaviour && swimBehaviour.enabled) { @@ -91,27 +102,41 @@ public void UpdateOrientation(Vector3 position, Quaternion rotation) public void UpdateKnownSplineUser(Vector3 currentPosition, Quaternion currentRotation, Vector3 destination, Vector3 destinationDirection, float velocity) { - TeleportIfTooFar(currentPosition, currentRotation); + bool teleported = TeleportIfTooFar(currentPosition, currentRotation, GetTeleportThreshold(velocity)); + // SwimBehaviour and WalkBehaviour will act the exact same if (swimBehaviour && swimBehaviour.enabled) { - // First lines of SwimBehaviour.SwimToInternal + float adjustedVelocity = velocity; + + if (!teleported) + { + float distance = Vector3.Distance(currentPosition, destination); + + // avoid too short paths + if (distance > 0.1f) + { + float localDistance = Vector3.Distance(transform.position, destination); + + adjustedVelocity *= localDistance / distance; + + adjustedVelocity = Mathf.Clamp(adjustedVelocity, velocity * 0.5f, velocity * 1.5f); + } + } + + // Adjust the target data and velocity swimBehaviour.originalTargetPosition = destination; swimBehaviour.originalTargetDirection = destinationDirection; - swimBehaviour.originalVelocity = velocity; - // Only the useful part of the methods called in SwimBehaviour.SwimToInternal - swimBehaviour.splineFollowing.GoTo(destination, destinationDirection, velocity); - } + swimBehaviour.originalVelocity = adjustedVelocity; - if (walkBehaviour && walkBehaviour.enabled) - { - walkBehaviour.GoToInternal(destination, destinationDirection, velocity); + // Trigger either SwimBehaviour.GoToInternal or WalkBehaviour.GoToInternal so they use their own way to pass the data to the SplineFollowing + swimBehaviour.GoToInternal(destination, destinationDirection, adjustedVelocity); } } - private bool TeleportIfTooFar(Vector3 position, Quaternion rotation) + private bool TeleportIfTooFar(Vector3 position, Quaternion rotation, float teleportThreshold) { - if ((transform.position - position).sqrMagnitude <= 25) // Optimized 5m distance test + if ((transform.position - position).sqrMagnitude <= teleportThreshold * teleportThreshold) { return false; } @@ -120,6 +145,8 @@ private bool TeleportIfTooFar(Vector3 position, Quaternion rotation) { rigidbody.position = position; rigidbody.rotation = rotation; + rigidbody.velocity = Vector3.zero; + rigidbody.angularVelocity = Vector3.zero; } else { @@ -129,6 +156,11 @@ private bool TeleportIfTooFar(Vector3 position, Quaternion rotation) return true; } + private static float GetTeleportThreshold(float velocity) + { + return Mathf.Max(5f, velocity * 1.5f); + } + public static RemotelyControlled Ensure(GameObject gameObject) { if (gameObject.TryGetComponent(out RemotelyControlled remotelyControlled)) diff --git a/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs b/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs index e1fd9f184a..e119b14346 100644 --- a/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs +++ b/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs @@ -1,22 +1,26 @@ namespace NitroxClient.MonoBehaviours; /// -/// Ensures a deployed is no longer remotely moved (it is supposed to be static). -/// Also ensures no longer sets isKinematic to false when deployed. +/// Ensures a surfaced is no longer movable (it is supposed to be static). /// public class RemotelyControlledPipeFloater : RemotelyControlled { - private PipeSurfaceFloater pipeSurfaceFloater; - - public new void Awake() + private bool positioned; + + public void SetPositioned() { - base.Awake(); - pipeSurfaceFloater = gameObject.GetComponent(); + positioned = true; + rigidbody.isKinematic = true; } public new void FixedUpdate() { - if (!pipeSurfaceFloater.deployed) + if (positioned) + { + rigidbody.position = smoothPosition.Target; + rigidbody.rotation = smoothRotation.Target; + } + else { base.FixedUpdate(); } diff --git a/NitroxPatcher/Patches/Dynamic/PipeSurfaceFloater_FixedUpdate_Patch.cs b/NitroxPatcher/Patches/Dynamic/PipeSurfaceFloater_FixedUpdate_Patch.cs index 2f56fce33b..d7e97c0e73 100644 --- a/NitroxPatcher/Patches/Dynamic/PipeSurfaceFloater_FixedUpdate_Patch.cs +++ b/NitroxPatcher/Patches/Dynamic/PipeSurfaceFloater_FixedUpdate_Patch.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; using System.Reflection; -using System.Reflection.Emit; -using HarmonyLib; using Nitrox.Model.DataStructures; using NitroxClient.GameLogic; using NitroxClient.MonoBehaviours; @@ -9,33 +6,28 @@ namespace NitroxPatcher.Patches.Dynamic; /// -/// Stops broadcasting position of a pipe surface floater when it has been deployed because it will no longer move. +/// Prevents non simulating player from applying upwards forces to PipeSurfaceFloater. Instead, detect when it reaches the surface and then +/// notify . /// public sealed partial class PipeSurfaceFloater_FixedUpdate_Patch : NitroxPatch, IDynamicPatch { public static readonly MethodInfo TARGET_METHOD = Reflect.Method((PipeSurfaceFloater t) => t.FixedUpdate()); - /* - * this.deployed = true; - * PipeSurfaceFloater_FixedUpdate_Patch.StopWatching(this); <--- [INSERTED LINE] - * } - */ - public static IEnumerable Transpiler(IEnumerable instructions) + public static bool Prefix(PipeSurfaceFloater __instance) { - // Insert an instruction right before the Ret - return new CodeMatcher(instructions).End() - .InsertAndAdvance([ - new CodeInstruction(OpCodes.Ldarg_0), - new CodeInstruction(OpCodes.Call, Reflect.Method(() => StopWatching(default))) - ]).InstructionEnumeration(); - } + bool isSimulated = __instance.TryGetIdOrWarn(out NitroxId pipeFloaterId) && Resolve().HasAnyLockType(pipeFloaterId); - public static void StopWatching(PipeSurfaceFloater pipeSurfaceFloater) - { - if (pipeSurfaceFloater.TryGetIdOrWarn(out NitroxId pipeFloaterId) && Resolve().HasAnyLockType(pipeFloaterId)) + if (!isSimulated) { - Resolve().StopSimulatingEntity(pipeFloaterId); - EntityPositionBroadcaster.Instance.StopWatchingEntity(pipeFloaterId); + __instance.deployed = true; + + if (!__instance.rigidBody.isKinematic && __instance.transform.position.y >= -0.1f && + __instance.TryGetComponent(out RemotelyControlledPipeFloater remotelyControlledPipeFloater)) + { + remotelyControlledPipeFloater.SetPositioned(); + } } + + return isSimulated; } } From 1e81ce5ede13fea2382a087ff16f5fb1797a87a9 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:54:40 +0200 Subject: [PATCH 04/23] Set no interpolation on surfaced floater to prevent little vibrations and fixed tests --- Nitrox.Test/Patcher/Patches/PatchesTranspilerTest.cs | 1 - NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Nitrox.Test/Patcher/Patches/PatchesTranspilerTest.cs b/Nitrox.Test/Patcher/Patches/PatchesTranspilerTest.cs index 67d6754e2a..f4d48a3432 100644 --- a/Nitrox.Test/Patcher/Patches/PatchesTranspilerTest.cs +++ b/Nitrox.Test/Patcher/Patches/PatchesTranspilerTest.cs @@ -69,7 +69,6 @@ public class PatchesTranspilerTest [typeof(MeleeAttack_CanDealDamageTo_Patch), 4], [typeof(PDAScanner_Scan_Patch), 3], [typeof(PickPrefab_AddToContainerAsync_Patch), 4], - [typeof(PipeSurfaceFloater_FixedUpdate_Patch), 2], [typeof(Player_OnKill_Patch), 0], [typeof(PrecursorDoorMotorModeSetter_OnTriggerEnter_Patch), 3], [typeof(PrecursorMoonPoolTrigger_Update_Patch), 3], diff --git a/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs b/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs index e119b14346..314a3008cf 100644 --- a/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs +++ b/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs @@ -11,6 +11,7 @@ public void SetPositioned() { positioned = true; rigidbody.isKinematic = true; + rigidbody.interpolation = UnityEngine.RigidbodyInterpolation.None; } public new void FixedUpdate() From e430eb794c4be46baeb81d9e649c2745fa900395 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:29:30 +0200 Subject: [PATCH 05/23] Now check if entity should change position without assuming it on client-side. Changed entity position broadcast rate to 10/s --- .../Helper}/SimulationWhitelist.cs | 19 ++++++++++--- .../GameLogic/Entities/EntitySimulation.cs | 27 +++++++------------ NitroxClient/GameLogic/Items.cs | 4 ++- .../EntityPositionBroadcaster.cs | 2 +- .../MonoBehaviours/RemotelyControlled.cs | 12 +++++++++ 5 files changed, 40 insertions(+), 24 deletions(-) rename {Nitrox.Server.Subnautica/Models/GameLogic/Entities => Nitrox.Model.Subnautica/Helper}/SimulationWhitelist.cs (82%) diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/SimulationWhitelist.cs b/Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs similarity index 82% rename from Nitrox.Server.Subnautica/Models/GameLogic/Entities/SimulationWhitelist.cs rename to Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs index 93f6752068..cf92b7853b 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/SimulationWhitelist.cs +++ b/Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs @@ -1,16 +1,17 @@ using System.Collections.Generic; using Nitrox.Model.Subnautica.DataStructures.GameLogic; +using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; -namespace Nitrox.Server.Subnautica.Models.GameLogic.Entities; +namespace Nitrox.Model.Subnautica.Helper; -internal static class SimulationWhitelist +public static class SimulationWhitelist { /// /// We don't want to give out simulation to all entities that the server sent out because there is a lot of stationary items and junk (TechType.None). /// It is easier to maintain a list of items we should simulate than try to blacklist items. This list should not be checked for non-server spawned items /// as they were probably dropped by the player and are mostly guaranteed to move. /// - public static readonly HashSet MovementWhitelist = + private static readonly HashSet movementWhitelist = [ TechType.Shocker.ToDto(), TechType.Biter.ToDto(), @@ -77,8 +78,18 @@ internal static class SimulationWhitelist /// We differentiate the entities which should be simulated because of one of their behaviour (ie for utility) /// from those are simulated for their movements. /// - public static readonly HashSet UtilityWhitelist = new() + private static readonly HashSet utilityWhitelist = new() { TechType.CrashHome.ToDto() }; + + public static bool ShouldSimulateEntity(WorldEntity entity) + { + return utilityWhitelist.Contains(entity.TechType) || ShouldSimulateEntityMovement(entity); + } + + public static bool ShouldSimulateEntityMovement(WorldEntity entity) + { + return !entity.SpawnedByServer || movementWhitelist.Contains(entity.TechType); + } } diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs index 6818864ea0..46c87ce7d1 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs +++ b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs @@ -5,6 +5,7 @@ using Nitrox.Model.DataStructures; using Nitrox.Model.Subnautica.DataStructures.GameLogic; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; +using Nitrox.Model.Subnautica.Helper; using Nitrox.Server.Subnautica.Models.AppEvents; using Nitrox.Server.Subnautica.Models.Packets.Core; @@ -35,7 +36,7 @@ public IEnumerable GetSimulationChangesForCell(Player player, A { foreach (WorldEntity entity in GetPlayerSimulatedEntities(player, cell)) { - bool doesEntityMove = ShouldSimulateEntityMovement(entity); + bool doesEntityMove = SimulationWhitelist.ShouldSimulateEntityMovement(entity); yield return new SimulatedEntity(entity.Id, player.SessionId, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); } } @@ -77,7 +78,7 @@ IEnumerable GetEntitiesToRevoke(Player simulatingPlayer, AbsoluteEn private IEnumerable GetSimulatableChildren(WorldEntity entity) { - return entity.ChildEntities.OfType().Where(ShouldSimulateEntity); + return entity.ChildEntities.OfType().Where(SimulationWhitelist.ShouldSimulateEntity); } private IEnumerable GetPlayerSimulatedEntities(Player simulatingPlayer, AbsoluteEntityCell cell) @@ -88,7 +89,7 @@ private IEnumerable GetPlayerSimulatedEntities(Player simulatingPla { continue; } - if (!ShouldSimulateEntity(entity)) + if (!SimulationWhitelist.ShouldSimulateEntity(entity)) { continue; } @@ -121,7 +122,7 @@ public bool TryAssignEntityToPlayer(Entity entity, Player player, bool shouldEnt { if (simulationOwnershipData.TryToAcquire(entity.Id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE)) { - bool doesEntityMove = shouldEntityMove && entity is WorldEntity worldEntity && ShouldSimulateEntityMovement(worldEntity); + bool doesEntityMove = shouldEntityMove && entity is WorldEntity worldEntity && SimulationWhitelist.ShouldSimulateEntityMovement(worldEntity); simulatedEntity = new(entity.Id, player.SessionId, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); return true; } @@ -137,7 +138,7 @@ public SimulatedEntity AssignEntityToPlayer(Entity entity, Player player, bool s { simulationOwnershipData.RevokeOwnerOfId(entity.Id); simulationOwnershipData.TryToAcquire(entity.Id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); - bool doesEntityMove = shouldEntityMove && entity is WorldEntity worldEntity && ShouldSimulateEntityMovement(worldEntity); + bool doesEntityMove = shouldEntityMove && entity is WorldEntity worldEntity && SimulationWhitelist.ShouldSimulateEntityMovement(worldEntity); return new(entity.Id, player.SessionId, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); } @@ -151,7 +152,7 @@ public List AssignGlobalRootEntitiesAndGetData(Player player) { continue; } - bool doesEntityMove = ShouldSimulateEntityMovement(entity); + bool doesEntityMove = SimulationWhitelist.ShouldSimulateEntityMovement(entity); SimulatedEntity simulatedEntity = new(entity.Id, playerLock.Player.SessionId, doesEntityMove, playerLock.LockType); simulatedEntities.Add(simulatedEntity); } @@ -166,7 +167,7 @@ public bool TryAssignEntityToPlayers(List players, Entity entity, [NotNu { if (player.CanSee(entity) && simulationOwnershipData.TryToAcquire(id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE)) { - bool doesEntityMove = entity is WorldEntity worldEntity && ShouldSimulateEntityMovement(worldEntity); + bool doesEntityMove = entity is WorldEntity worldEntity && SimulationWhitelist.ShouldSimulateEntityMovement(worldEntity); logger.ZLogTrace($"Player {player.Name} has taken over simulating {id}"); simulatedEntity = new(id, player.SessionId, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); @@ -178,19 +179,9 @@ public bool TryAssignEntityToPlayers(List players, Entity entity, [NotNu return false; } - public bool ShouldSimulateEntity(WorldEntity entity) - { - return SimulationWhitelist.UtilityWhitelist.Contains(entity.TechType) || ShouldSimulateEntityMovement(entity); - } - - public bool ShouldSimulateEntityMovement(WorldEntity entity) - { - return !entity.SpawnedByServer || SimulationWhitelist.MovementWhitelist.Contains(entity.TechType); - } - public bool ShouldSimulateEntityMovement(NitroxId entityId) { - return entityRegistry.TryGetEntityById(entityId, out WorldEntity worldEntity) && ShouldSimulateEntityMovement(worldEntity); + return entityRegistry.TryGetEntityById(entityId, out WorldEntity worldEntity) && SimulationWhitelist.ShouldSimulateEntityMovement(worldEntity); } public void EntityDestroyed(NitroxId id) diff --git a/NitroxClient/GameLogic/Items.cs b/NitroxClient/GameLogic/Items.cs index 6812f85abb..e450e842a6 100644 --- a/NitroxClient/GameLogic/Items.cs +++ b/NitroxClient/GameLogic/Items.cs @@ -6,6 +6,7 @@ using Nitrox.Model.Subnautica.DataStructures.GameLogic; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Metadata; +using Nitrox.Model.Subnautica.Helper; using Nitrox.Model.Subnautica.Packets; using NitroxClient.Communication.Abstract; using NitroxClient.GameLogic.Helper; @@ -160,7 +161,8 @@ public void Dropped(GameObject gameObject, TechType? techType = null) droppedItem = new(gameObject.transform.ToWorldDto(), level, classId, false, id, techType.Value.ToDto(), metadata.OrNull(), null, childrenEntities); } - simulationOwnership.TakeOwnership(id, SimulationLockType.TRANSIENT, true); + bool changesPosition = SimulationWhitelist.ShouldSimulateEntityMovement(droppedItem); + simulationOwnership.TakeOwnership(id, SimulationLockType.TRANSIENT, changesPosition); if (packetSender.Send(new EntitySpawnedByClient(droppedItem, true, true))) { Log.Debug($"Dropping item: {droppedItem}"); diff --git a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs index 3a92dc225b..992b6074a3 100644 --- a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs +++ b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs @@ -12,7 +12,7 @@ public class EntityPositionBroadcaster : MonoBehaviour { public static EntityPositionBroadcaster Instance; - public static readonly float BROADCAST_INTERVAL = 0.25f; + public static readonly float BROADCAST_INTERVAL = 0.1f; /// /// Dictionary of watched entities that don't follow spline movements. diff --git a/NitroxClient/MonoBehaviours/RemotelyControlled.cs b/NitroxClient/MonoBehaviours/RemotelyControlled.cs index fa2ac56c2a..b0a858ce8c 100644 --- a/NitroxClient/MonoBehaviours/RemotelyControlled.cs +++ b/NitroxClient/MonoBehaviours/RemotelyControlled.cs @@ -79,6 +79,12 @@ public void OnDestroy() public void UpdateOrientation(Vector3 position, Quaternion rotation) { + // ensures the object has fully spawned + if (!gameObject.activeSelf) + { + return; + } + float velocity = rigidbody ? rigidbody.velocity.magnitude : 0f; bool teleported = TeleportIfTooFar(position, rotation, GetTeleportThreshold(velocity)); @@ -102,6 +108,12 @@ public void UpdateOrientation(Vector3 position, Quaternion rotation) public void UpdateKnownSplineUser(Vector3 currentPosition, Quaternion currentRotation, Vector3 destination, Vector3 destinationDirection, float velocity) { + // ensures the object has fully spawned + if (!gameObject.activeSelf) + { + return; + } + bool teleported = TeleportIfTooFar(currentPosition, currentRotation, GetTeleportThreshold(velocity)); // SwimBehaviour and WalkBehaviour will act the exact same From dc8a1d7f7225c2fa54f5a90c60ace45b99a0d9a5 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:20:53 +0200 Subject: [PATCH 06/23] Properly clean not spawned simulated entities, refactor RemotelyControlled's utility code and moved it to MovementHelper --- .../Packets/SpawnEntities.cs | 57 ++++++------- .../CellVisibilityChangedProcessor.cs | 11 +-- NitroxClient/GameLogic/Entities.cs | 1 + NitroxClient/GameLogic/MovementHelper.cs | 35 +++++++- NitroxClient/GameLogic/RemotePlayer.cs | 7 +- .../EntityPositionBroadcaster.cs | 13 +++ .../MultiplayerVehicleControl.cs | 83 ------------------- .../MonoBehaviours/RemotelyControlled.cs | 53 ++++-------- .../Dynamic/Vehicle_OnPilotModeEnd_Patch.cs | 8 +- 9 files changed, 96 insertions(+), 172 deletions(-) delete mode 100644 NitroxClient/MonoBehaviours/MultiplayerVehicleControl.cs diff --git a/Nitrox.Model.Subnautica/Packets/SpawnEntities.cs b/Nitrox.Model.Subnautica/Packets/SpawnEntities.cs index 437d3626eb..417cc78772 100644 --- a/Nitrox.Model.Subnautica/Packets/SpawnEntities.cs +++ b/Nitrox.Model.Subnautica/Packets/SpawnEntities.cs @@ -4,46 +4,37 @@ using Nitrox.Model.Packets; using Nitrox.Model.Subnautica.DataStructures.GameLogic; -namespace Nitrox.Model.Subnautica.Packets +namespace Nitrox.Model.Subnautica.Packets; + +[Serializable] +public class SpawnEntities : Packet { - [Serializable] - public class SpawnEntities : Packet - { - public List Entities { get; } - public List Simulations { get; } + public List Entities { get; } + public List Simulations { get; } - public List SpawnedCells { get; } + public List SpawnedCells { get; } - public bool ForceRespawn { get; } + public bool ForceRespawn { get; } - public SpawnEntities(List entities, List spawnedCells, bool forceRespawn = false) + public SpawnEntities(Entity entity, SimulatedEntity? simulatedEntity = null, bool forceRespawn = false) + { + Entities = [entity]; + Simulations = []; + SpawnedCells = []; + if (simulatedEntity != null) { - Entities = entities; - Simulations = []; - SpawnedCells = spawnedCells; - ForceRespawn = forceRespawn; + Simulations.Add(simulatedEntity); } - public SpawnEntities(Entity entity, SimulatedEntity? simulatedEntity = null, bool forceRespawn = false) - { - Entities = [entity]; - Simulations = []; - SpawnedCells = []; - if (simulatedEntity != null) - { - Simulations.Add(simulatedEntity); - } - - ForceRespawn = forceRespawn; - } + ForceRespawn = forceRespawn; + } - // Constructor for serialization. - public SpawnEntities(List entities, List simulations, List spawnedCells, bool forceRespawn) - { - Entities = entities; - Simulations = simulations; - SpawnedCells = spawnedCells; - ForceRespawn = forceRespawn; - } + // Constructor for serialization. + public SpawnEntities(List entities, List simulations, List spawnedCells, bool forceRespawn) + { + Entities = entities; + Simulations = simulations; + SpawnedCells = spawnedCells; + ForceRespawn = forceRespawn; } } diff --git a/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs b/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs index 508d345913..92f252cee2 100644 --- a/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs +++ b/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs @@ -35,13 +35,10 @@ public async Task Process(AuthProcessorContext context, CellVisibilityChanged pa entitySimulation.FillWithRemovedCells(context.Sender, removedCell, totalSimulationChanges); } - // Simulation update must be broadcasted before the entities are spawned - if (totalSimulationChanges.Count > 0) - { - entitySimulation.BroadcastSimulationChanges(new(totalSimulationChanges)); - } + // no need to broadcast simulation changes because a player loading part of the world can only be given transient lock + // on entities which aren't already simulated - // We send this data whether it's empty because the client needs to know about it (see Terrain) - await context.ReplyAsync(new SpawnEntities(totalEntities, packet.Added, true)); + // We send this data whether it's empty or not because the client needs to know about it (see Terrain) + await context.ReplyAsync(new SpawnEntities(totalEntities, totalSimulationChanges, packet.Added, true)); } } diff --git a/NitroxClient/GameLogic/Entities.cs b/NitroxClient/GameLogic/Entities.cs index bbd78e757d..2b886ae431 100644 --- a/NitroxClient/GameLogic/Entities.cs +++ b/NitroxClient/GameLogic/Entities.cs @@ -137,6 +137,7 @@ private IEnumerator SpawnNewEntities(bool coldStart = false) entityMetadataManager.ClearNewerMetadata(); deletedEntitiesIds.Clear(); simulationOwnership.ClearNewerSimulations(); + EntityPositionBroadcaster.Instance.ClearNotSpawnedEntities(); foreach (AbsoluteEntityCell absoluteEntityCell in CellsToSpawn) { diff --git a/NitroxClient/GameLogic/MovementHelper.cs b/NitroxClient/GameLogic/MovementHelper.cs index b2d1c994d1..cea043ef53 100644 --- a/NitroxClient/GameLogic/MovementHelper.cs +++ b/NitroxClient/GameLogic/MovementHelper.cs @@ -29,9 +29,9 @@ public static void Stop(GameObject go) iTween.Stop(go); } - public static Vector3 GetCorrectedVelocity(Vector3 remotePosition, Vector3 remoteVelocity, GameObject gameObject, float correctionTime) + public static Vector3 GetCorrectedVelocity(Vector3 remotePosition, Vector3 remoteVelocity, Vector3 currentPosition, float correctionTime) { - Vector3 difference = remotePosition - gameObject.transform.position; + Vector3 difference = remotePosition - currentPosition; if (float.IsNaN(difference.x) || float.IsNaN(difference.y) || float.IsNaN(difference.z) || correctionTime == 0f) { @@ -41,9 +41,9 @@ public static Vector3 GetCorrectedVelocity(Vector3 remotePosition, Vector3 remot return difference / correctionTime; } - public static Vector3 GetCorrectedAngularVelocity(Quaternion remoteRotation, Vector3 angularVelocty, GameObject gameObject, float correctionTime) + public static Vector3 GetCorrectedAngularVelocity(Quaternion remoteRotation, Vector3 angularVelocty, Quaternion currentRotation, float correctionTime) { - Quaternion delta = remoteRotation * gameObject.transform.rotation.GetInverse(); + Quaternion delta = remoteRotation * currentRotation.GetInverse(); delta.ToAngleAxis(out float angle, out Vector3 axis); @@ -69,5 +69,32 @@ public static Vector3 GetCorrectedAngularVelocity(Quaternion remoteRotation, Vec // than overshoot and oscillate around it in the event of errors. return (.9f * Mathf.Deg2Rad * angle / correctionTime) * axis + angularVelocty; } + + public static bool TeleportIfTooFar(Transform transform, Rigidbody rigidbody, Vector3 targetPosition, Quaternion targetRotation, float teleportThreshold) + { + if ((transform.position - targetPosition).sqrMagnitude <= teleportThreshold * teleportThreshold) + { + return false; + } + + if (rigidbody) + { + rigidbody.position = targetPosition; + rigidbody.rotation = targetRotation; + rigidbody.velocity = Vector3.zero; + rigidbody.angularVelocity = Vector3.zero; + } + else + { + transform.position = targetPosition; + transform.rotation = targetRotation; + } + return true; + } + + public static float GetTeleportThreshold(float velocity) + { + return Mathf.Max(5f, velocity * 1.5f); + } } } diff --git a/NitroxClient/GameLogic/RemotePlayer.cs b/NitroxClient/GameLogic/RemotePlayer.cs index 9f9f417aee..f07d120934 100644 --- a/NitroxClient/GameLogic/RemotePlayer.cs +++ b/NitroxClient/GameLogic/RemotePlayer.cs @@ -157,9 +157,12 @@ public void UpdatePosition(Vector3 position, Vector3 velocity, Quaternion bodyRo SetVehicle(null); SetPilotingChair(null); + float teleportThreshold = MovementHelper.GetTeleportThreshold(velocity.magnitude); + MovementHelper.TeleportIfTooFar(Body.transform, RigidBody, position, bodyRotation, teleportThreshold); + AnimationController.AimingRotation = aimingRotation; AnimationController.UpdatePlayerAnimations = true; - AnimationController.Velocity = MovementHelper.GetCorrectedVelocity(position, velocity, Body, Time.fixedDeltaTime); + AnimationController.Velocity = MovementHelper.GetCorrectedVelocity(position, velocity, Body.transform.position, Time.fixedDeltaTime); // If in a subroot the position will be relative to the subroot if (SubRoot && SubRoot.isBase) @@ -172,7 +175,7 @@ public void UpdatePosition(Vector3 position, Vector3 velocity, Quaternion bodyRo } RigidBody.velocity = AnimationController.Velocity; - RigidBody.angularVelocity = MovementHelper.GetCorrectedAngularVelocity(bodyRotation, Vector3.zero, Body, Time.fixedDeltaTime); + RigidBody.angularVelocity = MovementHelper.GetCorrectedAngularVelocity(bodyRotation, Vector3.zero, Body.transform.rotation, Time.fixedDeltaTime); } public void UpdatePositionInCyclops(Vector3 localPosition, Quaternion localRotation) diff --git a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs index 992b6074a3..9885b1f522 100644 --- a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs +++ b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs @@ -12,6 +12,9 @@ public class EntityPositionBroadcaster : MonoBehaviour { public static EntityPositionBroadcaster Instance; + /// + /// The time between two broadcasts in seconds. + /// public static readonly float BROADCAST_INTERVAL = 0.1f; /// @@ -132,6 +135,11 @@ private void SortEntity(NitroxId nitroxId, GameObject entityObject) } } + /// + /// For each tracked entity, ensures it stays in the right HashSet/Dictionary depending on its state. + /// Either the entity has not spawned () or it follows a spline () + /// or in the default case (). + /// private void CheckEntities() { // when fishes die, they're only a corpse and their swim behaviour stops functioning @@ -183,6 +191,11 @@ public void StopWatchingEntity(NitroxId id) notSpawnedEntityIds.Remove(id); } + public void ClearNotSpawnedEntities() + { + notSpawnedEntityIds.Clear(); + } + public void RegisterSplineMovementChange(NitroxId id, GameObject gameObject, Vector3 targetPos, Vector3 targetDir, float velocity) { if (splineEntities.ContainsKey(id)) diff --git a/NitroxClient/MonoBehaviours/MultiplayerVehicleControl.cs b/NitroxClient/MonoBehaviours/MultiplayerVehicleControl.cs deleted file mode 100644 index eb50337982..0000000000 --- a/NitroxClient/MonoBehaviours/MultiplayerVehicleControl.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using NitroxClient.GameLogic; -using NitroxClient.Unity.Smoothing; -using UnityEngine; - -namespace NitroxClient.MonoBehaviours -{ - public abstract class MultiplayerVehicleControl : MonoBehaviour - { - private Rigidbody rigidbody; - - protected readonly SmoothParameter SmoothYaw = new SmoothParameter(); - protected readonly SmoothParameter SmoothPitch = new SmoothParameter(); - protected readonly SmoothVector SmoothLeftArm = new SmoothVector(); - protected readonly SmoothVector SmoothRightArm = new SmoothVector(); - protected SmoothVector SmoothPosition; - protected SmoothVector SmoothVelocity; - protected SmoothRotation SmoothRotation; - protected SmoothVector SmoothAngularVelocity; - protected Action WheelYawSetter; - protected Action WheelPitchSetter; - - protected virtual void Awake() - { - rigidbody = gameObject.GetComponent(); - // For now, we assume the set position and rotation is equal to the server one. - // Default velocities are probably empty, but set them anyway. - SmoothPosition = new SmoothVector(gameObject.transform.position); - SmoothVelocity = new SmoothVector(rigidbody.velocity); - SmoothRotation = new SmoothRotation(gameObject.transform.rotation); - SmoothAngularVelocity = new SmoothVector(rigidbody.angularVelocity); - } - - protected virtual void FixedUpdate() - { - SmoothYaw.FixedUpdate(); - SmoothPitch.FixedUpdate(); - - SmoothPosition.FixedUpdate(); - SmoothVelocity.FixedUpdate(); - rigidbody.velocity = MovementHelper.GetCorrectedVelocity(SmoothPosition.Current, SmoothVelocity.Current, gameObject, Time.fixedDeltaTime); - SmoothRotation.FixedUpdate(); - SmoothAngularVelocity.FixedUpdate(); - rigidbody.angularVelocity = MovementHelper.GetCorrectedAngularVelocity(SmoothRotation.Current, SmoothAngularVelocity.Current, gameObject, Time.fixedDeltaTime); - - WheelYawSetter(SmoothYaw.SmoothValue); - WheelPitchSetter(SmoothPitch.SmoothValue); - } - - internal void SetPositionVelocityRotation(Vector3 remotePosition, Vector3 remoteVelocity, Quaternion remoteRotation, Vector3 remoteAngularVelocity) - { - gameObject.SetActive(true); - SmoothPosition.Target = remotePosition; - SmoothVelocity.Target = remoteVelocity; - SmoothRotation.Target = remoteRotation; - SmoothAngularVelocity.Target = remoteAngularVelocity; - } - - internal virtual void SetSteeringWheel(float yaw, float pitch) - { - SmoothYaw.Target = yaw; - SmoothPitch.Target = pitch; - } - - internal virtual void SetArmPositions(Vector3 leftArmPosition, Vector3 rightArmPosition) - { - SmoothLeftArm.Target = leftArmPosition; - SmoothRightArm.Target = rightArmPosition; - } - - internal virtual void Enter() - { - enabled = true; - } - - public virtual void Exit() - { - enabled = false; - } - - internal abstract void SetThrottle(bool isOn); - } -} diff --git a/NitroxClient/MonoBehaviours/RemotelyControlled.cs b/NitroxClient/MonoBehaviours/RemotelyControlled.cs index b0a858ce8c..4db2e3cf5a 100644 --- a/NitroxClient/MonoBehaviours/RemotelyControlled.cs +++ b/NitroxClient/MonoBehaviours/RemotelyControlled.cs @@ -31,6 +31,12 @@ public void Awake() } } + // - non spline entities generally are dropped items/utility (like a flare or a pipe surface floater) + // we don't want the environment to interfere with their movement since the simulating player is already broadcasting movement + // that was affected by those interactions + // - spline entities (Creatures) follow movements based on the local game's calculations so they need to be affected by gravity + // to not stay blocked in the air (for leviathans and other fishes jumping out of the water) + // NB: WorldForces is responsible for applying gravity, currents, explosions if (worldForces && !followsSpline) { disabledWorldForces = worldForces.enabled; @@ -57,8 +63,8 @@ public void FixedUpdate() { rigidbody.isKinematic = false; } - rigidbody.velocity = MovementHelper.GetCorrectedVelocity(smoothPosition.Current, Vector3.zero, gameObject, EntityPositionBroadcaster.BROADCAST_INTERVAL); - rigidbody.angularVelocity = MovementHelper.GetCorrectedAngularVelocity(smoothRotation.Current, Vector3.zero, gameObject, EntityPositionBroadcaster.BROADCAST_INTERVAL); + rigidbody.velocity = MovementHelper.GetCorrectedVelocity(smoothPosition.Current, Vector3.zero, transform.position, EntityPositionBroadcaster.BROADCAST_INTERVAL); + rigidbody.angularVelocity = MovementHelper.GetCorrectedAngularVelocity(smoothRotation.Current, Vector3.zero, transform.rotation, EntityPositionBroadcaster.BROADCAST_INTERVAL); } else { @@ -86,7 +92,8 @@ public void UpdateOrientation(Vector3 position, Quaternion rotation) } float velocity = rigidbody ? rigidbody.velocity.magnitude : 0f; - bool teleported = TeleportIfTooFar(position, rotation, GetTeleportThreshold(velocity)); + float teleportThreshold = MovementHelper.GetTeleportThreshold(velocity); + bool teleported = MovementHelper.TeleportIfTooFar(transform, rigidbody, position, rotation, teleportThreshold); if (swimBehaviour && swimBehaviour.enabled) { @@ -106,7 +113,7 @@ public void UpdateOrientation(Vector3 position, Quaternion rotation) smoothRotation.Target = rotation; } - public void UpdateKnownSplineUser(Vector3 currentPosition, Quaternion currentRotation, Vector3 destination, Vector3 destinationDirection, float velocity) + public void UpdateKnownSplineUser(Vector3 remotePosition, Quaternion remoteRotation, Vector3 destinationPosition, Vector3 destinationDirection, float velocity) { // ensures the object has fully spawned if (!gameObject.activeSelf) @@ -114,7 +121,8 @@ public void UpdateKnownSplineUser(Vector3 currentPosition, Quaternion currentRot return; } - bool teleported = TeleportIfTooFar(currentPosition, currentRotation, GetTeleportThreshold(velocity)); + float teleportThreshold = MovementHelper.GetTeleportThreshold(velocity); + bool teleported = MovementHelper.TeleportIfTooFar(transform, rigidbody, remotePosition, remoteRotation, teleportThreshold); // SwimBehaviour and WalkBehaviour will act the exact same if (swimBehaviour && swimBehaviour.enabled) @@ -123,12 +131,12 @@ public void UpdateKnownSplineUser(Vector3 currentPosition, Quaternion currentRot if (!teleported) { - float distance = Vector3.Distance(currentPosition, destination); + float distance = Vector3.Distance(remotePosition, destinationPosition); // avoid too short paths if (distance > 0.1f) { - float localDistance = Vector3.Distance(transform.position, destination); + float localDistance = Vector3.Distance(transform.position, destinationPosition); adjustedVelocity *= localDistance / distance; @@ -137,42 +145,15 @@ public void UpdateKnownSplineUser(Vector3 currentPosition, Quaternion currentRot } // Adjust the target data and velocity - swimBehaviour.originalTargetPosition = destination; + swimBehaviour.originalTargetPosition = destinationPosition; swimBehaviour.originalTargetDirection = destinationDirection; swimBehaviour.originalVelocity = adjustedVelocity; // Trigger either SwimBehaviour.GoToInternal or WalkBehaviour.GoToInternal so they use their own way to pass the data to the SplineFollowing - swimBehaviour.GoToInternal(destination, destinationDirection, adjustedVelocity); + swimBehaviour.GoToInternal(destinationPosition, destinationDirection, adjustedVelocity); } } - private bool TeleportIfTooFar(Vector3 position, Quaternion rotation, float teleportThreshold) - { - if ((transform.position - position).sqrMagnitude <= teleportThreshold * teleportThreshold) - { - return false; - } - - if (rigidbody) - { - rigidbody.position = position; - rigidbody.rotation = rotation; - rigidbody.velocity = Vector3.zero; - rigidbody.angularVelocity = Vector3.zero; - } - else - { - transform.position = position; - transform.rotation = rotation; - } - return true; - } - - private static float GetTeleportThreshold(float velocity) - { - return Mathf.Max(5f, velocity * 1.5f); - } - public static RemotelyControlled Ensure(GameObject gameObject) { if (gameObject.TryGetComponent(out RemotelyControlled remotelyControlled)) diff --git a/NitroxPatcher/Patches/Dynamic/Vehicle_OnPilotModeEnd_Patch.cs b/NitroxPatcher/Patches/Dynamic/Vehicle_OnPilotModeEnd_Patch.cs index c14ab89fbb..4fe97e09fc 100644 --- a/NitroxPatcher/Patches/Dynamic/Vehicle_OnPilotModeEnd_Patch.cs +++ b/NitroxPatcher/Patches/Dynamic/Vehicle_OnPilotModeEnd_Patch.cs @@ -1,7 +1,6 @@ using System.Reflection; -using NitroxClient.GameLogic; -using NitroxClient.MonoBehaviours; using Nitrox.Model.DataStructures; +using NitroxClient.GameLogic; namespace NitroxPatcher.Patches.Dynamic; @@ -12,11 +11,6 @@ public sealed partial class Vehicle_OnPilotModeEnd_Patch : NitroxPatch, IDynamic public static void Prefix(Vehicle __instance) { Resolve().BroadcastOnPilotModeChanged(__instance.gameObject, false); - // Fixes instances of vehicles stuck on nothing by forcing the workaround (let another player enter and leave the vehicle) - if (__instance.TryGetComponent(out MultiplayerVehicleControl mvc)) - { - mvc.Exit(); - } if (__instance.TryGetIdOrWarn(out NitroxId id)) { From e4f66e381d458e27ee12c633eb5d7828fb9a0142 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:51:12 +0200 Subject: [PATCH 07/23] Add a test class for DictionaryExtensions.RemoveWhere, and a comment --- .../Extensions/DictionaryExtensionsTest.cs | 55 +++++++++++++++++++ .../RemotelyControlledPipeFloater.cs | 2 + 2 files changed, 57 insertions(+) create mode 100644 Nitrox.Test/Model/Extensions/DictionaryExtensionsTest.cs diff --git a/Nitrox.Test/Model/Extensions/DictionaryExtensionsTest.cs b/Nitrox.Test/Model/Extensions/DictionaryExtensionsTest.cs new file mode 100644 index 0000000000..daaca4b3a0 --- /dev/null +++ b/Nitrox.Test/Model/Extensions/DictionaryExtensionsTest.cs @@ -0,0 +1,55 @@ +namespace Nitrox.Model.Extensions; + +[TestClass] +public class DictionaryExtensionsTest +{ + [TestMethod] + public void RemoveWhere_WithExtraParameter_ShouldRemoveMatchingItems() + { + Dictionary dict = new() + { + { "a", 1 }, + { "b", 2 }, + { "c", 3 }, + { "d", 4 } + }; + dict.RemoveWhere(2, (value, parameter) => value % parameter == 0); + dict.Should().HaveCount(2); + dict.Should().ContainKey("a").WhoseValue.Should().Be(1); + dict.Should().ContainKey("c").WhoseValue.Should().Be(3); + } + + [TestMethod] + public void RemoveWhere_WithoutParameter_ShouldRemoveMatchingItems() + { + Dictionary dict = new() + { + { "apple", 5 }, + { "banana", 6 }, + { "cherry", 6 } + }; + dict.RemoveWhere(pair => pair.Key.StartsWith("b") || pair.Value == 5); + dict.Should().ContainSingle(); + dict.Should().ContainKey("cherry").WhoseValue.Should().Be(6); + } + + [TestMethod] + public void RemoveWhere_ShouldHandleEmptyDictionary() + { + Dictionary dict = []; + dict.RemoveWhere(pair => true); + dict.Should().BeEmpty(); + } + + [TestMethod] + public void RemoveWhere_ShouldHandleRemovingAllItems() + { + Dictionary dict = new() + { + { "a", 1 }, + { "b", 2 } + }; + dict.RemoveWhere(pair => true); + dict.Should().BeEmpty(); + } +} diff --git a/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs b/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs index 314a3008cf..c1aabea7d7 100644 --- a/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs +++ b/NitroxClient/MonoBehaviours/RemotelyControlledPipeFloater.cs @@ -10,7 +10,9 @@ public class RemotelyControlledPipeFloater : RemotelyControlled public void SetPositioned() { positioned = true; + // We set the floater to kinematic so it's unaffected by the local world rigidbody.isKinematic = true; + // interpolation to none prevents some little jittering rigidbody.interpolation = UnityEngine.RigidbodyInterpolation.None; } From 94e6e46d9fbb990f284c8da5eaad51e034fc7a84 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:54:08 +0200 Subject: [PATCH 08/23] Reworked WorldEntityManager to only serve top entities (unparented), and to be more optimized --- .../Commands/Debugging/PlayerCommand.cs | 3 +- .../GameLogic/Entities/WorldEntityManager.cs | 57 +++++++++++++++++-- .../Serialization/World/WorldService.cs | 2 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/Nitrox.Server.Subnautica/Models/Commands/Debugging/PlayerCommand.cs b/Nitrox.Server.Subnautica/Models/Commands/Debugging/PlayerCommand.cs index 805c3f794b..fbce3fc09c 100644 --- a/Nitrox.Server.Subnautica/Models/Commands/Debugging/PlayerCommand.cs +++ b/Nitrox.Server.Subnautica/Models/Commands/Debugging/PlayerCommand.cs @@ -1,7 +1,6 @@ #if DEBUG using System.Collections.Generic; using System.ComponentModel; -using Nitrox.Model.Core; using Nitrox.Model.DataStructures.GameLogic; using Nitrox.Model.DataStructures.Unity; using Nitrox.Model.Subnautica.DataStructures.GameLogic; @@ -36,7 +35,7 @@ public Task Execute(ICommandContext context, [Description("name of the target pl foreach (AbsoluteEntityCell visibleCell in visibleCells) { string simulatedEntities = ""; - foreach (WorldEntity worldEntity in entityManager.GetEntities(visibleCell)) + foreach (WorldEntity worldEntity in entityManager.EnumerateCellEntities(visibleCell)) { if (simulationOwnership.TryGetLock(worldEntity.Id, out SimulationOwnershipData.PlayerLock playerLock) && playerLock.Player.Id == player.Id) diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/WorldEntityManager.cs b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/WorldEntityManager.cs index 7693c2e09f..e3dbc9d71f 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/WorldEntityManager.cs +++ b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/WorldEntityManager.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -84,17 +85,49 @@ public List GetPersistentGlobalRootEntities() }).ToList(); } - public List GetEntities(AbsoluteEntityCell cell) + public IEnumerable EnumerateCellEntities(AbsoluteEntityCell cell) { + WorldEntity[] array = null; + int count = 0; + lock (worldEntitiesLock) { - if (worldEntitiesByCell.TryGetValue(cell, out Dictionary batchEntities)) + if (worldEntitiesByCell.TryGetValue(cell, out Dictionary batchEntites)) { - return batchEntities.Values.ToList(); + count = batchEntites.Count; + array = ArrayPool.Shared.Rent(count); + batchEntites.Values.CopyTo(array, 0); } } - return []; + if (array == null) + { + yield break; + } + + // never yield while having the lock or it will never be released before the end of this method + try + { + for (int i = 0; i < count; i++) + { + yield return array[i]; + } + } + finally + { + ArrayPool.Shared.Return(array, true); + } + } + + public void FillEntitiesNonAlloc(AbsoluteEntityCell cell, List targetList) + { + lock (worldEntitiesLock) + { + if (worldEntitiesByCell.TryGetValue(cell, out Dictionary batchEntities)) + { + targetList.AddRange(batchEntities.Values); + } + } } public bool TryUpdateEntityPosition(NitroxId id, NitroxVector3 position, NitroxQuaternion rotation, out AbsoluteEntityCell? newCell, out WorldEntity worldEntity) @@ -189,15 +222,23 @@ public void RegisterWorldEntity(WorldEntity entity) RegisterWorldEntityInCell(entity, entity.AbsoluteEntityCell); } - public void RegisterWorldEntityInCell(WorldEntity entity, AbsoluteEntityCell cell) + public bool RegisterWorldEntityInCell(WorldEntity entity, AbsoluteEntityCell cell) { lock (worldEntitiesLock) { + if (entity.ParentId != null) + { + // entities parented to a WorldEntity most likely have their LargeWorldEntity component disabled, which means they + // will only disappear once their parent disappears, thus we do not need to hold them in a cell + return false; + } + if (!worldEntitiesByCell.TryGetValue(cell, out Dictionary worldEntitiesInCell)) { worldEntitiesInCell = worldEntitiesByCell[cell] = []; } worldEntitiesInCell[entity.Id] = entity; + return true; } } @@ -351,7 +392,11 @@ private void EntitySwitchedCells(WorldEntity entity, AbsoluteEntityCell oldCell, UnregisterWorldEntityFromCell(entity.Id, oldCell); // Automatically add entity to its new cell - RegisterWorldEntityInCell(entity, newCell); + if (!RegisterWorldEntityInCell(entity, newCell)) + { + // in case it wasn't moved to a new cell (because it depends on a parent for example) we don't need to broadcast it + return; + } // It can happen for some players that the entity moves to a loaded cell of theirs, but that they hadn't spawned it in the first place foreach (Player player in playerManager.ConnectedPlayers()) diff --git a/Nitrox.Server.Subnautica/Models/Serialization/World/WorldService.cs b/Nitrox.Server.Subnautica/Models/Serialization/World/WorldService.cs index f2693e4800..76b2111f34 100644 --- a/Nitrox.Server.Subnautica/Models/Serialization/World/WorldService.cs +++ b/Nitrox.Server.Subnautica/Models/Serialization/World/WorldService.cs @@ -329,7 +329,7 @@ private async Task LoadPersistedWorldIntoServicesAsync(PersistedWorldData pWorld // TODO: hacky code - see WorldEntityManager for more information. List worldEntities = entityRegistry.GetEntities(); worldEntityManager.globalRootEntitiesById = entityRegistry.GetEntities().ToDictionary(entity => entity.Id); - worldEntityManager.worldEntitiesByCell = worldEntities.Where(entity => entity is not GlobalRootEntity) + worldEntityManager.worldEntitiesByCell = worldEntities.Where(entity => entity is not GlobalRootEntity && entity.ParentId == null) .GroupBy(entity => entity.AbsoluteEntityCell) .ToDictionary(group => group.Key, group => group.ToDictionary(entity => entity.Id, entity => entity)); From 6eea5e99dfe00511f3c7ae9213691bf7f9f023ae Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:56:42 +0200 Subject: [PATCH 09/23] Reworked and upgraded server-side simulation ownership to be more performant --- .../GameLogic/Entities/EntitySimulation.cs | 96 +++++++------------ .../Models/GameLogic/SimulationOwnership.cs | 10 +- .../CellVisibilityChangedProcessor.cs | 32 ++++--- .../SimulationOwnershipRequestProcessor.cs | 2 +- .../Processors/SpawnEntitiesProcessor.cs | 16 +++- 5 files changed, 74 insertions(+), 82 deletions(-) diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs index 46c87ce7d1..6bff1441a8 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs +++ b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using Nitrox.Model.Core; using Nitrox.Model.DataStructures; using Nitrox.Model.Subnautica.DataStructures.GameLogic; @@ -32,83 +31,48 @@ public EntitySimulation(IPacketSender packetSender, EntityRegistry entityRegistr this.logger = logger; } - public IEnumerable GetSimulationChangesForCell(Player player, AbsoluteEntityCell cell) + /// The successfully acquired entities from the provided . + public IEnumerable TryAcquireCellEntities(Player player, AbsoluteEntityCell cell) { - foreach (WorldEntity entity in GetPlayerSimulatedEntities(player, cell)) + foreach (WorldEntity worldEntity in worldEntityManager.EnumerateCellEntities(cell)) { - bool doesEntityMove = SimulationWhitelist.ShouldSimulateEntityMovement(entity); - yield return new SimulatedEntity(entity.Id, player.SessionId, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); - } - } - - public void FillWithRemovedCells(Player player, AbsoluteEntityCell removedCell, List ownershipChanges) - { - AssignEntitiesToOtherPlayers(player.SessionId, GetEntitiesToRevoke(player, removedCell), ownershipChanges); - return; - - IEnumerable GetEntitiesOfCell(AbsoluteEntityCell cell) - { - foreach (WorldEntity entity in worldEntityManager.GetEntities(cell)) + if (!player.CanSee(worldEntity) || !SimulationWhitelist.ShouldSimulateEntity(worldEntity)) { - yield return entity; - foreach (WorldEntity child in GetSimulatableChildren(entity)) - { - yield return child; - } + continue; } - } - IEnumerable GetEntitiesToRevoke(Player simulatingPlayer, AbsoluteEntityCell cell) - { - foreach (WorldEntity entity in GetEntitiesOfCell(cell)) + if (simulationOwnershipData.TryAcquire(worldEntity.Id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE)) { - if (player.CanSee(entity)) - { - continue; - } - if (!simulationOwnershipData.RevokeIfOwner(entity.Id, simulatingPlayer)) - { - continue; - } - - yield return entity; + bool doesEntityMove = SimulationWhitelist.ShouldSimulateEntityMovement(worldEntity); + yield return new SimulatedEntity(worldEntity.Id, player.SessionId, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); } } } - private IEnumerable GetSimulatableChildren(WorldEntity entity) - { - return entity.ChildEntities.OfType().Where(SimulationWhitelist.ShouldSimulateEntity); - } - - private IEnumerable GetPlayerSimulatedEntities(Player simulatingPlayer, AbsoluteEntityCell cell) + /// The successfully revoked simulated entities from the provided . + public IEnumerable RevokeSimulatedCellEntities(Player simulatingPlayer, AbsoluteEntityCell cell) { - foreach (WorldEntity entity in worldEntityManager.GetEntities(cell)) + foreach (WorldEntity entity in worldEntityManager.EnumerateCellEntities(cell)) { - if (!simulatingPlayer.CanSee(entity)) + if (simulatingPlayer.CanSee(entity)) { continue; } - if (!SimulationWhitelist.ShouldSimulateEntity(entity)) + if (simulationOwnershipData.RevokeIfOwner(entity.Id, simulatingPlayer)) { - continue; - } - if (!simulationOwnershipData.TryToAcquire(entity.Id, simulatingPlayer, DEFAULT_ENTITY_SIMULATION_LOCKTYPE)) - { - continue; - } - - yield return entity; - foreach (WorldEntity child in GetSimulatableChildren(entity)) - { - if (simulationOwnershipData.TryToAcquire(child.Id, simulatingPlayer, DEFAULT_ENTITY_SIMULATION_LOCKTYPE)) - { - yield return child; - } + yield return entity; } } } + /// + /// Fills with ownership changes from revoking 's simulated entities in . + /// + public void RevokeAndReassignCellEntities(Player player, AbsoluteEntityCell removedCell, List ownershipChanges) + { + AssignEntitiesToOtherPlayers(player.SessionId, RevokeSimulatedCellEntities(player, removedCell), ownershipChanges); + } + public void BroadcastSimulationChanges(List ownershipChanges) { if (ownershipChanges.Count > 0) @@ -120,7 +84,7 @@ public void BroadcastSimulationChanges(List ownershipChanges) public bool TryAssignEntityToPlayer(Entity entity, Player player, bool shouldEntityMove, [NotNullWhen(true)] out SimulatedEntity? simulatedEntity) { - if (simulationOwnershipData.TryToAcquire(entity.Id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE)) + if (simulationOwnershipData.TryAcquire(entity.Id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE)) { bool doesEntityMove = shouldEntityMove && entity is WorldEntity worldEntity && SimulationWhitelist.ShouldSimulateEntityMovement(worldEntity); simulatedEntity = new(entity.Id, player.SessionId, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); @@ -136,8 +100,7 @@ public bool TryAssignEntityToPlayer(Entity entity, Player player, bool shouldEnt /// public SimulatedEntity AssignEntityToPlayer(Entity entity, Player player, bool shouldEntityMove) { - simulationOwnershipData.RevokeOwnerOfId(entity.Id); - simulationOwnershipData.TryToAcquire(entity.Id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); + simulationOwnershipData.ForceAcquire(entity.Id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); bool doesEntityMove = shouldEntityMove && entity is WorldEntity worldEntity && SimulationWhitelist.ShouldSimulateEntityMovement(worldEntity); return new(entity.Id, player.SessionId, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE); } @@ -147,7 +110,7 @@ public List AssignGlobalRootEntitiesAndGetData(Player player) List simulatedEntities = new(); foreach (GlobalRootEntity entity in worldEntityManager.GetGlobalRootEntities()) { - simulationOwnershipData.TryToAcquire(entity.Id, player, SimulationLockType.TRANSIENT); + simulationOwnershipData.TryAcquire(entity.Id, player, SimulationLockType.TRANSIENT); if (!simulationOwnershipData.TryGetLock(entity.Id, out SimulationOwnershipData.PlayerLock playerLock)) { continue; @@ -165,7 +128,7 @@ public bool TryAssignEntityToPlayers(List players, Entity entity, [NotNu foreach (Player player in players) { - if (player.CanSee(entity) && simulationOwnershipData.TryToAcquire(id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE)) + if (player.CanSee(entity) && simulationOwnershipData.TryAcquire(id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE)) { bool doesEntityMove = entity is WorldEntity worldEntity && SimulationWhitelist.ShouldSimulateEntityMovement(worldEntity); @@ -211,8 +174,9 @@ private List CalculateSimulationChangesFromPlayerDisconnect(Ses return ownershipChanges; } - private void AssignEntitiesToOtherPlayers(SessionId oldSessionId, IEnumerable entities, List ownershipChanges) + public void AssignEntitiesToOtherPlayers(SessionId oldSessionId, IEnumerable entities, List ownershipChanges) { + // TODO: (optional) Find out if ordering the otherPlayers by distance to the previous simulator improves performance (ascending) List otherPlayers = playerManager.GetConnectedPlayersExcept(oldSessionId); foreach (Entity entity in entities) { @@ -220,6 +184,10 @@ private void AssignEntitiesToOtherPlayers(SessionId oldSessionId, IEnumerable playerLocksById = new Dictionary(); - public bool TryToAcquire(NitroxId id, Player player, SimulationLockType requestedLock) + public void ForceAcquire(NitroxId entityId, Player player, SimulationLockType requestedLock) + { + lock (playerLocksById) + { + playerLocksById[entityId] = new(player, requestedLock); + } + } + + public bool TryAcquire(NitroxId id, Player player, SimulationLockType requestedLock) { lock (playerLocksById) { diff --git a/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs b/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs index 92f252cee2..843b98bd97 100644 --- a/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs +++ b/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using Nitrox.Model.DataStructures; using Nitrox.Model.Subnautica.DataStructures.GameLogic; -using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; using Nitrox.Server.Subnautica.Models.GameLogic.Entities; using Nitrox.Server.Subnautica.Models.Packets.Core; @@ -17,28 +16,35 @@ public async Task Process(AuthProcessorContext context, CellVisibilityChanged pa context.Sender.AddCells(packet.Added); context.Sender.RemoveCells(packet.Removed); - List totalEntities = []; - List totalSimulationChanges = []; + List totalEntities = new(32); + List simulationChanges = new(32); - foreach (AbsoluteEntityCell addedCell in packet.Added) + foreach (AbsoluteEntityCell removedCell in packet.Removed) { - await worldEntityManager.LoadUnspawnedEntitiesAsync(addedCell.BatchId, false); - - totalSimulationChanges.AddRange(entitySimulation.GetSimulationChangesForCell(context.Sender, addedCell)); - List newEntities = worldEntityManager.GetEntities(addedCell); + entitySimulation.RevokeAndReassignCellEntities(context.Sender, removedCell, simulationChanges); + } - totalEntities.AddRange(newEntities); + if (simulationChanges.Count > 0) + { + // so far we're only sending no longer visible entities reattribution, so no need to send it to the packet sender + // NB: we can only reuse the same List totalSimulationChanges because the packet is immediately serialized with its data + await context.SendToAllAsync(new SimulationOwnershipChange(simulationChanges)); } - foreach (AbsoluteEntityCell removedCell in packet.Removed) + // The following section is only for the sender + foreach (AbsoluteEntityCell addedCell in packet.Added) { - entitySimulation.FillWithRemovedCells(context.Sender, removedCell, totalSimulationChanges); + await worldEntityManager.LoadUnspawnedEntitiesAsync(addedCell.BatchId, false); + + simulationChanges.AddRange(entitySimulation.TryAcquireCellEntities(context.Sender, addedCell)); + + worldEntityManager.FillEntitiesNonAlloc(addedCell, totalEntities); } - // no need to broadcast simulation changes because a player loading part of the world can only be given transient lock + // no need to broadcast other simulation changes because a player loading part of the world can only be given transient lock // on entities which aren't already simulated // We send this data whether it's empty or not because the client needs to know about it (see Terrain) - await context.ReplyAsync(new SpawnEntities(totalEntities, totalSimulationChanges, packet.Added, true)); + await context.ReplyAsync(new SpawnEntities(totalEntities, simulationChanges, packet.Added, true)); } } diff --git a/Nitrox.Server.Subnautica/Models/Packets/Processors/SimulationOwnershipRequestProcessor.cs b/Nitrox.Server.Subnautica/Models/Packets/Processors/SimulationOwnershipRequestProcessor.cs index cd78614980..22ba95c8a9 100644 --- a/Nitrox.Server.Subnautica/Models/Packets/Processors/SimulationOwnershipRequestProcessor.cs +++ b/Nitrox.Server.Subnautica/Models/Packets/Processors/SimulationOwnershipRequestProcessor.cs @@ -11,7 +11,7 @@ internal sealed class SimulationOwnershipRequestProcessor(SimulationOwnershipDat public async Task Process(AuthProcessorContext context, SimulationOwnershipRequest ownershipRequest) { - bool aquiredLock = simulationOwnershipData.TryToAcquire(ownershipRequest.Id, context.Sender, ownershipRequest.LockType); + bool aquiredLock = simulationOwnershipData.TryAcquire(ownershipRequest.Id, context.Sender, ownershipRequest.LockType); if (aquiredLock) { diff --git a/NitroxClient/Communication/Packets/Processors/SpawnEntitiesProcessor.cs b/NitroxClient/Communication/Packets/Processors/SpawnEntitiesProcessor.cs index 9c99e3e091..97c4e88566 100644 --- a/NitroxClient/Communication/Packets/Processors/SpawnEntitiesProcessor.cs +++ b/NitroxClient/Communication/Packets/Processors/SpawnEntitiesProcessor.cs @@ -6,11 +6,12 @@ namespace NitroxClient.Communication.Packets.Processors; -internal sealed class SpawnEntitiesProcessor(Entities entities, SimulationOwnership simulationOwnership, Terrain terrain) : IClientPacketProcessor +internal sealed class SpawnEntitiesProcessor(Entities entities, SimulationOwnership simulationOwnership, Terrain terrain, LocalPlayer localPlayer) : IClientPacketProcessor { private readonly Entities entities = entities; private readonly SimulationOwnership simulationOwnership = simulationOwnership; private readonly Terrain terrain = terrain; + private readonly LocalPlayer localPlayer = localPlayer; public Task Process(ClientProcessorContext context, SpawnEntities packet) { @@ -19,13 +20,22 @@ public Task Process(ClientProcessorContext context, SpawnEntities packet) entities.CleanupExistingEntities(packet.Entities); } - if (packet.Entities.Count > 0) + foreach (SimulatedEntity simulatedEntity in packet.SimulatedEntities) { - foreach (SimulatedEntity simulatedEntity in packet.Simulations) + // If we're simulating the entity, we can take note of that once it has spawned + if (simulatedEntity.SessionId == localPlayer.SessionId) { simulationOwnership.RegisterNewerSimulation(simulatedEntity.Id, simulatedEntity); } + // if we're losing ownership, we can apply the change now + else + { + simulationOwnership.TreatSimulatedEntity(simulatedEntity); + } + } + if (packet.Entities.Count > 0) + { // Packet processing is done in the main thread so there's no issue calling this // We need a cold start so that all cleaned up entities (if force respawn is true) have time to be fully destroyed entities.EnqueueEntitiesToSpawn(packet.Entities, packet.SpawnedCells, packet.ForceRespawn); From 9d5ce487da2ebe3e4591e7c39ec02d0c1b98fafc Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:14:49 +0200 Subject: [PATCH 10/23] Feat: Preload all prefabs when starting the game --- .../GlobalRootInitialSyncProcessor.cs | 7 +- .../InitialSync/PlayerInitialSyncProcessor.cs | 7 +- .../PrefabLoadInitialSyncProcessor.cs | 73 +++++++++++++++++++ .../Persistent/uGUI_MainMenu_Start_Patch.cs | 14 +++- 4 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs diff --git a/NitroxClient/GameLogic/InitialSync/GlobalRootInitialSyncProcessor.cs b/NitroxClient/GameLogic/InitialSync/GlobalRootInitialSyncProcessor.cs index 351074f346..21058e8836 100644 --- a/NitroxClient/GameLogic/InitialSync/GlobalRootInitialSyncProcessor.cs +++ b/NitroxClient/GameLogic/InitialSync/GlobalRootInitialSyncProcessor.cs @@ -1,10 +1,10 @@ using System.Collections; -using NitroxClient.GameLogic.Bases; -using NitroxClient.GameLogic.InitialSync.Abstract; -using NitroxClient.MonoBehaviours.Cyclops; using Nitrox.Model.GameLogic.PlayerAnimation; using Nitrox.Model.Subnautica.MultiplayerSession; using Nitrox.Model.Subnautica.Packets; +using NitroxClient.GameLogic.Bases; +using NitroxClient.GameLogic.InitialSync.Abstract; +using NitroxClient.MonoBehaviours.Cyclops; using UnityEngine; namespace NitroxClient.GameLogic.InitialSync; @@ -34,6 +34,7 @@ public GlobalRootInitialSyncProcessor(Entities entities, Vehicles vehicles, Play // As we migrate systems over to entities, we want to ensure the required components are in place to spawn these entities. // For example, migrating inventories to the entity system requires players are spawned in the world before we try to add // inventory items to them. Eventually, all of the below processors will become entities on their own + AddDependency(); AddDependency(); AddDependency(); AddDependency(); diff --git a/NitroxClient/GameLogic/InitialSync/PlayerInitialSyncProcessor.cs b/NitroxClient/GameLogic/InitialSync/PlayerInitialSyncProcessor.cs index 1dd7538e6c..b549af9c3b 100644 --- a/NitroxClient/GameLogic/InitialSync/PlayerInitialSyncProcessor.cs +++ b/NitroxClient/GameLogic/InitialSync/PlayerInitialSyncProcessor.cs @@ -1,11 +1,11 @@ using System.Collections; using System.Collections.Generic; using System.Text; -using NitroxClient.GameLogic.InitialSync.Abstract; -using NitroxClient.MonoBehaviours; using Nitrox.Model.DataStructures; using Nitrox.Model.DataStructures.GameLogic; using Nitrox.Model.Subnautica.DataStructures.GameLogic; +using NitroxClient.GameLogic.InitialSync.Abstract; +using NitroxClient.MonoBehaviours; using UnityEngine; namespace NitroxClient.GameLogic.InitialSync; @@ -29,6 +29,9 @@ public PlayerInitialSyncProcessor(Items item, ItemContainers itemContainers, Loc this.itemContainers = itemContainers; this.localPlayer = localPlayer; + AddDependency(); + AddDependency(); + AddStep(sync => SetupEscapePod(sync.FirstTimeConnecting)); AddStep(sync => SetPlayerPermissions(sync.Permissions)); AddStep(sync => SetPlayerIntroCinematicMode(sync.IntroCinematicMode)); diff --git a/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs b/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs new file mode 100644 index 0000000000..d6044909f7 --- /dev/null +++ b/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs @@ -0,0 +1,73 @@ +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using Nitrox.Model.Subnautica.Packets; +using NitroxClient.GameLogic.InitialSync.Abstract; +using NitroxClient.GameLogic.Spawning.WorldEntities; +using UnityEngine; +using UWE; + +namespace NitroxClient.GameLogic.InitialSync; + +public sealed class PrefabLoadInitialSyncProcessor : InitialSyncProcessor +{ + private static bool loadStarted; + private static readonly Queue<(string, TechType)> loadQueue = new(PrefabDatabase.prefabFiles.Keys.Count); + private static readonly Stopwatch stopwatch = new(); + private static int workersRunning = 32; + + public PrefabLoadInitialSyncProcessor() + { + AddDependency(); + + AddStep(WaitForAllPrefabLoaded); + } + + public static IEnumerator WaitForAllPrefabLoaded(InitialPlayerSync packet) + { + yield return new WaitUntil(() => workersRunning == 0); + } + + public static IEnumerator StartAllPrefabLoad() + { + if (loadStarted) + { + yield break; + } + loadStarted = true; + + CraftData.PreparePrefabIDCache(); + CraftData.PrepareEntTechCache(); + + Log.Info($"Enqueuing loading for {PrefabDatabase.prefabFiles.Keys.Count} prefabs on {workersRunning} workers"); + + stopwatch.Start(); + foreach (string classId in PrefabDatabase.prefabFiles.Keys) + { + if (CraftData.entClassTechTable.TryGetValue(classId, out TechType techType)) + { + loadQueue.Enqueue((classId, techType)); + } + } + + for (int i = 0; i < workersRunning; i++) + { + CoroutineHost.StartCoroutine(ProcessQueueWorker(loadQueue)); + } + + yield return new WaitUntil(() => workersRunning == 0); + + stopwatch.Stop(); + Log.Info($"All prefab loading took {(double)stopwatch.ElapsedTicks / Stopwatch.Frequency}s"); + } + + private static IEnumerator ProcessQueueWorker(Queue<(string classId, TechType techType)> queue) + { + while (queue.Count > 0) + { + (string classId, TechType techType) = queue.Dequeue(); + yield return DefaultWorldEntitySpawner.CachePrefab(classId, techType); + } + workersRunning--; + } +} diff --git a/NitroxPatcher/Patches/Persistent/uGUI_MainMenu_Start_Patch.cs b/NitroxPatcher/Patches/Persistent/uGUI_MainMenu_Start_Patch.cs index b444685e36..b02e4c173e 100644 --- a/NitroxPatcher/Patches/Persistent/uGUI_MainMenu_Start_Patch.cs +++ b/NitroxPatcher/Patches/Persistent/uGUI_MainMenu_Start_Patch.cs @@ -5,18 +5,24 @@ using Nitrox.Model.Constants; using Nitrox.Model.Core; using Nitrox.Model.DataStructures; -using NitroxClient.Communication.Abstract; -using NitroxClient.Communication.MultiplayerSession; -using NitroxClient.MonoBehaviours.Gui.MainMenu.ServerJoin; using Nitrox.Model.DataStructures.Unity; using Nitrox.Model.MultiplayerSession; using Nitrox.Model.Subnautica.MultiplayerSession; +using NitroxClient.Communication.Abstract; +using NitroxClient.Communication.MultiplayerSession; +using NitroxClient.GameLogic.InitialSync; +using NitroxClient.MonoBehaviours.Gui.MainMenu.ServerJoin; using NitroxPatcher.Patches.Dynamic; using UnityEngine; +using UWE; namespace NitroxPatcher.Patches.Persistent; // TODO: Rework this to be less ad hoc and more robust with command line arguments +/// +/// Adds Nitrox's thanks for playing to end credits. +/// Starts the loading of all prefabs as soon as the main menu loads. +/// public sealed partial class uGUI_MainMenu_Start_Patch : NitroxPatch, IPersistentPatch { private static readonly MethodInfo TARGET_METHOD = AccessTools.EnumeratorMoveNext(Reflect.Method((uGUI_MainMenu t) => t.Start())); @@ -33,6 +39,8 @@ public static void Postfix() SpawnThankDialog(); } + CoroutineHost.StartCoroutine(PrefabLoadInitialSyncProcessor.StartAllPrefabLoad()); + #if DEBUG if (applied) { From 4f9be126ee7efb427a2d0c92a8511e721ab1b772 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:20:23 +0200 Subject: [PATCH 11/23] Fix: crash will now spawn by themselves and their parent home will have to recognize them or kick them out of home --- .../Entities/Metadata/CrashHomeMetadata.cs | 9 ++- .../Spawning/CrashHomeBootstrapper.cs | 3 +- .../Patcher/Patches/PatchesTranspilerTest.cs | 2 +- .../Extractor/CrashHomeMetadataExtractor.cs | 8 +- .../Processor/CrashHomeMetadataProcessor.cs | 50 +++++++++--- .../WorldEntities/CrashEntitySpawner.cs | 81 ------------------- .../Patches/Dynamic/CrashHome_Spawn_Patch.cs | 28 ++++--- 7 files changed, 71 insertions(+), 110 deletions(-) delete mode 100644 NitroxClient/GameLogic/Spawning/WorldEntities/CrashEntitySpawner.cs diff --git a/Nitrox.Model.Subnautica/DataStructures/GameLogic/Entities/Metadata/CrashHomeMetadata.cs b/Nitrox.Model.Subnautica/DataStructures/GameLogic/Entities/Metadata/CrashHomeMetadata.cs index 1e6f75f3ca..b783c30e01 100644 --- a/Nitrox.Model.Subnautica/DataStructures/GameLogic/Entities/Metadata/CrashHomeMetadata.cs +++ b/Nitrox.Model.Subnautica/DataStructures/GameLogic/Entities/Metadata/CrashHomeMetadata.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.Serialization; using BinaryPack.Attributes; +using Nitrox.Model.DataStructures; namespace Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Metadata; @@ -10,19 +11,23 @@ public class CrashHomeMetadata : EntityMetadata [DataMember(Order = 1)] public float SpawnTime { get; } + [DataMember(Order = 2)] + public Optional SpawnedCrashId { get; } + [IgnoreConstructor] protected CrashHomeMetadata() { // Constructor for serialization. Has to be "protected" for json serialization. } - public CrashHomeMetadata(float spawnTime) + public CrashHomeMetadata(float spawnTime, Optional spawnedCrashId) { SpawnTime = spawnTime; + SpawnedCrashId = spawnedCrashId; } public override string ToString() { - return $"[CrashHomeMetadata SpawnTime: {SpawnTime}]"; + return $"[{nameof(CrashHomeMetadata)} SpawnTime: {SpawnTime}, SpawnedCrashId: {SpawnedCrashId}]"; } } diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/CrashHomeBootstrapper.cs b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/CrashHomeBootstrapper.cs index 0310061b68..d8d1a5ba92 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/CrashHomeBootstrapper.cs +++ b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/CrashHomeBootstrapper.cs @@ -1,3 +1,4 @@ +using Nitrox.Model.DataStructures; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Metadata; using Nitrox.Server.Subnautica.Models.Helper; @@ -9,6 +10,6 @@ internal sealed class CrashHomeBootstrapper : IEntityBootstrapper public void Prepare(ref WorldEntity entity, DeterministicGenerator deterministicBatchGenerator) { // Set 0 for spawnTime so that CrashHome.Update can spawn a Crash if Start() couldn't - entity.Metadata = new CrashHomeMetadata(0); + entity.Metadata = new CrashHomeMetadata(0, Optional.Empty); } } diff --git a/Nitrox.Test/Patcher/Patches/PatchesTranspilerTest.cs b/Nitrox.Test/Patcher/Patches/PatchesTranspilerTest.cs index f4d48a3432..6126f57196 100644 --- a/Nitrox.Test/Patcher/Patches/PatchesTranspilerTest.cs +++ b/Nitrox.Test/Patcher/Patches/PatchesTranspilerTest.cs @@ -29,7 +29,7 @@ public class PatchesTranspilerTest [typeof(Constructable_DeconstructAsync_Patch), Constructable_DeconstructAsync_Patch.InstructionsToAdd.Count], [typeof(ConstructableBase_SetState_Patch), ConstructableBase_SetState_Patch.InstructionsToAdd.Count], [typeof(ConstructorInput_OnCraftingBegin_Patch), 7], - [typeof(CrashHome_Spawn_Patch), 2], + [typeof(CrashHome_Spawn_Patch), -5], [typeof(CrashHome_Update_Patch), -5], [typeof(CreatureDeath_OnKillAsync_Patch), 5], [typeof(CreatureDeath_SpawnRespawner_Patch), 2], diff --git a/NitroxClient/GameLogic/Spawning/Metadata/Extractor/CrashHomeMetadataExtractor.cs b/NitroxClient/GameLogic/Spawning/Metadata/Extractor/CrashHomeMetadataExtractor.cs index 7bdb52e7ec..001e77e2d3 100644 --- a/NitroxClient/GameLogic/Spawning/Metadata/Extractor/CrashHomeMetadataExtractor.cs +++ b/NitroxClient/GameLogic/Spawning/Metadata/Extractor/CrashHomeMetadataExtractor.cs @@ -1,3 +1,4 @@ +using Nitrox.Model.DataStructures; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Metadata; using NitroxClient.GameLogic.Spawning.Metadata.Extractor.Abstract; @@ -7,6 +8,11 @@ public class CrashHomeMetadataExtractor : EntityMetadataExtractor spawnedCrashId = Optional.Empty; + if (crashHome.crash && crashHome.crash.TryGetNitroxId(out NitroxId crashId)) + { + spawnedCrashId = crashId; + } + return new(crashHome.spawnTime, spawnedCrashId); } } diff --git a/NitroxClient/GameLogic/Spawning/Metadata/Processor/CrashHomeMetadataProcessor.cs b/NitroxClient/GameLogic/Spawning/Metadata/Processor/CrashHomeMetadataProcessor.cs index ca1d9d1bcc..dfa72a4fcf 100644 --- a/NitroxClient/GameLogic/Spawning/Metadata/Processor/CrashHomeMetadataProcessor.cs +++ b/NitroxClient/GameLogic/Spawning/Metadata/Processor/CrashHomeMetadataProcessor.cs @@ -1,31 +1,57 @@ +using System; +using System.Collections; +using Nitrox.Model.Core; +using Nitrox.Model.DataStructures; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Metadata; using NitroxClient.GameLogic.Spawning.Metadata.Processor.Abstract; +using NitroxClient.MonoBehaviours; using UnityEngine; +using UWE; namespace NitroxClient.GameLogic.Spawning.Metadata.Processor; public class CrashHomeMetadataProcessor : EntityMetadataProcessor { + private readonly Lazy entities = new Lazy(NitroxServiceLocator.LocateService); + public override void ProcessMetadata(GameObject gameObject, CrashHomeMetadata metadata) { - if (gameObject.TryGetComponent(out CrashHome crashHome)) + if (!gameObject.TryGetComponent(out CrashHome crashHome)) { - crashHome.spawnTime = metadata.SpawnTime; - UpdateCrashHomeOpen(crashHome); + Log.Error($"[{nameof(CrashHomeMetadataProcessor)}] Could not find {nameof(CrashHome)} on {gameObject}"); + return; } - else + + crashHome.spawnTime = metadata.SpawnTime; + + if (!metadata.SpawnedCrashId.HasValue) { - Log.Error($"[{nameof(CrashHomeMetadataProcessor)}] Could not find {nameof(CrashHome)} on {gameObject}"); + return; + } + + if (NitroxEntity.TryGetComponentFrom(metadata.SpawnedCrashId.Value, out Crash crash)) + { + crashHome.crash = crash; + return; + } + + if (entities.Value.SpawningEntities) + { + // Prevent spawn from occurring at all until we find out if the Crash is being spawned or not + crashHome.spawnTime = float.MaxValue; + CoroutineHost.StartCoroutine(DelayedPairToCrashAttempt(crashHome, metadata.SpawnedCrashId.Value)); } } - public static void UpdateCrashHomeOpen(CrashHome crashHome) + private IEnumerator DelayedPairToCrashAttempt(CrashHome crashHome, NitroxId crashId) { - // From CrashHome.Update - // We also add a distance detection to take into account if the crash is still in the home or not - bool isCrashResting = crashHome.crash && crashHome.crash.IsResting() && crashHome && - Vector3.Distance(crashHome.transform.position, crashHome.crash.transform.position) < 1f; - crashHome.animator.SetBool(AnimatorHashID.attacking, !isCrashResting); - crashHome.prevClosed = isCrashResting; + yield return new WaitUntil(() => !entities.Value.SpawningEntities); + if (NitroxEntity.TryGetComponentFrom(crashId, out Crash crash)) + { + crashHome.crash = crash; + } + // if no Crash was found, it might be because it has yet to be scheduled to spawn + // if the Crash has spawned, the spawnTime is also -1 + crashHome.spawnTime = -1; } } diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/CrashEntitySpawner.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/CrashEntitySpawner.cs deleted file mode 100644 index bf7716c205..0000000000 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/CrashEntitySpawner.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Collections; -using Nitrox.Model.DataStructures; -using NitroxClient.GameLogic.Spawning.Metadata.Processor; -using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; -using UnityEngine; - -namespace NitroxClient.GameLogic.Spawning.WorldEntities; - -public class CrashEntitySpawner : IWorldEntitySpawner, IWorldEntitySyncSpawner -{ - public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, EntityCell cellRoot, TaskResult> result) - { - if (!DefaultWorldEntitySpawner.TryGetCachedPrefab(out GameObject prefab, classId: entity.ClassId)) - { - TaskResult prefabResult = new(); - yield return DefaultWorldEntitySpawner.RequestPrefab(entity.ClassId, prefabResult); - if (!prefabResult.Get()) - { - Log.Error($"Couldn't find a prefab for {nameof(WorldEntity)} of ClassId {entity.ClassId}"); - yield break; - } - prefab = prefabResult.Get(); - } - - GameObject gameObject = GameObjectExtensions.InstantiateWithId(prefab, entity.Id); - if (!VerifyCanSpawnOrError(entity, gameObject, parent.Value, out Crash crash, out CrashHome crashHome)) - { - yield break; - } - SetupObject(entity, crash, crashHome); - - result.Set(gameObject); - } - - public bool SpawnsOwnChildren() => false; - - public bool SpawnSync(WorldEntity entity, Optional parent, EntityCell cellRoot, TaskResult> result) - { - if (!DefaultWorldEntitySpawner.TryGetCachedPrefab(out GameObject prefab, classId: entity.ClassId)) - { - return false; - } - - GameObject gameObject = GameObjectExtensions.InstantiateWithId(prefab, entity.Id); - if (!VerifyCanSpawnOrError(entity, gameObject, parent.Value, out Crash crash, out CrashHome crashHome)) - { - return true; - } - SetupObject(entity, crash, crashHome); - - result.Set(gameObject); - return true; - } - - private static bool VerifyCanSpawnOrError(WorldEntity entity, GameObject prefabObject, GameObject parentObject, out Crash crash, out CrashHome crashHome) - { - if (!prefabObject.TryGetComponent(out crash)) - { - Log.Error($"Couldn't find component {nameof(Crash)} on prefab with ClassId: {entity.ClassId}"); - crashHome = null; - return false; - } - if (parentObject && parentObject.TryGetComponent(out crashHome)) - { - return true; - } - - crashHome = null; - Log.Error($"Couldn't find a valid parent for {entity}"); - return false; - } - - private static void SetupObject(WorldEntity worldEntity, Crash crash, CrashHome crashHome) - { - crash.transform.SetPositionAndRotation(worldEntity.Transform.Position.ToUnity(), worldEntity.Transform.Rotation.ToUnity()); - crash.transform.localScale = worldEntity.Transform.LocalScale.ToUnity(); - crashHome.crash = crash; - CrashHomeMetadataProcessor.UpdateCrashHomeOpen(crashHome); - LargeWorldStreamer.main.MakeEntityTransient(crash.gameObject); - } -} diff --git a/NitroxPatcher/Patches/Dynamic/CrashHome_Spawn_Patch.cs b/NitroxPatcher/Patches/Dynamic/CrashHome_Spawn_Patch.cs index e51ba79bc0..924037691a 100644 --- a/NitroxPatcher/Patches/Dynamic/CrashHome_Spawn_Patch.cs +++ b/NitroxPatcher/Patches/Dynamic/CrashHome_Spawn_Patch.cs @@ -2,13 +2,12 @@ using System.Reflection; using System.Reflection.Emit; using HarmonyLib; -using NitroxClient.GameLogic; -using NitroxClient.GameLogic.Spawning.Metadata; -using NitroxClient.MonoBehaviours; using Nitrox.Model.DataStructures; -using Nitrox.Model.Subnautica.DataStructures.GameLogic; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Metadata; +using NitroxClient.GameLogic; +using NitroxClient.GameLogic.Spawning.Metadata.Extractor; +using NitroxClient.MonoBehaviours; using UnityEngine; namespace NitroxPatcher.Patches.Dynamic; @@ -30,7 +29,7 @@ public static bool Prefix(CrashHome __instance) /* * this.spawnTime = -1f; * BroadcastFishCreated(gameObject); [INSERTED LINE] - * if (LargeWorldStreamer.main != null) + * [RETURN EQUIVALENT] */ public static IEnumerable Transpiler(IEnumerable instructions) { @@ -38,6 +37,7 @@ public static IEnumerable Transpiler(IEnumerable BroadcastFishCreated(default)))) + .RemoveInstructions(7) .InstructionEnumeration(); } @@ -52,15 +52,19 @@ public static void BroadcastFishCreated(GameObject crashFishObject) LargeWorldEntity largeWorldEntity = crashFishObject.GetComponent(); UniqueIdentifier uniqueIdentifier = crashFishObject.GetComponent(); - // Broadcast the new CrashHome's metadata (spawnTime = -1) - Optional metadata = Resolve().Extract(crashHome); - if (metadata.HasValue) + // Create the entity + WorldEntity crashFishEntity = new(crashFishObject.transform.ToWorldDto(), (int)largeWorldEntity.cellLevel, uniqueIdentifier.classId, false, crashFishId, TechType.Crash.ToDto(), null, null, []); + Resolve().BroadcastEntitySpawnedByClient(crashFishEntity); + + // Forcefully unparents it from the CrashHome and puts it under its parent cell + if (LargeWorldStreamer.main) { - Resolve().BroadcastMetadataUpdate(crashHomeId, metadata.Value); + LargeWorldStreamer.main.cellManager.RegisterEntity(largeWorldEntity); } - // Create the entity - WorldEntity crashFishEntity = new(crashFishObject.transform.ToWorldDto(), (int)largeWorldEntity.cellLevel, uniqueIdentifier.classId, false, crashFishId, TechType.Crash.ToDto(), null, crashHomeId, new List()); - Resolve().BroadcastEntitySpawnedByClient(crashFishEntity); + // Broadcast the new CrashHome's metadata ONLY after sending the entity spawn packet so that the processor always tries finding the Crash + // after the spawning process has at least begun + CrashHomeMetadata crashHomeMetadata = Resolve().Extract(crashHome); + Resolve().BroadcastMetadataUpdate(crashHomeId, crashHomeMetadata); } } From 5eaed2062c5e49ec5c39680b756acd10eeef3db7 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:26:09 +0200 Subject: [PATCH 12/23] Fix: reefback spawn now profits from prefab preload and handles its children correctly --- .../Packets/EntityTransformUpdates.cs | 10 +-- .../Packets/LastEntityTransformUpdate.cs | 11 +++ .../Entities/Spawning/ReefbackBootstrapper.cs | 4 + .../LastEntityTransformUpdateProcessor.cs | 47 ++++++++++ NitroxClient/GameLogic/SimulationOwnership.cs | 34 ++++++- .../DefaultWorldEntitySpawner.cs | 25 ++++-- .../ReefbackChildEntitySpawner.cs | 4 + .../WorldEntities/ReefbackEntitySpawner.cs | 89 +++++++------------ .../WorldEntitySpawnerResolver.cs | 2 +- NitroxClient/GameLogic/Terrain.cs | 6 +- .../EntityPositionBroadcaster.cs | 39 +++++++- .../MonoBehaviours/OutOfCellEntity.cs | 3 +- .../LargeWorldEntity_UpdateCell_Patch.cs | 19 ++-- 13 files changed, 206 insertions(+), 87 deletions(-) create mode 100644 Nitrox.Model.Subnautica/Packets/LastEntityTransformUpdate.cs create mode 100644 Nitrox.Server.Subnautica/Models/Packets/Processors/LastEntityTransformUpdateProcessor.cs diff --git a/Nitrox.Model.Subnautica/Packets/EntityTransformUpdates.cs b/Nitrox.Model.Subnautica/Packets/EntityTransformUpdates.cs index edb3c17cb9..41375bcb51 100644 --- a/Nitrox.Model.Subnautica/Packets/EntityTransformUpdates.cs +++ b/Nitrox.Model.Subnautica/Packets/EntityTransformUpdates.cs @@ -18,15 +18,15 @@ public EntityTransformUpdates(List updates) public override string ToString() { - return $"[EntityTransformUpdates: {String.Join(" ", Updates)} ]"; + return $"[{nameof(EntityTransformUpdates)} [{string.Join(" ", Updates)}]]"; } [Serializable] public abstract class EntityTransformUpdate { public NitroxId Id { get; } - public NitroxVector3 Position { get; } - public NitroxQuaternion Rotation { get; } + public NitroxVector3 Position { get; set; } + public NitroxQuaternion Rotation { get; set; } public EntityTransformUpdate(NitroxId id, NitroxVector3 position, NitroxQuaternion rotation) { @@ -46,7 +46,7 @@ public RawTransformUpdate(NitroxId id, NitroxVector3 position, NitroxQuaternion public override string ToString() { - return $"[RawTransformUpdate Id:{Id} Position:{Position} Rotation:{Rotation}]"; + return $"[{nameof(RawTransformUpdate)} Id: {Id}, Position: {Position}, Rotation: {Rotation}]"; } } @@ -66,7 +66,7 @@ public SplineTransformUpdate(NitroxId id, NitroxVector3 position, NitroxQuaterni public override string ToString() { - return $"[SplineTransformUpdate Id:{Id} Position:{Position} Rotation:{Rotation} DestinationPosition:{DestinationPosition} DestinationDirection:{DestinationDirection} Velocity:{Velocity} ]"; + return $"[{nameof(SplineTransformUpdate)} Id: {Id}, Position: {Position}, Rotation: {Rotation}, DestinationPosition: {DestinationPosition}, DestinationDirection: {DestinationDirection}, Velocity: {Velocity}]"; } } } diff --git a/Nitrox.Model.Subnautica/Packets/LastEntityTransformUpdate.cs b/Nitrox.Model.Subnautica/Packets/LastEntityTransformUpdate.cs new file mode 100644 index 0000000000..45a5d60853 --- /dev/null +++ b/Nitrox.Model.Subnautica/Packets/LastEntityTransformUpdate.cs @@ -0,0 +1,11 @@ +using System; +using Nitrox.Model.Packets; +using static Nitrox.Model.Subnautica.Packets.EntityTransformUpdates; + +namespace Nitrox.Model.Subnautica.Packets; + +[Serializable] +public class LastEntityTransformUpdate(EntityTransformUpdate lastUpdate) : Packet +{ + public EntityTransformUpdate LastUpdate { get; set; } = lastUpdate; +} diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/ReefbackBootstrapper.cs b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/ReefbackBootstrapper.cs index 8b44d1ffe1..cb31812fd6 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/ReefbackBootstrapper.cs +++ b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/ReefbackBootstrapper.cs @@ -2,6 +2,7 @@ using Nitrox.Model.DataStructures.Unity; using Nitrox.Model.Subnautica.DataStructures.GameLogic; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; +using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Metadata; using Nitrox.Server.Subnautica.Models.Helper; using static Nitrox.Server.Subnautica.Models.GameLogic.Entities.Spawning.ReefbackSpawnData; @@ -28,6 +29,9 @@ public ReefbackBootstrapper(XorRandom random) public void Prepare(ref WorldEntity entity, DeterministicGenerator generator) { + // See Creature.InitializeOnce + entity.Metadata = new StayAtLeashPositionMetadata(entity.Transform.Position); + // From ReefbackLife.Initialize if (entity.Transform.LocalScale.X <= 0.8f) { diff --git a/Nitrox.Server.Subnautica/Models/Packets/Processors/LastEntityTransformUpdateProcessor.cs b/Nitrox.Server.Subnautica/Models/Packets/Processors/LastEntityTransformUpdateProcessor.cs new file mode 100644 index 0000000000..e6c79ca52f --- /dev/null +++ b/Nitrox.Server.Subnautica/Models/Packets/Processors/LastEntityTransformUpdateProcessor.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using Nitrox.Model.DataStructures; +using Nitrox.Model.Subnautica.DataStructures.GameLogic; +using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; +using Nitrox.Server.Subnautica.Models.GameLogic; +using Nitrox.Server.Subnautica.Models.GameLogic.Entities; +using Nitrox.Server.Subnautica.Models.Packets.Core; +using static Nitrox.Model.Subnautica.Packets.EntityTransformUpdates; + +namespace Nitrox.Server.Subnautica.Models.Packets.Processors; + +internal sealed class LastEntityTransformUpdateProcessor(WorldEntityManager worldEntityManager, SimulationOwnershipData simulationOwnershipData, PlayerManager playerManager, EntitySimulation entitySimulation) : IAuthPacketProcessor +{ + private readonly WorldEntityManager worldEntityManager = worldEntityManager; + private readonly SimulationOwnershipData simulationOwnershipData = simulationOwnershipData; + private readonly PlayerManager playerManager = playerManager; + private readonly EntitySimulation entitySimulation = entitySimulation; + + public async Task Process(AuthProcessorContext context, LastEntityTransformUpdate packet) + { + EntityTransformUpdate lastUpdate = packet.LastUpdate; + if (!simulationOwnershipData.RevokeIfOwner(lastUpdate.Id, context.Sender)) + { + return; + } + + if (!worldEntityManager.TryUpdateEntityPosition(lastUpdate.Id, lastUpdate.Position, lastUpdate.Rotation, out AbsoluteEntityCell currentCell, out WorldEntity worldEntity)) + { + return; + } + + EntityTransformUpdates entityTransformUpdates = new([lastUpdate]); + + foreach (Player player in playerManager.GetConnectedPlayersExcept(context.Sender.SessionId)) + { + if (player.CanSee(worldEntity)) + { + await context.SendAsync(entityTransformUpdates, player.SessionId); + } + } + + List simulationChange = []; + entitySimulation.AssignEntitiesToOtherPlayers(context.Sender.SessionId, [worldEntity], simulationChange); + + await context.SendToAllAsync(new SimulationOwnershipChange(simulationChange)); + } +} diff --git a/NitroxClient/GameLogic/SimulationOwnership.cs b/NitroxClient/GameLogic/SimulationOwnership.cs index b7c2162d53..a31a10e331 100644 --- a/NitroxClient/GameLogic/SimulationOwnership.cs +++ b/NitroxClient/GameLogic/SimulationOwnership.cs @@ -1,9 +1,9 @@ using System.Collections.Generic; +using Nitrox.Model.DataStructures; +using Nitrox.Model.Subnautica.Packets; using NitroxClient.Communication.Abstract; using NitroxClient.GameLogic.Simulation; using NitroxClient.MonoBehaviours; -using Nitrox.Model.DataStructures; -using Nitrox.Model.Subnautica.Packets; using UnityEngine; namespace NitroxClient.GameLogic; @@ -116,6 +116,8 @@ public void TreatSimulatedEntity(SimulatedEntity simulatedEntity) { Object.Destroy(remotelyControlled); } + + TreatReefback(simulatedEntity.Id, isLocalPlayerNewOwner); } /// @@ -152,6 +154,34 @@ public bool TryGetLockType(NitroxId nitroxId, out SimulationLockType simulationL return simulatedIdsByLockType.TryGetValue(nitroxId, out simulationLockType); } + /// + /// Reefbacks host creatures as children. These creatures have their LargeWorldEntity disabled, which means they only dispawn when the parent Reefback dispawns. + /// Thus they're are so connected to it that we can assume taking ownership on a Reefback is equivalent to taking it on its children. + /// + private void TreatReefback(NitroxId entityId, bool isSimulating) + { + if (!NitroxEntity.TryGetComponentFrom(entityId, out ReefbackLife reefbackLife)) + { + return; + } + + // Any creatureSlot's parent is the CreatureSlots Transform + foreach (Transform reefbackChildTransform in reefbackLife.creatureSlots[0].parent) + { + if (reefbackChildTransform.TryGetNitroxId(out NitroxId reefbackChildId)) + { + if (isSimulating) + { + TakeOwnership(reefbackChildId, SimulationLockType.TRANSIENT, true); + } + else + { + DropSimulationFrom(reefbackChildId); + } + } + } + } + public bool TreatVehicleEntity(NitroxId entityId, bool isLocalPlayerNewOwner, SimulationLockType simulationLockType) { if (!NitroxEntity.TryGetObjectFrom(entityId, out GameObject gameObject) || !IsVehicle(gameObject)) diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/DefaultWorldEntitySpawner.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/DefaultWorldEntitySpawner.cs index bf54441218..b3d2b1d55c 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/DefaultWorldEntitySpawner.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/DefaultWorldEntitySpawner.cs @@ -23,12 +23,12 @@ public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, E yield return CreateGameObject(techType, entity.ClassId, entity.Id, gameObjectResult); GameObject gameObject = gameObjectResult.Get(); - SetupObject(entity, parent, gameObject, cellRoot, techType); + SetupObject(entity, parent, gameObject, cellRoot, techType, true); result.Set(Optional.Of(gameObject)); } - private void SetupObject(WorldEntity entity, Optional parent, GameObject gameObject, EntityCell cellRoot, TechType techType) + public void SetupObject(WorldEntity entity, Optional parent, GameObject gameObject, EntityCell cellRoot, TechType techType, bool setupLargeWorldEntity) { gameObject.transform.position = entity.Transform.Position.ToUnity(); gameObject.transform.rotation = entity.Transform.Rotation.ToUnity(); @@ -48,7 +48,7 @@ private void SetupObject(WorldEntity entity, Optional parent, GameOb largeWorldEntity.cellLevel = (LargeWorldEntity.CellLevel)entity.Level; } - if (!parentWaterPark) + if (!parentWaterPark && setupLargeWorldEntity) { if (parent.HasValue && !parent.Value.GetComponent()) { @@ -89,7 +89,7 @@ public static bool TryGetCachedPrefab(out GameObject prefab, TechType techType = // If we've never even once issued a request prefab for the class id we need to do it because multiple prefabs // can have the same TechType so it's not good enough to find the right prefab - if (!classIdsWithoutPrefab.Contains(classId) || techType == TechType.None) + if ((classId != null && !classIdsWithoutPrefab.Contains(classId)) || techType == TechType.None) { prefab = null; return false; @@ -98,6 +98,21 @@ public static bool TryGetCachedPrefab(out GameObject prefab, TechType techType = return prefabCacheByTechType.TryGetValue(techType, out prefab); } + public static IEnumerator CachePrefab(string classId, TechType techType) + { + IPrefabRequest request = PrefabDatabase.GetPrefabAsync(classId); + yield return request; + if (request.TryGetPrefab(out GameObject prefabObject)) + { + prefabCacheByClassId[classId] = prefabObject; + prefabCacheByTechType[techType] = prefabObject; + } + else + { + prefabNotFound.Add((classId, techType)); + } + } + /// /// Either gets the prefab reference from the cache or requests it and fills the provided result with it. /// @@ -190,7 +205,7 @@ public bool SpawnSync(WorldEntity entity, Optional parent, EntityCel if (TryCreateGameObjectSync(techType, entity.ClassId, entity.Id, out GameObject gameObject)) { - SetupObject(entity, parent, gameObject, cellRoot, techType); + SetupObject(entity, parent, gameObject, cellRoot, techType, true); result.Set(gameObject); return true; } diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackChildEntitySpawner.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackChildEntitySpawner.cs index f3cce86616..3caa9fe1a8 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackChildEntitySpawner.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackChildEntitySpawner.cs @@ -95,5 +95,9 @@ private static void SetupObject(ReefbackChildEntity entity, GameObject gameObjec gameObject.AddComponent(); break; } + + // Reefback children have a global position that gives them AbsoluteCellEntity that might differ from + // their parent's, so just in case we mark them as OutOfCellEntity so server-side Player.CanSee returns true for them + gameObject.EnsureComponent().Init(entity.Id); } } diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackEntitySpawner.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackEntitySpawner.cs index f1d01f2295..524ae61e97 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackEntitySpawner.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackEntitySpawner.cs @@ -6,89 +6,62 @@ namespace NitroxClient.GameLogic.Spawning.WorldEntities; -public class ReefbackEntitySpawner : IWorldEntitySpawner, IWorldEntitySyncSpawner +public class ReefbackEntitySpawner(ReefbackChildEntitySpawner reefbackChildEntitySpawner, DefaultWorldEntitySpawner defaultWorldEntitySpawner, Entities entities) : IWorldEntitySpawner { - private readonly ReefbackChildEntitySpawner reefbackChildEntitySpawner; - - public ReefbackEntitySpawner(ReefbackChildEntitySpawner reefbackChildEntitySpawner) - { - this.reefbackChildEntitySpawner = reefbackChildEntitySpawner; - } + private readonly ReefbackChildEntitySpawner reefbackChildEntitySpawner = reefbackChildEntitySpawner; + private readonly DefaultWorldEntitySpawner defaultWorldEntitySpawner = defaultWorldEntitySpawner; + private readonly Entities entities = entities; public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, EntityCell cellRoot, TaskResult> result) { if (entity is not ReefbackEntity reefbackEntity) { + Log.Error($"[{nameof(ReefbackEntitySpawner)}] Can't spawn {entity.Id} of type {entity.GetType()} because it is not a {nameof(ReefbackEntity)}"); yield break; } - if (!DefaultWorldEntitySpawner.TryGetCachedPrefab(out GameObject prefab, classId: entity.ClassId)) - { - TaskResult prefabResult = new(); - yield return DefaultWorldEntitySpawner.RequestPrefab(entity.ClassId, prefabResult); - if (!prefabResult.Get()) - { - Log.Error($"Couldn't find a prefab for {nameof(OxygenPipeEntity)} of ClassId {entity.ClassId}"); - yield break; - } - prefab = prefabResult.Get(); - } - - GameObject gameObject = GameObjectExtensions.InstantiateWithId(prefab, entity.Id); - if (!VerifyCanSpawnOrError(reefbackEntity, gameObject, out ReefbackLife reefbackLife)) + if (!DefaultWorldEntitySpawner.TryCreateGameObjectSync(entity.TechType.ToUnity(), entity.ClassId, entity.Id, out GameObject reefbackObject)) { + Log.ErrorOnce($"[{nameof(PlaceholderGroupWorldEntitySpawner)}] Could not find a prefab for {entity.Id} [classId: {entity.ClassId}, TechType: {entity.TechType}]"); yield break; } + ReefbackLife reefbackLife = reefbackObject.GetComponent(); + LargeWorldEntity largeWorldEntity = reefbackObject.GetComponent(); - SetupObject(reefbackEntity, gameObject, cellRoot, reefbackLife); - result.Set(gameObject); - } + // Prevent the entity from disappearing because of parent cell going to sleep until it's fully spawned + largeWorldEntity.enabled = false; - public bool SpawnSync(WorldEntity entity, Optional parent, EntityCell cellRoot, TaskResult> result) - { - if (entity is not ReefbackEntity reefbackEntity) - { - return true; - } + SetupObject(reefbackEntity, reefbackObject, cellRoot, reefbackLife); - if (!DefaultWorldEntitySpawner.TryGetCachedPrefab(out GameObject prefab, classId: entity.ClassId)) + TaskResult> childTaskResult = new(); + foreach (ReefbackChildEntity reefbackChildEntity in entity.ChildEntities) { - return false; - } + reefbackChildEntitySpawner.SpawnSync(reefbackChildEntity, reefbackObject, cellRoot, childTaskResult); - GameObject gameObject = GameObjectExtensions.InstantiateWithId(prefab, entity.Id); - if (!VerifyCanSpawnOrError(reefbackEntity, gameObject, out ReefbackLife reefbackLife)) - { - return true; + if (childTaskResult.Get().HasValue) + { + entities.OnEntitySpawned(reefbackChildEntity, childTaskResult.Get().Value); + } + + if (entities.ShouldSkipFrame) + { + yield return null; + entities.RefreshTimeUntilNextYield(); + } } - SetupObject(reefbackEntity, gameObject, cellRoot, reefbackLife); + largeWorldEntity.enabled = true; + LargeWorldEntity.Register(reefbackObject); - result.Set(gameObject); - return true; + result.Set(reefbackObject); } - public bool SpawnsOwnChildren() => false; - - private static bool VerifyCanSpawnOrError(ReefbackEntity entity, GameObject prefabObject, out ReefbackLife reefbackLife) - { - if (prefabObject.TryGetComponent(out reefbackLife)) - { - return true; - } - Log.Error($"Could not find component {nameof(ReefbackLife)} on prefab with ClassId: {entity.ClassId}"); - return false; - } + public bool SpawnsOwnChildren() => true; - private static void SetupObject(ReefbackEntity entity, GameObject gameObject, EntityCell entityCell, ReefbackLife reefbackLife) + private void SetupObject(ReefbackEntity entity, GameObject gameObject, EntityCell cellRoot, ReefbackLife reefbackLife) { - Transform transform = gameObject.transform; - transform.localPosition = entity.Transform.Position.ToUnity(); - transform.localRotation = entity.Transform.Rotation.ToUnity(); - transform.localScale = entity.Transform.LocalScale.ToUnity(); - entityCell.EnsureRoot(); - transform.SetParent(entityCell.liveRoot.transform); + defaultWorldEntitySpawner.SetupObject(entity, Optional.Empty, gameObject, cellRoot, entity.TechType.ToUnity(), false); // Replicate only the useful parts of ReefbackLife.Initialize reefbackLife.initialized = true; diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs index 9db0b09ba4..287eb24804 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs @@ -28,7 +28,7 @@ public WorldEntitySpawnerResolver(EntityMetadataManager entityMetadataManager, E serializedWorldEntitySpawner = new SerializedWorldEntitySpawner(); geyserWorldEntitySpawner = new GeyserWorldEntitySpawner(entities); reefbackChildEntitySpawner = new ReefbackChildEntitySpawner(); - reefbackEntitySpawner = new ReefbackEntitySpawner(reefbackChildEntitySpawner); + reefbackEntitySpawner = new ReefbackEntitySpawner(reefbackChildEntitySpawner, defaultEntitySpawner, entities); creatureRespawnEntitySpawner = new CreatureRespawnEntitySpawner(simulationOwnership); } diff --git a/NitroxClient/GameLogic/Terrain.cs b/NitroxClient/GameLogic/Terrain.cs index d68cc6847a..81b319a44f 100644 --- a/NitroxClient/GameLogic/Terrain.cs +++ b/NitroxClient/GameLogic/Terrain.cs @@ -33,10 +33,9 @@ public void CellLoaded(Int3 batchId, Int3 cellId, int level) { AbsoluteEntityCell cell = new(batchId.ToDto(), cellId.ToDto(), level); - if (!visibleCells.Contains(cell)) + if (visibleCells.Add(cell)) { removedCells.Remove(cell); - visibleCells.Add(cell); addedCells.Add(cell); cellsPendingSync = true; } @@ -46,9 +45,8 @@ public void CellUnloaded(Int3 batchId, Int3 cellId, int level) { AbsoluteEntityCell cell = new(batchId.ToDto(), cellId.ToDto(), level); - if (visibleCells.Contains(cell)) + if (visibleCells.Remove(cell)) { - visibleCells.Remove(cell); removedCells.Add(cell); cellsPendingSync = true; fullySpawnedCells.Remove((batchId, cellId, level)); diff --git a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs index 9885b1f522..0dc2e575f4 100644 --- a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs +++ b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs @@ -37,7 +37,7 @@ public class EntityPositionBroadcaster : MonoBehaviour /// Reusable list of s to avoid reallocating a new list at each broadcast. /// /// - /// This only works because immediately serialiazes the list. + /// This only works because immediately serializes the list. /// private readonly List updates = new(50); @@ -93,8 +93,11 @@ private void BuildUpdates() // Only send data for entities still simulated by the local player foreach (SplineTransformUpdate splineUpdate in splineUpdatesById.Values) { - if (simulationOwnership.HasAnyLockType(splineUpdate.Id)) + if (simulationOwnership.HasAnyLockType(splineUpdate.Id) && splineEntities.TryGetValue(splineUpdate.Id, out SwimBehaviour swimBehaviour)) { + Transform entityTransform = swimBehaviour.transform; + splineUpdate.Position = entityTransform.position.ToDto(); + splineUpdate.Rotation = entityTransform.rotation.ToDto(); updates.Add(splineUpdate); } } @@ -212,4 +215,36 @@ public void RemoveEntityMovementControl(GameObject gameObject, NitroxId entityId } StopWatchingEntity(entityId); } + + /// + /// Notifies the server of the latest known position of this entity if the local player is simulating it. + /// + public void BroadcastLastUpdate(GameObject gameObject) + { + if (!gameObject.TryGetNitroxId(out NitroxId entityId) || !simulationOwnership.HasAnyLockType(entityId)) + { + return; + } + + Transform entityTransform = gameObject.transform; + EntityTransformUpdate entityTransformUpdate = null; + + if (splineEntities.ContainsKey(entityId) && gameObject.TryGetComponent(out SplineFollowing splineFollowing)) + { + // Clean up in case there remains an update that wasn't sent yet + splineUpdatesById.Remove(entityId); + entityTransformUpdate = new SplineTransformUpdate(entityId, entityTransform.position.ToDto(), entityTransform.rotation.ToDto(), splineFollowing.targetPosition.ToDto(), splineFollowing.targetDirection.ToDto(), splineFollowing.medianSpeed); + } else if (regularEntities.ContainsKey(entityId)) + { + entityTransformUpdate = new RawTransformUpdate(entityId, entityTransform.position.ToDto(), entityTransform.rotation.ToDto()); + } + + if (entityTransformUpdate != null) + { + packetSender.Send(new LastEntityTransformUpdate(entityTransformUpdate)); + } + + // locally drop simulation + simulationOwnership.DropSimulationFrom(entityId); + } } diff --git a/NitroxClient/MonoBehaviours/OutOfCellEntity.cs b/NitroxClient/MonoBehaviours/OutOfCellEntity.cs index 293ad523b7..1b662a9f61 100644 --- a/NitroxClient/MonoBehaviours/OutOfCellEntity.cs +++ b/NitroxClient/MonoBehaviours/OutOfCellEntity.cs @@ -1,7 +1,6 @@ -using NitroxClient.Communication.Abstract; using Nitrox.Model.DataStructures; -using Nitrox.Model.Packets; using Nitrox.Model.Subnautica.Packets; +using NitroxClient.Communication.Abstract; using UnityEngine; namespace NitroxClient.MonoBehaviours; diff --git a/NitroxPatcher/Patches/Dynamic/LargeWorldEntity_UpdateCell_Patch.cs b/NitroxPatcher/Patches/Dynamic/LargeWorldEntity_UpdateCell_Patch.cs index 22c70df66a..77f46f41ed 100644 --- a/NitroxPatcher/Patches/Dynamic/LargeWorldEntity_UpdateCell_Patch.cs +++ b/NitroxPatcher/Patches/Dynamic/LargeWorldEntity_UpdateCell_Patch.cs @@ -2,8 +2,8 @@ using System.Reflection; using System.Reflection.Emit; using HarmonyLib; -using NitroxClient.MonoBehaviours; using Nitrox.Model.DataStructures; +using NitroxClient.MonoBehaviours; using UnityEngine; namespace NitroxPatcher.Patches.Dynamic; @@ -45,21 +45,24 @@ private static void EntityUpdated(bool success, LargeWorldEntity largeWorldEntit { return; } + GameObject entityObject = largeWorldEntity.gameObject; // The entity has left the regular loaded zone // NB: Very important to check for selfActive because it's false when the entity is unloaded by EntityCell.AddEntity - if (largeWorldEntity.gameObject.activeSelf) + if (entityObject.activeSelf) { if (largeWorldEntity.TryGetNitroxId(out NitroxId nitroxId)) { - largeWorldEntity.gameObject.EnsureComponent().Init(nitroxId); + entityObject.EnsureComponent().Init(nitroxId); } + return; } - // Entity was added to a sleeping cell so it'll be just like it's - else if (largeWorldEntity.TryGetComponent(out OutOfCellEntity outOfCellEntity)) - { - Object.Destroy(outOfCellEntity); - } + + // We need to send a last position update in case we're simulating the entity so that at least the server knows where we left it + EntityPositionBroadcaster.Instance.BroadcastLastUpdate(entityObject); + + // Entity was added to a sleeping cell so it's as if it was removed + GameObject.Destroy(entityObject); } } From 5634192c86e7179b6526d1afae892342b44648a8 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:26:59 +0200 Subject: [PATCH 13/23] Fix batch spawn regression and leftover misses --- Nitrox.Model.Subnautica/Packets/SpawnEntities.cs | 10 +++++----- .../GameLogic/Entities/Spawning/BatchEntitySpawner.cs | 2 +- .../WorldEntities/WorldEntitySpawnerResolver.cs | 1 - 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Nitrox.Model.Subnautica/Packets/SpawnEntities.cs b/Nitrox.Model.Subnautica/Packets/SpawnEntities.cs index 417cc78772..b317126919 100644 --- a/Nitrox.Model.Subnautica/Packets/SpawnEntities.cs +++ b/Nitrox.Model.Subnautica/Packets/SpawnEntities.cs @@ -10,7 +10,7 @@ namespace Nitrox.Model.Subnautica.Packets; public class SpawnEntities : Packet { public List Entities { get; } - public List Simulations { get; } + public List SimulatedEntities { get; } public List SpawnedCells { get; } @@ -19,21 +19,21 @@ public class SpawnEntities : Packet public SpawnEntities(Entity entity, SimulatedEntity? simulatedEntity = null, bool forceRespawn = false) { Entities = [entity]; - Simulations = []; + SimulatedEntities = []; SpawnedCells = []; if (simulatedEntity != null) { - Simulations.Add(simulatedEntity); + SimulatedEntities.Add(simulatedEntity); } ForceRespawn = forceRespawn; } // Constructor for serialization. - public SpawnEntities(List entities, List simulations, List spawnedCells, bool forceRespawn) + public SpawnEntities(List entities, List simulatedEntities, List spawnedCells, bool forceRespawn) { Entities = entities; - Simulations = simulations; + SimulatedEntities = simulatedEntities; SpawnedCells = spawnedCells; ForceRespawn = forceRespawn; } diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/BatchEntitySpawner.cs b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/BatchEntitySpawner.cs index ab46e1608f..bfdcfd150d 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/BatchEntitySpawner.cs +++ b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/BatchEntitySpawner.cs @@ -388,7 +388,7 @@ private async Task> SpawnEntitiesAsync(List entit if (spawnedEntity != null) { // Spawned child will not be of the same type as the current prefabAsset - if (prefabPlaceholderGroupsResource.PlaceholdersByClassId.ContainsKey(spawnedEntity.ClassId)) + if (prefabPlaceholderGroupsResource.GroupsByClassId.ContainsKey(spawnedEntity.ClassId)) { spawnedEntity = new PlaceholderGroupWorldEntity(spawnedEntity, i); } diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs index 287eb24804..800baa71f4 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs @@ -20,7 +20,6 @@ internal sealed class WorldEntitySpawnerResolver public WorldEntitySpawnerResolver(EntityMetadataManager entityMetadataManager, Entities entities, SimulationOwnership simulationOwnership) { - customSpawnersByTechType[TechType.Crash] = new CrashEntitySpawner(); customSpawnersByTechType[TechType.Creepvine] = new CreepvineEntitySpawner(defaultEntitySpawner); prefabPlaceholderEntitySpawner = new PrefabPlaceholderEntitySpawner(defaultEntitySpawner); From 40a2847492e6e3f47cb71d75f3fed873e7b2364f Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:28:24 +0200 Subject: [PATCH 14/23] fix: improve placeholder group spawn by using prefab preload --- .../PlaceholderGroupWorldEntitySpawner.cs | 67 ++++++++++--------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/PlaceholderGroupWorldEntitySpawner.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/PlaceholderGroupWorldEntitySpawner.cs index fa4965be2b..230d5019ec 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/PlaceholderGroupWorldEntitySpawner.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/PlaceholderGroupWorldEntitySpawner.cs @@ -1,9 +1,9 @@ using System.Collections; using System.Collections.Generic; -using NitroxClient.GameLogic.Spawning.Metadata; using Nitrox.Model.DataStructures; using Nitrox.Model.Subnautica.DataStructures.GameLogic; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; +using NitroxClient.GameLogic.Spawning.Metadata; using UnityEngine; namespace NitroxClient.GameLogic.Spawning.WorldEntities; @@ -12,22 +12,13 @@ namespace NitroxClient.GameLogic.Spawning.WorldEntities; /// This spawner can't hold a SpawnSync function because it is also responsible for spawning its children /// so the function will still use sync spawning when possible and fall back to async when required. /// -internal sealed class PlaceholderGroupWorldEntitySpawner : IWorldEntitySpawner +internal sealed class PlaceholderGroupWorldEntitySpawner(Entities entities, WorldEntitySpawnerResolver spawnerResolver, DefaultWorldEntitySpawner defaultWorldEntitySpawner, EntityMetadataManager entityMetadataManager, PrefabPlaceholderEntitySpawner prefabPlaceholderEntitySpawner) : IWorldEntitySpawner { - private readonly Entities entities; - private readonly WorldEntitySpawnerResolver spawnerResolver; - private readonly DefaultWorldEntitySpawner defaultSpawner; - private readonly EntityMetadataManager entityMetadataManager; - private readonly PrefabPlaceholderEntitySpawner prefabPlaceholderEntitySpawner; - - public PlaceholderGroupWorldEntitySpawner(Entities entities, WorldEntitySpawnerResolver spawnerResolver, DefaultWorldEntitySpawner defaultSpawner, EntityMetadataManager entityMetadataManager, PrefabPlaceholderEntitySpawner prefabPlaceholderEntitySpawner) - { - this.entities = entities; - this.spawnerResolver = spawnerResolver; - this.defaultSpawner = defaultSpawner; - this.entityMetadataManager = entityMetadataManager; - this.prefabPlaceholderEntitySpawner = prefabPlaceholderEntitySpawner; - } + private readonly Entities entities = entities; + private readonly WorldEntitySpawnerResolver spawnerResolver = spawnerResolver; + private readonly DefaultWorldEntitySpawner defaultWorldEntitySpawner = defaultWorldEntitySpawner; + private readonly EntityMetadataManager entityMetadataManager = entityMetadataManager; + private readonly PrefabPlaceholderEntitySpawner prefabPlaceholderEntitySpawner = prefabPlaceholderEntitySpawner; public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, EntityCell cellRoot, TaskResult> result) { @@ -38,22 +29,24 @@ public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, E } TaskResult> prefabPlaceholderGroupTaskResult = new(); - if (!defaultSpawner.SpawnSync(entity, parent, cellRoot, prefabPlaceholderGroupTaskResult)) + + if (!DefaultWorldEntitySpawner.TryCreateGameObjectSync(entity.TechType.ToUnity(), entity.ClassId, entity.Id, out GameObject groupObject)) { - yield return defaultSpawner.SpawnAsync(entity, parent, cellRoot, prefabPlaceholderGroupTaskResult); + Log.ErrorOnce($"[{nameof(PlaceholderGroupWorldEntitySpawner)}] Could not find a prefab for {entity.Id} [classId: {entity.ClassId}, TechType: {entity.TechType}]"); + yield break; } + LargeWorldEntity largeWorldEntity = groupObject.GetComponent(); + PrefabPlaceholdersGroup prefabPlaceholderGroup = groupObject.GetComponent(); + + defaultWorldEntitySpawner.SetupObject(entity, parent, groupObject, cellRoot, entity.TechType.ToUnity(), false); - Optional prefabPlaceholderGroupGameObject = prefabPlaceholderGroupTaskResult.Get(); - if (!prefabPlaceholderGroupGameObject.HasValue) + // Prevent the entity from disappearing because of parent cell going to sleep until it's fully spawned + if (!parent.HasValue) { - yield break; + largeWorldEntity.enabled = false; } - GameObject groupObject = prefabPlaceholderGroupGameObject.Value; - // Spawning PrefabPlaceholders as siblings to the group - PrefabPlaceholdersGroup prefabPlaceholderGroup = groupObject.GetComponent(); - // Spawning all children iteratively Stack stack = new(placeholderGroupEntity.ChildEntities); @@ -62,13 +55,9 @@ public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, E { { entity.Id, groupObject } }; + while (stack.Count > 0) { - // It may happen that the chunk is unloaded, and the group along so we just cancel this spawn behaviour - if (!groupObject) - { - yield break; - } childResult.Set(Optional.Empty); Entity current = stack.Pop(); switch (current) @@ -76,19 +65,21 @@ public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, E case PrefabPlaceholderEntity prefabEntity: if (!prefabPlaceholderEntitySpawner.SpawnSync(prefabEntity, groupObject, cellRoot, childResult)) { - yield return prefabPlaceholderEntitySpawner.SpawnAsync(prefabEntity, groupObject, cellRoot, childResult); + Log.Error($"[{nameof(PlaceholderGroupWorldEntitySpawner)}] Could not spawn child entity {prefabEntity}"); } break; case PlaceholderGroupWorldEntity groupEntity: PrefabPlaceholder placeholder = prefabPlaceholderGroup.prefabPlaceholders[groupEntity.ComponentIndex]; yield return SpawnAsync(groupEntity, placeholder.transform.parent.gameObject, cellRoot, childResult); + entities.RefreshTimeUntilNextYield(); break; case WorldEntity worldEntity: if (!SpawnWorldEntityChildSync(worldEntity, cellRoot, parentById.GetOrDefault(current.ParentId, null), childResult, out IEnumerator asyncInstructions)) { yield return asyncInstructions; + entities.RefreshTimeUntilNextYield(); } break; @@ -117,12 +108,24 @@ public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, E stack.Push(slotEntityChild); } } + + if (entities.ShouldSkipFrame) + { + yield return null; + entities.RefreshTimeUntilNextYield(); + } } // Handle setting isKinematic on Floating Stones prefabPlaceholderGroup.OnPrefabGroupSpawned?.Invoke(); - result.Set(prefabPlaceholderGroupGameObject); + if (!parent.HasValue) + { + largeWorldEntity.enabled = true; + LargeWorldEntity.Register(groupObject); + } + + result.Set(groupObject); } public bool SpawnsOwnChildren() => true; From c0a9ffbcce3956b6488dc5bb6094cfa3d951924b Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:29:45 +0200 Subject: [PATCH 15/23] Feat: upgrade Entities.cs to better work with in-spawner children spawning --- NitroxClient/GameLogic/Entities.cs | 90 +++++++++++------------------- 1 file changed, 34 insertions(+), 56 deletions(-) diff --git a/NitroxClient/GameLogic/Entities.cs b/NitroxClient/GameLogic/Entities.cs index 2b886ae431..d16adaca26 100644 --- a/NitroxClient/GameLogic/Entities.cs +++ b/NitroxClient/GameLogic/Entities.cs @@ -1,7 +1,12 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Linq; +using Nitrox.Model.DataStructures; +using Nitrox.Model.Subnautica.DataStructures.GameLogic; +using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; +using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Bases; +using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Metadata; +using Nitrox.Model.Subnautica.Packets; using NitroxClient.Communication; using NitroxClient.Communication.Abstract; using NitroxClient.GameLogic.Spawning; @@ -10,12 +15,6 @@ using NitroxClient.GameLogic.Spawning.Metadata; using NitroxClient.GameLogic.Spawning.WorldEntities; using NitroxClient.MonoBehaviours; -using Nitrox.Model.DataStructures; -using Nitrox.Model.Subnautica.DataStructures.GameLogic; -using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; -using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Bases; -using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities.Metadata; -using Nitrox.Model.Subnautica.Packets; using UnityEngine; using UWE; @@ -30,7 +29,6 @@ public class Entities private readonly Terrain terrain; private readonly Dictionary spawnedAsType = []; - private readonly Dictionary> pendingParentEntitiesByParentId = []; private readonly Dictionary entitySpawnersByType = []; @@ -40,6 +38,16 @@ public class Entities private readonly HashSet deletedEntitiesIds = []; + /// + /// We divide the FPS by 2.5 because we consider (time for 1 frame + spawning time without a frame + extra computing time). + /// + private static float allottedTimePerFrame => 0.4f / Application.targetFrameRate; + private readonly TaskResult> entityResult = new(); + private readonly TaskResult exception = new(); + private float timeUntilNextYield; + public bool ShouldSkipFrame => Time.realtimeSinceStartup >= timeUntilNextYield; + + public Entities(IPacketSender packetSender, ThrottledPacketSender throttledPacketSender, EntityMetadataManager entityMetadataManager, PlayerManager playerManager, LocalPlayer localPlayer, LiveMixinManager liveMixinManager, TimeManager timeManager, SimulationOwnership simulationOwnership, Terrain terrain) { this.packetSender = packetSender; @@ -61,7 +69,7 @@ public Entities(IPacketSender packetSender, ThrottledPacketSender throttledPacke entitySpawnersByType[typeof(PlaceholderGroupWorldEntity)] = entitySpawnersByType[typeof(WorldEntity)]; entitySpawnersByType[typeof(PrefabPlaceholderEntity)] = entitySpawnersByType[typeof(WorldEntity)]; entitySpawnersByType[typeof(EscapePodEntity)] = new EscapePodEntitySpawner(localPlayer); - entitySpawnersByType[typeof(PlayerEntity)] = new PlayerEntitySpawner(playerManager, localPlayer); + entitySpawnersByType[typeof(PlayerEntity)] = new PlayerEntitySpawner(playerManager, localPlayer, this); entitySpawnersByType[typeof(VehicleEntity)] = new VehicleEntitySpawner(); entitySpawnersByType[typeof(SerializedWorldEntity)] = entitySpawnersByType[typeof(WorldEntity)]; entitySpawnersByType[typeof(GlobalRootEntity)] = new GlobalRootEntitySpawner(); @@ -138,7 +146,7 @@ private IEnumerator SpawnNewEntities(bool coldStart = false) deletedEntitiesIds.Clear(); simulationOwnership.ClearNewerSimulations(); EntityPositionBroadcaster.Instance.ClearNotSpawnedEntities(); - + foreach (AbsoluteEntityCell absoluteEntityCell in CellsToSpawn) { terrain.AddFullySpawnedCell(absoluteEntityCell); @@ -157,6 +165,11 @@ public void EnqueueEntitiesToSpawn(List entitiesToEnqueue, List /// Yield returning takes too much time (at least once per IEnumerator branch) and it quickly gets out of hand with long function call hierarchies so /// we want to reduce the amount of yield operations and only skip to the next frame when required (to maintain the FPS). @@ -167,12 +180,7 @@ public void EnqueueEntitiesToSpawn(List entitiesToEnqueue, List public IEnumerator SpawnBatchAsync(List batch, bool forceRespawn = false, bool skipFrames = true) { - // we divide the FPS by 2.5 because we consider (time for 1 frame + spawning time without a frame + extra computing time) - float allottedTimePerFrame = 0.4f / Application.targetFrameRate; - float timeLimit = Time.realtimeSinceStartup + allottedTimePerFrame; - - TaskResult> entityResult = new(); - TaskResult exception = new(); + RefreshTimeUntilNextYield(); while (batch.Count > 0) { @@ -189,12 +197,11 @@ public IEnumerator SpawnBatchAsync(List batch, bool forceRespawn = false } if (WasAlreadySpawned(entity) && !forceRespawn) { - UpdateEntity(entity); continue; } else if (entity.ParentId != null && !IsParentReady(entity.ParentId)) { - AddPendingParentEntity(entity); + Log.ErrorOnce($"[{nameof(Entities)}] Could not find parent {entity.ParentId} when spawning {entity.Id}"); continue; } @@ -221,35 +228,29 @@ public IEnumerator SpawnBatchAsync(List batch, bool forceRespawn = false continue; } - entityMetadataManager.ApplyMetadata(entityResult.Get().Value, entity.Metadata); + OnEntitySpawned(entity, entityResult.Get().Value); simulationOwnership.ApplyNewerSimulation(entity.Id); - MarkAsSpawned(entity); - - // Finding out about all children (can be hidden in the object's hierarchy or in a pending list) - if (!entitySpawner.SpawnsOwnChildren(entity)) { batch.AddRange(entity.ChildEntities); - - List childrenIds = entity.ChildEntities.Select(entity => entity.Id).ToList(); - if (pendingParentEntitiesByParentId.TryGetValue(entity.Id, out List pendingEntities)) - { - IEnumerable childrenToAdd = pendingEntities.Where(e => !childrenIds.Contains(e.Id)); - batch.AddRange(childrenToAdd); - pendingParentEntitiesByParentId.Remove(entity.Id); - } } // Skip a frame to maintain FPS - if (Time.realtimeSinceStartup >= timeLimit && skipFrames) + if (ShouldSkipFrame && skipFrames) { yield return new WaitForEndOfFrame(); - timeLimit = Time.realtimeSinceStartup + allottedTimePerFrame; + RefreshTimeUntilNextYield(); } } } + public void OnEntitySpawned(Entity entity, GameObject gameObject) + { + entityMetadataManager.ApplyMetadata(entityResult.Get().Value, entity.Metadata); + MarkAsSpawned(entity); + } + public IEnumerator SpawnEntityAsync(Entity entity, bool forceRespawn = false, bool skipFrames = false) { return SpawnBatchAsync(new() { entity }, forceRespawn, skipFrames); @@ -294,29 +295,6 @@ public static void DestroyObject(GameObject gameObject) UnityEngine.Object.Destroy(gameObject); } - private void UpdateEntity(Entity entity) - { - if (!NitroxEntity.TryGetObjectFrom(entity.Id, out GameObject gameObject)) - { -#if DEBUG && ENTITY_LOG - Log.Error($"Entity was already spawned but not found(is it in another chunk?) NitroxId: {entity.Id} TechType: {entity.TechType} ClassId: {entity.ClassId} Transform: {entity.Transform}"); -#endif - return; - } - entityMetadataManager.ApplyMetadata(gameObject, entity.Metadata); - } - - private void AddPendingParentEntity(Entity entity) - { - if (!pendingParentEntitiesByParentId.TryGetValue(entity.ParentId, out List pendingEntities)) - { - pendingEntities = new List(); - pendingParentEntitiesByParentId[entity.ParentId] = pendingEntities; - } - - pendingEntities.Add(entity); - } - // Entites can sometimes be spawned as one thing but need to be respawned later as another. For example, a flare // spawned inside an Inventory as an InventoryItemEntity can later be dropped in the world as a WorldEntity. Another // example would be a base ghost that needs to be respawned a completed piece. From 75e20653f04d4796c92e2ce245f1fb0ea003ebdb Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:31:42 +0200 Subject: [PATCH 16/23] Fix: try fix remote player race conditions --- .../AnimationChangeEventProcessor.cs | 2 +- ...PlayerJoinedMultiplayerSessionProcessor.cs | 4 +++ .../WorldEntities/PlayerEntitySpawner.cs | 34 +++++++------------ 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/NitroxClient/Communication/Packets/Processors/AnimationChangeEventProcessor.cs b/NitroxClient/Communication/Packets/Processors/AnimationChangeEventProcessor.cs index 67eae68222..e96c50157b 100644 --- a/NitroxClient/Communication/Packets/Processors/AnimationChangeEventProcessor.cs +++ b/NitroxClient/Communication/Packets/Processors/AnimationChangeEventProcessor.cs @@ -37,7 +37,7 @@ IEnumerator Coroutine() private void UpdateAnimation(AnimationChangeEvent animEvent) { Optional opPlayer = remotePlayerManager.Find(animEvent.SessionId); - if (opPlayer.HasValue) + if (opPlayer.HasValue && opPlayer.Value.AnimationController) { PlayerAnimation playerAnimation = animEvent.Animation; opPlayer.Value.UpdateAnimationAndCollider(playerAnimation.Type, playerAnimation.State); diff --git a/NitroxClient/Communication/Packets/Processors/PlayerJoinedMultiplayerSessionProcessor.cs b/NitroxClient/Communication/Packets/Processors/PlayerJoinedMultiplayerSessionProcessor.cs index d7321a705c..e9e452b376 100644 --- a/NitroxClient/Communication/Packets/Processors/PlayerJoinedMultiplayerSessionProcessor.cs +++ b/NitroxClient/Communication/Packets/Processors/PlayerJoinedMultiplayerSessionProcessor.cs @@ -2,6 +2,8 @@ using Nitrox.Model.Subnautica.Packets; using NitroxClient.Communication.Packets.Processors.Core; using NitroxClient.GameLogic; +using NitroxClient.MonoBehaviours; +using UnityEngine; using UWE; namespace NitroxClient.Communication.Packets.Processors; @@ -19,6 +21,8 @@ public Task Process(ClientProcessorContext context, PlayerJoinedMultiplayerSessi private IEnumerator SpawnRemotePlayer(PlayerJoinedMultiplayerSession packet) { + yield return new WaitUntil(() => Multiplayer.Main && Multiplayer.Main.InitialSyncCompleted); + playerManager.Create(packet.PlayerContext); yield return entities.SpawnEntityAsync(packet.PlayerEntity, true, true); diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/PlayerEntitySpawner.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/PlayerEntitySpawner.cs index f3ef5836a3..36681d591b 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/PlayerEntitySpawner.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/PlayerEntitySpawner.cs @@ -1,37 +1,26 @@ using System.Collections; +using Nitrox.Model.DataStructures; +using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; using NitroxClient.GameLogic.PlayerLogic.PlayerModel.Abstract; using NitroxClient.GameLogic.Spawning.Abstract; using NitroxClient.MonoBehaviours; -using Nitrox.Model.DataStructures; -using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; using UnityEngine; namespace NitroxClient.GameLogic.Spawning.WorldEntities; -public class PlayerEntitySpawner : SyncEntitySpawner +public class PlayerEntitySpawner(PlayerManager playerManager, ILocalNitroxPlayer localPlayer, Entities entities) : EntitySpawner { - private readonly PlayerManager playerManager; - private readonly ILocalNitroxPlayer localPlayer; - - public PlayerEntitySpawner(PlayerManager playerManager, ILocalNitroxPlayer localPlayer) - { - this.playerManager = playerManager; - this.localPlayer = localPlayer; - } + private readonly PlayerManager playerManager = playerManager; + private readonly ILocalNitroxPlayer localPlayer = localPlayer; + private readonly Entities entities = entities; protected override IEnumerator SpawnAsync(PlayerEntity entity, TaskResult> result) - { - SpawnSync(entity, result); - return null; - } - - protected override bool SpawnSync(PlayerEntity entity, TaskResult> result) { if (Player.main.TryGetNitroxId(out NitroxId localPlayerId) && localPlayerId == entity.Id) { - // No special setup for the local player. Simply return saying it is spawned. + yield return entities.SpawnBatchAsync(entity.ChildEntities, true, true); result.Set(Player.main.gameObject); - return true; + yield break; } Optional remotePlayer = playerManager.Find(entity.Id); @@ -42,7 +31,7 @@ protected override bool SpawnSync(PlayerEntity entity, TaskResult false; + protected override bool SpawnsOwnChildren(PlayerEntity entity) => true; private GameObject CloneLocalPlayerBodyPrototype() { From 64294d9954652f009a9770157cf21faa6369440a Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:01:27 +0200 Subject: [PATCH 17/23] Fix certain entities dispawning because of wrong LargeWorldEntity registration --- NitroxClient/GameLogic/Entities.cs | 2 +- .../DefaultWorldEntitySpawner.cs | 41 +++++++------------ .../PlaceholderGroupWorldEntitySpawner.cs | 6 +-- .../WorldEntities/ReefbackEntitySpawner.cs | 12 +++--- .../WorldEntitySpawnerResolver.cs | 2 +- 5 files changed, 24 insertions(+), 39 deletions(-) diff --git a/NitroxClient/GameLogic/Entities.cs b/NitroxClient/GameLogic/Entities.cs index d16adaca26..941b934b5c 100644 --- a/NitroxClient/GameLogic/Entities.cs +++ b/NitroxClient/GameLogic/Entities.cs @@ -247,7 +247,7 @@ public IEnumerator SpawnBatchAsync(List batch, bool forceRespawn = false public void OnEntitySpawned(Entity entity, GameObject gameObject) { - entityMetadataManager.ApplyMetadata(entityResult.Get().Value, entity.Metadata); + entityMetadataManager.ApplyMetadata(gameObject, entity.Metadata); MarkAsSpawned(entity); } diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/DefaultWorldEntitySpawner.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/DefaultWorldEntitySpawner.cs index b3d2b1d55c..83ac8fa4ee 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/DefaultWorldEntitySpawner.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/DefaultWorldEntitySpawner.cs @@ -23,12 +23,12 @@ public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, E yield return CreateGameObject(techType, entity.ClassId, entity.Id, gameObjectResult); GameObject gameObject = gameObjectResult.Get(); - SetupObject(entity, parent, gameObject, cellRoot, techType, true); + SetupObject(entity, parent, gameObject, techType, true); result.Set(Optional.Of(gameObject)); } - public void SetupObject(WorldEntity entity, Optional parent, GameObject gameObject, EntityCell cellRoot, TechType techType, bool setupLargeWorldEntity) + public static void SetupObject(WorldEntity entity, Optional parent, GameObject gameObject, TechType techType, bool registerLargeWorldEntity) { gameObject.transform.position = entity.Transform.Position.ToUnity(); gameObject.transform.rotation = entity.Transform.Rotation.ToUnity(); @@ -46,37 +46,24 @@ public void SetupObject(WorldEntity entity, Optional parent, GameObj if (largeWorldEntity) { largeWorldEntity.cellLevel = (LargeWorldEntity.CellLevel)entity.Level; - } - if (!parentWaterPark && setupLargeWorldEntity) - { - if (parent.HasValue && !parent.Value.GetComponent()) - { - LargeWorldEntity.Register(gameObject); // This calls SetActive on the GameObject - } - else if (largeWorldEntity && !gameObject.transform.parent && cellRoot.liveRoot) + // LargeWorldEntity.Start unregisters itself if there is a parent which has a LargeWorldEntity + // thus we detect the opposite case to call Register + if (registerLargeWorldEntity && (!parent.HasValue || !parent.Value.GetComponentInParent())) { - gameObject.transform.SetParent(cellRoot.liveRoot.transform, true); LargeWorldEntity.Register(gameObject); } - else - { - gameObject.SetActive(true); - } } - if (parent.HasValue) + if (parentWaterPark && gameObject.TryGetComponent(out Pickupable pickupable)) { - if (parentWaterPark && gameObject.TryGetComponent(out Pickupable pickupable)) - { - pickupable.SetVisible(false); - pickupable.Activate(false); - parentWaterPark.AddItem(pickupable); - } - else - { - gameObject.transform.SetParent(parent.Value.transform, true); - } + pickupable.SetVisible(false); + pickupable.Activate(false); + parentWaterPark.AddItem(pickupable); + } + else if (parent.HasValue) + { + gameObject.transform.SetParent(parent.Value.transform, true); } } @@ -205,7 +192,7 @@ public bool SpawnSync(WorldEntity entity, Optional parent, EntityCel if (TryCreateGameObjectSync(techType, entity.ClassId, entity.Id, out GameObject gameObject)) { - SetupObject(entity, parent, gameObject, cellRoot, techType, true); + SetupObject(entity, parent, gameObject, techType, true); result.Set(gameObject); return true; } diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/PlaceholderGroupWorldEntitySpawner.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/PlaceholderGroupWorldEntitySpawner.cs index 230d5019ec..7888117854 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/PlaceholderGroupWorldEntitySpawner.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/PlaceholderGroupWorldEntitySpawner.cs @@ -38,8 +38,7 @@ public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, E LargeWorldEntity largeWorldEntity = groupObject.GetComponent(); PrefabPlaceholdersGroup prefabPlaceholderGroup = groupObject.GetComponent(); - defaultWorldEntitySpawner.SetupObject(entity, parent, groupObject, cellRoot, entity.TechType.ToUnity(), false); - + DefaultWorldEntitySpawner.SetupObject(entity, parent, groupObject, entity.TechType.ToUnity(), false); // Prevent the entity from disappearing because of parent cell going to sleep until it's fully spawned if (!parent.HasValue) @@ -95,9 +94,8 @@ public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, E } GameObject childObject = childResult.value.Value; - entities.MarkAsSpawned(current); + entities.OnEntitySpawned(current, childObject); parentById[current.Id] = childObject; - entityMetadataManager.ApplyMetadata(childObject, current.Metadata); // PlaceholderGroupWorldEntity's children spawning is already handled by this function which is called recursively if (current is not PlaceholderGroupWorldEntity) diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackEntitySpawner.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackEntitySpawner.cs index 524ae61e97..0dcb702141 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackEntitySpawner.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/ReefbackEntitySpawner.cs @@ -1,15 +1,15 @@ using System; using System.Collections; +using System.Linq; using Nitrox.Model.DataStructures; using Nitrox.Model.Subnautica.DataStructures.GameLogic.Entities; using UnityEngine; namespace NitroxClient.GameLogic.Spawning.WorldEntities; -public class ReefbackEntitySpawner(ReefbackChildEntitySpawner reefbackChildEntitySpawner, DefaultWorldEntitySpawner defaultWorldEntitySpawner, Entities entities) : IWorldEntitySpawner +public class ReefbackEntitySpawner(ReefbackChildEntitySpawner reefbackChildEntitySpawner, Entities entities) : IWorldEntitySpawner { private readonly ReefbackChildEntitySpawner reefbackChildEntitySpawner = reefbackChildEntitySpawner; - private readonly DefaultWorldEntitySpawner defaultWorldEntitySpawner = defaultWorldEntitySpawner; private readonly Entities entities = entities; public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, EntityCell cellRoot, TaskResult> result) @@ -32,10 +32,10 @@ public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, E // Prevent the entity from disappearing because of parent cell going to sleep until it's fully spawned largeWorldEntity.enabled = false; - SetupObject(reefbackEntity, reefbackObject, cellRoot, reefbackLife); + SetupObject(reefbackEntity, reefbackObject, reefbackLife); TaskResult> childTaskResult = new(); - foreach (ReefbackChildEntity reefbackChildEntity in entity.ChildEntities) + foreach (ReefbackChildEntity reefbackChildEntity in entity.ChildEntities.Cast()) { reefbackChildEntitySpawner.SpawnSync(reefbackChildEntity, reefbackObject, cellRoot, childTaskResult); @@ -59,9 +59,9 @@ public IEnumerator SpawnAsync(WorldEntity entity, Optional parent, E public bool SpawnsOwnChildren() => true; - private void SetupObject(ReefbackEntity entity, GameObject gameObject, EntityCell cellRoot, ReefbackLife reefbackLife) + private static void SetupObject(ReefbackEntity entity, GameObject gameObject, ReefbackLife reefbackLife) { - defaultWorldEntitySpawner.SetupObject(entity, Optional.Empty, gameObject, cellRoot, entity.TechType.ToUnity(), false); + DefaultWorldEntitySpawner.SetupObject(entity, Optional.Empty, gameObject, entity.TechType.ToUnity(), false); // Replicate only the useful parts of ReefbackLife.Initialize reefbackLife.initialized = true; diff --git a/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs b/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs index 800baa71f4..b41936b199 100644 --- a/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs +++ b/NitroxClient/GameLogic/Spawning/WorldEntities/WorldEntitySpawnerResolver.cs @@ -27,7 +27,7 @@ public WorldEntitySpawnerResolver(EntityMetadataManager entityMetadataManager, E serializedWorldEntitySpawner = new SerializedWorldEntitySpawner(); geyserWorldEntitySpawner = new GeyserWorldEntitySpawner(entities); reefbackChildEntitySpawner = new ReefbackChildEntitySpawner(); - reefbackEntitySpawner = new ReefbackEntitySpawner(reefbackChildEntitySpawner, defaultEntitySpawner, entities); + reefbackEntitySpawner = new ReefbackEntitySpawner(reefbackChildEntitySpawner, entities); creatureRespawnEntitySpawner = new CreatureRespawnEntitySpawner(simulationOwnership); } From 5711784defd20318af77629a57c851ab073e144f Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:01:57 +0200 Subject: [PATCH 18/23] Feat: allow simulation of underwater rocks (those with floaters) --- Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs b/Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs index cf92b7853b..a1722cdde4 100644 --- a/Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs +++ b/Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs @@ -11,7 +11,7 @@ public static class SimulationWhitelist /// It is easier to maintain a list of items we should simulate than try to blacklist items. This list should not be checked for non-server spawned items /// as they were probably dropped by the player and are mostly guaranteed to move. /// - private static readonly HashSet movementWhitelist = + private static readonly HashSet movementTechTypeWhitelist = [ TechType.Shocker.ToDto(), TechType.Biter.ToDto(), @@ -74,6 +74,11 @@ public static class SimulationWhitelist TechType.PipeSurfaceFloater.ToDto() ]; + private static readonly HashSet movementClassIdWhitelist = [ + "f895696c-cdc6-4427-a87f-2b62666ea0cb", // FloatingStone4_Floaters + "9a9cdb4e-f110-412d-b16b-b9ace904b569" // FloatingStone5_Floaters + ]; + /// /// We differentiate the entities which should be simulated because of one of their behaviour (ie for utility) /// from those are simulated for their movements. @@ -90,6 +95,6 @@ public static bool ShouldSimulateEntity(WorldEntity entity) public static bool ShouldSimulateEntityMovement(WorldEntity entity) { - return !entity.SpawnedByServer || movementWhitelist.Contains(entity.TechType); + return !entity.SpawnedByServer || movementTechTypeWhitelist.Contains(entity.TechType) || movementClassIdWhitelist.Contains(entity.ClassId); } } From f9471daec3d677be0a3903bb0294e2787fe37d02 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:03:15 +0200 Subject: [PATCH 19/23] Refactoring according to reviews --- .../Helper/SimulationWhitelist.cs | 7 ++--- .../GameLogic/Entities/EntitySimulation.cs | 7 +++++ .../GameLogic/Entities/WorldEntityManager.cs | 31 +++++++++---------- .../CellVisibilityChangedProcessor.cs | 6 ++-- NitroxClient/GameLogic/Entities.cs | 9 +++--- .../PrefabLoadInitialSyncProcessor.cs | 6 +++- NitroxClient/GameLogic/SimulationOwnership.cs | 4 +-- .../EntityPositionBroadcaster.cs | 2 +- .../LargeWorldEntity_UpdateCell_Patch.cs | 2 +- 9 files changed, 41 insertions(+), 33 deletions(-) diff --git a/Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs b/Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs index a1722cdde4..36e087d723 100644 --- a/Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs +++ b/Nitrox.Model.Subnautica/Helper/SimulationWhitelist.cs @@ -83,10 +83,9 @@ public static class SimulationWhitelist /// We differentiate the entities which should be simulated because of one of their behaviour (ie for utility) /// from those are simulated for their movements. /// - private static readonly HashSet utilityWhitelist = new() - { - TechType.CrashHome.ToDto() - }; + private static readonly HashSet utilityWhitelist = [ + TechType.CrashHome.ToDto(), + ]; public static bool ShouldSimulateEntity(WorldEntity entity) { diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs index 6bff1441a8..106efc2d85 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs +++ b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/EntitySimulation.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Nitrox.Model.Core; using Nitrox.Model.DataStructures; using Nitrox.Model.Subnautica.DataStructures.GameLogic; @@ -176,6 +177,12 @@ private List CalculateSimulationChangesFromPlayerDisconnect(Ses public void AssignEntitiesToOtherPlayers(SessionId oldSessionId, IEnumerable entities, List ownershipChanges) { + // In case the enumerator can be counted (e.g. a list) + if (entities.TryGetNonEnumeratedCount(out int count)) + { + ownershipChanges.EnsureCapacity(ownershipChanges.Count + count); + } + // TODO: (optional) Find out if ordering the otherPlayers by distance to the previous simulator improves performance (ascending) List otherPlayers = playerManager.GetConnectedPlayersExcept(oldSessionId); foreach (Entity entity in entities) diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/WorldEntityManager.cs b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/WorldEntityManager.cs index e3dbc9d71f..474fd38b90 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/WorldEntityManager.cs +++ b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/WorldEntityManager.cs @@ -92,11 +92,11 @@ public IEnumerable EnumerateCellEntities(AbsoluteEntityCell cell) lock (worldEntitiesLock) { - if (worldEntitiesByCell.TryGetValue(cell, out Dictionary batchEntites)) + if (worldEntitiesByCell.TryGetValue(cell, out Dictionary batchEntities)) { - count = batchEntites.Count; + count = batchEntities.Count; array = ArrayPool.Shared.Rent(count); - batchEntites.Values.CopyTo(array, 0); + batchEntities.Values.CopyTo(array, 0); } } @@ -119,7 +119,7 @@ public IEnumerable EnumerateCellEntities(AbsoluteEntityCell cell) } } - public void FillEntitiesNonAlloc(AbsoluteEntityCell cell, List targetList) + public void GetCellEntitiesNonAlloc(AbsoluteEntityCell cell, List targetList) { lock (worldEntitiesLock) { @@ -224,15 +224,15 @@ public void RegisterWorldEntity(WorldEntity entity) public bool RegisterWorldEntityInCell(WorldEntity entity, AbsoluteEntityCell cell) { - lock (worldEntitiesLock) + if (entity.ParentId != null) { - if (entity.ParentId != null) - { - // entities parented to a WorldEntity most likely have their LargeWorldEntity component disabled, which means they - // will only disappear once their parent disappears, thus we do not need to hold them in a cell - return false; - } + // entities parented to a WorldEntity most likely have their LargeWorldEntity component disabled, which means they + // will only disappear once their parent disappears, thus we do not need to hold them in a cell + return false; + } + lock (worldEntitiesLock) + { if (!worldEntitiesByCell.TryGetValue(cell, out Dictionary worldEntitiesInCell)) { worldEntitiesInCell = worldEntitiesByCell[cell] = []; @@ -297,11 +297,10 @@ private async Task LoadAndRegisterBatchInternalAsync(NitroxInt3 batchId, bo { List spawnedEntities = await batchEntitySpawner.LoadUnspawnedEntitiesAsync(batchId, suppressLogs); - List entitiesInCells = spawnedEntities.Where(entity => typeof(WorldEntity).IsAssignableFrom(entity.GetType()) && + List entitiesInCells = [.. spawnedEntities.Where(entity => typeof(WorldEntity).IsAssignableFrom(entity.GetType()) && entity.GetType() != typeof(CellRootEntity) && entity.GetType() != typeof(GlobalRootEntity)) - .Cast() - .ToList(); + .Cast()]; // UWE stores entities serialized with a handful of parent cell roots. These only represent a small fraction of all possible cell // roots that could exist. There is no reason for the server to know about these and much easier to consider top-level world entities @@ -315,12 +314,12 @@ private async Task LoadAndRegisterBatchInternalAsync(NitroxInt3 batchId, bo entitiesInCells.Add(worldEntity); } - cellRoot.ChildEntities = new List(); + cellRoot.ChildEntities.Clear(); } // Specific type of entities which is not parented to a CellRootEntity entitiesInCells.AddRange(spawnedEntities.OfType()); - entityRegistry.AddEntitiesIgnoringDuplicate(entitiesInCells.OfType().ToList()); + entityRegistry.AddEntitiesIgnoringDuplicate(entitiesInCells.OfType()); foreach (WorldEntity entity in entitiesInCells) { diff --git a/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs b/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs index 843b98bd97..458efc6f95 100644 --- a/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs +++ b/Nitrox.Server.Subnautica/Models/Packets/Processors/CellVisibilityChangedProcessor.cs @@ -16,8 +16,8 @@ public async Task Process(AuthProcessorContext context, CellVisibilityChanged pa context.Sender.AddCells(packet.Added); context.Sender.RemoveCells(packet.Removed); - List totalEntities = new(32); - List simulationChanges = new(32); + List totalEntities = []; + List simulationChanges = []; foreach (AbsoluteEntityCell removedCell in packet.Removed) { @@ -38,7 +38,7 @@ public async Task Process(AuthProcessorContext context, CellVisibilityChanged pa simulationChanges.AddRange(entitySimulation.TryAcquireCellEntities(context.Sender, addedCell)); - worldEntityManager.FillEntitiesNonAlloc(addedCell, totalEntities); + worldEntityManager.GetCellEntitiesNonAlloc(addedCell, totalEntities); } // no need to broadcast other simulation changes because a player loading part of the world can only be given transient lock diff --git a/NitroxClient/GameLogic/Entities.cs b/NitroxClient/GameLogic/Entities.cs index 941b934b5c..da18307f56 100644 --- a/NitroxClient/GameLogic/Entities.cs +++ b/NitroxClient/GameLogic/Entities.cs @@ -41,12 +41,11 @@ public class Entities /// /// We divide the FPS by 2.5 because we consider (time for 1 frame + spawning time without a frame + extra computing time). /// - private static float allottedTimePerFrame => 0.4f / Application.targetFrameRate; + private static float allottedTimePerFrameSeconds => 0.4f / Application.targetFrameRate; private readonly TaskResult> entityResult = new(); private readonly TaskResult exception = new(); - private float timeUntilNextYield; - public bool ShouldSkipFrame => Time.realtimeSinceStartup >= timeUntilNextYield; - + private float timeUntilNextYieldSeconds; + public bool ShouldSkipFrame => Time.realtimeSinceStartup >= timeUntilNextYieldSeconds; public Entities(IPacketSender packetSender, ThrottledPacketSender throttledPacketSender, EntityMetadataManager entityMetadataManager, PlayerManager playerManager, LocalPlayer localPlayer, LiveMixinManager liveMixinManager, TimeManager timeManager, SimulationOwnership simulationOwnership, Terrain terrain) { @@ -167,7 +166,7 @@ public void EnqueueEntitiesToSpawn(List entitiesToEnqueue, List diff --git a/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs b/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs index d6044909f7..4c2198e5af 100644 --- a/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs +++ b/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs @@ -9,7 +9,11 @@ namespace NitroxClient.GameLogic.InitialSync; -public sealed class PrefabLoadInitialSyncProcessor : InitialSyncProcessor +/// +/// Ensures all prefabs are loaded before any spawning happens in-game. This allows to spawn prefabs without yielding which is a +/// huge plus when spawning an object that is related to a critical code path (in batteries code for example). +/// +internal sealed class PrefabLoadInitialSyncProcessor : InitialSyncProcessor { private static bool loadStarted; private static readonly Queue<(string, TechType)> loadQueue = new(PrefabDatabase.prefabFiles.Keys.Count); diff --git a/NitroxClient/GameLogic/SimulationOwnership.cs b/NitroxClient/GameLogic/SimulationOwnership.cs index a31a10e331..ea47a2b23b 100644 --- a/NitroxClient/GameLogic/SimulationOwnership.cs +++ b/NitroxClient/GameLogic/SimulationOwnership.cs @@ -117,7 +117,7 @@ public void TreatSimulatedEntity(SimulatedEntity simulatedEntity) Object.Destroy(remotelyControlled); } - TreatReefback(simulatedEntity.Id, isLocalPlayerNewOwner); + PropagateReefbackOwnership(simulatedEntity.Id, isLocalPlayerNewOwner); } /// @@ -158,7 +158,7 @@ public bool TryGetLockType(NitroxId nitroxId, out SimulationLockType simulationL /// Reefbacks host creatures as children. These creatures have their LargeWorldEntity disabled, which means they only dispawn when the parent Reefback dispawns. /// Thus they're are so connected to it that we can assume taking ownership on a Reefback is equivalent to taking it on its children. /// - private void TreatReefback(NitroxId entityId, bool isSimulating) + private void PropagateReefbackOwnership(NitroxId entityId, bool isSimulating) { if (!NitroxEntity.TryGetComponentFrom(entityId, out ReefbackLife reefbackLife)) { diff --git a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs index 0dc2e575f4..0633d55298 100644 --- a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs +++ b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs @@ -219,7 +219,7 @@ public void RemoveEntityMovementControl(GameObject gameObject, NitroxId entityId /// /// Notifies the server of the latest known position of this entity if the local player is simulating it. /// - public void BroadcastLastUpdate(GameObject gameObject) + public void SendLastUpdateAndDropOwnership(GameObject gameObject) { if (!gameObject.TryGetNitroxId(out NitroxId entityId) || !simulationOwnership.HasAnyLockType(entityId)) { diff --git a/NitroxPatcher/Patches/Dynamic/LargeWorldEntity_UpdateCell_Patch.cs b/NitroxPatcher/Patches/Dynamic/LargeWorldEntity_UpdateCell_Patch.cs index 77f46f41ed..0c1600f14b 100644 --- a/NitroxPatcher/Patches/Dynamic/LargeWorldEntity_UpdateCell_Patch.cs +++ b/NitroxPatcher/Patches/Dynamic/LargeWorldEntity_UpdateCell_Patch.cs @@ -60,7 +60,7 @@ private static void EntityUpdated(bool success, LargeWorldEntity largeWorldEntit } // We need to send a last position update in case we're simulating the entity so that at least the server knows where we left it - EntityPositionBroadcaster.Instance.BroadcastLastUpdate(entityObject); + EntityPositionBroadcaster.Instance.SendLastUpdateAndDropOwnership(entityObject); // Entity was added to a sleeping cell so it's as if it was removed GameObject.Destroy(entityObject); From f81105041c5ad2110ed614fec8b0707de0a81b38 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:16:43 +0200 Subject: [PATCH 20/23] Set PrefabLoadInitialSyncProcessor to be public so it can be accessed from patch --- .../GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs b/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs index 4c2198e5af..804ada6b8d 100644 --- a/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs +++ b/NitroxClient/GameLogic/InitialSync/PrefabLoadInitialSyncProcessor.cs @@ -13,7 +13,7 @@ namespace NitroxClient.GameLogic.InitialSync; /// Ensures all prefabs are loaded before any spawning happens in-game. This allows to spawn prefabs without yielding which is a /// huge plus when spawning an object that is related to a critical code path (in batteries code for example). /// -internal sealed class PrefabLoadInitialSyncProcessor : InitialSyncProcessor +public sealed class PrefabLoadInitialSyncProcessor : InitialSyncProcessor { private static bool loadStarted; private static readonly Queue<(string, TechType)> loadQueue = new(PrefabDatabase.prefabFiles.Keys.Count); From 3861f6ddfddf3a0098088a5e961f10d960bc1e5a Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:35:58 +0200 Subject: [PATCH 21/23] Fix entity spawn misplacements issues --- .../Entities/Spawning/BatchEntitySpawner.cs | 4 +- .../Resources/Core/SubnauticaAssetsManager.cs | 48 +++++++++++++------ .../PrefabPlaceholderGroupsResource.cs | 6 +-- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/BatchEntitySpawner.cs b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/BatchEntitySpawner.cs index bfdcfd150d..2d1c2590b6 100644 --- a/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/BatchEntitySpawner.cs +++ b/Nitrox.Server.Subnautica/Models/GameLogic/Entities/Spawning/BatchEntitySpawner.cs @@ -405,7 +405,7 @@ private async Task> SpawnEntitiesAsync(List entit prefabClassId = randomAsset.ClassIds[randomIndex]; } - EntitySpawnPoint esp = new(entity.AbsoluteEntityCell, prefabAsset.Transform.LocalPosition, prefabAsset.Transform.LocalRotation, prefabAsset.Transform.LocalScale, prefabClassId); + EntitySpawnPoint esp = new(entity.AbsoluteEntityCell, prefabAsset.Transform.Position, prefabAsset.Transform.Rotation, prefabAsset.Transform.LocalScale, prefabClassId); WorldEntity spawnedEntity = (WorldEntity)(await SpawnEntitiesStaticallyAsync(esp, deterministicBatchGenerator, entity)).First(); if (prefabAsset is PrefabPlaceholdersGroupAsset) { @@ -431,7 +431,7 @@ private async Task> SpawnEntitiesAsync(List entit } List entities = []; - EntitySpawnPoint entitySpawnPoint = new(cell, transform.LocalPosition, transform.LocalRotation, entitySlot.AllowedTypes.ToList(), 1f, entitySlot.BiomeType); + EntitySpawnPoint entitySpawnPoint = new(cell, transform.Position, transform.Rotation, [.. entitySlot.AllowedTypes], 1f, entitySlot.BiomeType); entities.AddRange(await SpawnEntitiesUsingRandomDistributionAsync(entitySpawnPoint, prefabs, deterministicBatchGenerator, parentEntity)); if (entities.Count > 0) { diff --git a/Nitrox.Server.Subnautica/Models/Resources/Core/SubnauticaAssetsManager.cs b/Nitrox.Server.Subnautica/Models/Resources/Core/SubnauticaAssetsManager.cs index 4e67e17450..ab57cd79bd 100644 --- a/Nitrox.Server.Subnautica/Models/Resources/Core/SubnauticaAssetsManager.cs +++ b/Nitrox.Server.Subnautica/Models/Resources/Core/SubnauticaAssetsManager.cs @@ -91,33 +91,53 @@ public AssetsFileInstance LoadBundleWithDependencies(string[] bundlePaths) return monoBehaviourInf; } - public NitroxTransform GetTransformFromGameObject(AssetsFileInstance assetFileInst, AssetTypeValueField rootGameObject, string parentName, bool isEntitySlotAsset) + public NitroxTransform GetTransformFromGameObject(AssetsFileInstance assetFileInst, AssetTypeValueField rootGameObject, string rootName, bool isEntitySlotAsset) { AssetTypeValueField componentArray = rootGameObject["m_Component"]["Array"]; - AssetTypeValueField transformRef = componentArray[0]["component"]; - AssetTypeValueField transformField = GetExtAsset(assetFileInst, transformRef).baseField; + AssetTypeValueField transformPtr = componentArray[0]["component"]; + AssetTypeValueField transformField = GetExtAsset(assetFileInst, transformPtr).baseField; + + NitroxTransform objectNitroxTransform = new(transformField["m_LocalPosition"].ToNitroxVector3(), transformField["m_LocalRotation"].ToNitroxQuaternion(), transformField["m_LocalScale"].ToNitroxVector3()); // We only target entity slots because they spawn entities which aren't directly reparented to the slot's parent, but instead they are put in a CellRoot. // So we need to account for position offsets from the PrefabPlaceholderGroup other than LocalPosition if (isEntitySlotAsset) { - AssetTypeValueField parentTransformPtr = transformField["m_Father"]; - AssetTypeValueField parentTransformField = GetExtAsset(assetFileInst, parentTransformPtr).baseField; - - AssetTypeValueField parentGameObjectPtr = parentTransformField["m_GameObject"]; - AssetTypeValueField parentGameObjectField = GetExtAsset(assetFileInst, parentGameObjectPtr).baseField; + AssetTypeValueField currentTransformField = transformField; + NitroxTransform currentNitroxTransform = objectNitroxTransform; - string gameObjectName = parentGameObjectField["m_Name"].AsString; - // We only add the parent's position offset if the entity slot is not directly under the PrefabPlaceholderGroup - // because the potential source of position offset is an intermediary parent in between - if (!string.Equals(gameObjectName, parentName, StringComparison.OrdinalIgnoreCase)) + // The PrefabPlaceholder can be under multiple layers after the root placeholder group so we go up setp by step + while (true) { - return new(transformField["m_LocalPosition"].ToNitroxVector3() + parentTransformField["m_LocalPosition"].ToNitroxVector3(), transformField["m_LocalRotation"].ToNitroxQuaternion(), transformField["m_LocalScale"].ToNitroxVector3()); + AssetTypeValueField parentTransformPtr = currentTransformField["m_Father"]; + // Stop when we reach scene root + if (parentTransformPtr["m_PathID"].AsLong == 0) + { + break; + } + + AssetTypeValueField parentTransformField = GetExtAsset(assetFileInst, parentTransformPtr).baseField; + AssetTypeValueField parentGameObjectPtr = parentTransformField["m_GameObject"]; + AssetTypeValueField parentGameObjectField = GetExtAsset(assetFileInst, parentGameObjectPtr).baseField; + string gameObjectName = parentGameObjectField["m_Name"].AsString; + + // We only add the parent's position offset if the entity slot is not directly under the PrefabPlaceholderGroup + // because the potential source of position offset is an intermediary parent in between + if (string.Equals(gameObjectName, rootName, StringComparison.OrdinalIgnoreCase)) + { + break; + } + + NitroxTransform parentTransform = new(parentTransformField["m_LocalPosition"].ToNitroxVector3(), parentTransformField["m_LocalRotation"].ToNitroxQuaternion(), parentTransformField["m_LocalScale"].ToNitroxVector3()); + currentNitroxTransform.SetParent(parentTransform, false); + + currentTransformField = parentTransformField; + currentNitroxTransform = parentTransform; } } - return new(transformField["m_LocalPosition"].ToNitroxVector3(), transformField["m_LocalRotation"].ToNitroxQuaternion(), transformField["m_LocalScale"].ToNitroxVector3()); + return objectNitroxTransform; } private new void SetMonoTempGenerator(IMonoBehaviourTemplateGenerator? generator) diff --git a/Nitrox.Server.Subnautica/Models/Resources/Parsers/PrefabPlaceholderGroupsResource.cs b/Nitrox.Server.Subnautica/Models/Resources/Parsers/PrefabPlaceholderGroupsResource.cs index dad60a465c..3d83e1ef77 100644 --- a/Nitrox.Server.Subnautica/Models/Resources/Parsers/PrefabPlaceholderGroupsResource.cs +++ b/Nitrox.Server.Subnautica/Models/Resources/Parsers/PrefabPlaceholderGroupsResource.cs @@ -11,8 +11,8 @@ using Nitrox.Server.Subnautica.Models.Helper; using Nitrox.Server.Subnautica.Models.Resources.AddressablesTools.Catalog; using Nitrox.Server.Subnautica.Models.Resources.Core; -using ClassIdByRuntimeKeyDictionary = System.Collections.Generic.Dictionary; using AddressableCatalogDictionary = System.Collections.Generic.Dictionary; +using ClassIdByRuntimeKeyDictionary = System.Collections.Generic.Dictionary; namespace Nitrox.Server.Subnautica.Models.Resources.Parsers; @@ -457,9 +457,7 @@ private PrefabPlaceholdersGroupAsset GetAndCachePrefabPlaceholdersGroupGroup(Sub AssetTypeValueField databoxSpawner = am.GetBaseField(assetFileInst, databoxSpawnerInfo); string runtimeKey = databoxSpawner["databoxPrefabReference"]["m_AssetGUID"].AsString; - PrefabPlaceholderAsset databoxAsset = new(classIdByRuntimeKey[runtimeKey]); - placeholdersByClassId[classId] = databoxAsset; - return databoxAsset; + return GetAndCacheAsset(am, classIdByRuntimeKey[runtimeKey], addressableCatalog, classIdByRuntimeKey); } AssetFileInfo entitySlotInfo = am.GetMonoBehaviourFromGameObject(assetFileInst, prefabGameObjectInfo, "EntitySlot"); From 3385e1217cd8e8447f1076c087def3e58f498793 Mon Sep 17 00:00:00 2001 From: tornac1234 <24827220+tornac1234@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:39:40 +0200 Subject: [PATCH 22/23] Bump cache version because of modifications to prefab cache contents --- .../Models/Resources/Parsers/PrefabPlaceholderGroupsResource.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Nitrox.Server.Subnautica/Models/Resources/Parsers/PrefabPlaceholderGroupsResource.cs b/Nitrox.Server.Subnautica/Models/Resources/Parsers/PrefabPlaceholderGroupsResource.cs index 3d83e1ef77..a8271a4877 100644 --- a/Nitrox.Server.Subnautica/Models/Resources/Parsers/PrefabPlaceholderGroupsResource.cs +++ b/Nitrox.Server.Subnautica/Models/Resources/Parsers/PrefabPlaceholderGroupsResource.cs @@ -26,7 +26,7 @@ internal sealed class PrefabPlaceholderGroupsResource(SubnauticaAssetsManager as /// the cache is rebuilt /// /// - private const int CACHE_VERSION = 4; + private const int CACHE_VERSION = 5; private const string CACHE_FILENAME = "PrefabPlaceholdersGroupAssetsCache.json"; From e7b82dea393902aeb54da5ac72832041a8927014 Mon Sep 17 00:00:00 2001 From: Measurity <1107063+Measurity@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:51:09 +0200 Subject: [PATCH 23/23] Reduced allocations in EntityPositionBroadcaster --- .../Extensions/DictionaryExtensions.cs | 26 ++++++- Nitrox.Model/Extensions/SetExtensions.cs | 32 +++++++++ .../EntityPositionBroadcaster.cs | 69 ++++++++++--------- 3 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 Nitrox.Model/Extensions/SetExtensions.cs diff --git a/Nitrox.Model/Extensions/DictionaryExtensions.cs b/Nitrox.Model/Extensions/DictionaryExtensions.cs index 7903c6dc28..4cfb85344f 100644 --- a/Nitrox.Model/Extensions/DictionaryExtensions.cs +++ b/Nitrox.Model/Extensions/DictionaryExtensions.cs @@ -38,7 +38,31 @@ public static void RemoveWhere(this IDictionary dict { foreach (KeyValuePair item in dictionary) { - if (predicate.Invoke(item)) + if (predicate(item)) + { + toRemove[toRemoveIndex++] = item.Key; + } + } + for (int i = 0; i < toRemoveIndex; i++) + { + dictionary.Remove(toRemove[i]); + } + } + finally + { + ArrayPool.Shared.Return(toRemove, true); + } + } + + public static void RemoveWhere(this IDictionary dictionary, TParam parameter, Func, bool> predicate) + { + int toRemoveIndex = 0; + TKey[] toRemove = ArrayPool.Shared.Rent(dictionary.Count); + try + { + foreach (KeyValuePair item in dictionary) + { + if (predicate(parameter, item)) { toRemove[toRemoveIndex++] = item.Key; } diff --git a/Nitrox.Model/Extensions/SetExtensions.cs b/Nitrox.Model/Extensions/SetExtensions.cs new file mode 100644 index 0000000000..13c5b8533d --- /dev/null +++ b/Nitrox.Model/Extensions/SetExtensions.cs @@ -0,0 +1,32 @@ +using System; +using System.Buffers; +using System.Collections.Generic; + +namespace Nitrox.Model.Extensions; + +public static class SetExtensions +{ + public static void RemoveWhere(this ISet self, TParam parameter, Func predicate) + { + int toRemoveIndex = 0; + T[] toRemove = ArrayPool.Shared.Rent(self.Count); + try + { + foreach (T item in self) + { + if (predicate(parameter, item)) + { + toRemove[toRemoveIndex++] = item; + } + } + for (int i = 0; i < toRemoveIndex; i++) + { + self.Remove(toRemove[i]); + } + } + finally + { + ArrayPool.Shared.Return(toRemove, true); + } + } +} diff --git a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs index 0633d55298..b8230007c4 100644 --- a/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs +++ b/NitroxClient/MonoBehaviours/EntityPositionBroadcaster.cs @@ -2,42 +2,47 @@ using Nitrox.Model.DataStructures; using Nitrox.Model.Subnautica.Packets; using NitroxClient.Communication.Abstract; +using NitroxClient.Communication.NetworkingLayer.LiteNetLib; using NitroxClient.GameLogic; using UnityEngine; using static Nitrox.Model.Subnautica.Packets.EntityTransformUpdates; namespace NitroxClient.MonoBehaviours; -public class EntityPositionBroadcaster : MonoBehaviour +public sealed class EntityPositionBroadcaster : MonoBehaviour { public static EntityPositionBroadcaster Instance; /// - /// The time between two broadcasts in seconds. + /// The time between two broadcasts in seconds. /// public static readonly float BROADCAST_INTERVAL = 0.1f; /// - /// Dictionary of watched entities that don't follow spline movements. + /// Set of watched entities that weren't spawned yet. /// - private readonly Dictionary regularEntities = []; + private readonly HashSet notSpawnedEntityIds = []; + /// - /// Dictionary of watched entities that follow spline movements. + /// Dictionary of watched entities that don't follow spline movements. /// - private readonly Dictionary splineEntities = []; + private readonly Dictionary regularEntities = []; + /// - /// Set of watched entities that weren't spawned yet. + /// Dictionary of watched entities that follow spline movements. /// - private readonly HashSet notSpawnedEntityIds = []; + private readonly Dictionary splineEntities = []; + /// - /// Latest registered spline updates from SplineFollowing.GoTo + /// Latest registered spline updates from SplineFollowing.GoTo /// private readonly Dictionary splineUpdatesById = []; + /// - /// Reusable list of s to avoid reallocating a new list at each broadcast. + /// Reusable list of s to avoid reallocating a new list at each broadcast. /// /// - /// This only works because immediately serializes the list. + /// This only works because immediately serializes the list. /// private readonly List updates = new(50); @@ -69,7 +74,7 @@ public void Update() { time = 0; - CheckEntities(); + ReassignEntitiesToLookups(); BuildUpdates(); if (updates.Count > 0) @@ -101,7 +106,7 @@ private void BuildUpdates() updates.Add(splineUpdate); } } - + splineUpdatesById.Clear(); } @@ -110,10 +115,10 @@ public void WatchEntity(NitroxId id) // The game object may not exist at this very moment (due to being spawned in async). This is OK as we will // automatically start sending updates when we finally get it in the world. This behavior will also allow us // to resync or respawn entities while still have broadcasting enabled without doing anything extra. - + if (NitroxEntity.TryGetObjectFrom(id, out GameObject entityObject)) { - SortEntity(id, entityObject); + AddEntityToLookup(id, entityObject); } else { @@ -121,7 +126,7 @@ public void WatchEntity(NitroxId id) } } - private void SortEntity(NitroxId nitroxId, GameObject entityObject) + private void AddEntityToLookup(NitroxId nitroxId, GameObject entityObject) { if (entityObject.TryGetComponent(out SwimBehaviour swimBehaviour) && swimBehaviour.enabled) { @@ -134,41 +139,40 @@ private void SortEntity(NitroxId nitroxId, GameObject entityObject) if (entityObject.TryGetComponent(out RemotelyControlled remotelyControlled)) { - Object.Destroy(remotelyControlled); + Destroy(remotelyControlled); } } /// - /// For each tracked entity, ensures it stays in the right HashSet/Dictionary depending on its state. - /// Either the entity has not spawned () or it follows a spline () - /// or in the default case (). + /// For each tracked entity, ensures it stays in the right HashSet/Dictionary depending on its state. + /// Either the entity has not spawned () or it follows a spline ( + /// ) + /// or in the default case (). /// - private void CheckEntities() + private void ReassignEntitiesToLookups() { // when fishes die, they're only a corpse and their swim behaviour stops functioning - splineEntities.RemoveWhere(pair => + splineEntities.RemoveWhere(this, static (self, pair) => { SwimBehaviour swimBehaviour = pair.Value; - if (!swimBehaviour) { - notSpawnedEntityIds.Add(pair.Key); + self.notSpawnedEntityIds.Add(pair.Key); return true; } - if (!swimBehaviour.enabled) { - regularEntities[pair.Key] = swimBehaviour.gameObject; + self.regularEntities[pair.Key] = swimBehaviour.gameObject; return true; } return false; }); - regularEntities.RemoveWhere(pair => + regularEntities.RemoveWhere(this, static (self, pair) => { if (!pair.Value) { - notSpawnedEntityIds.Add(pair.Key); + self.notSpawnedEntityIds.Add(pair.Key); return true; } return false; @@ -176,11 +180,11 @@ private void CheckEntities() // in case a fish was removed from splineEntities (from the above loop), it can be added back in here as a regular entity if required // NB: keep this section below the other RemoveWhere sections so it can eventually collect fresh references from the NitroxIds - notSpawnedEntityIds.RemoveWhere(id => + notSpawnedEntityIds.RemoveWhere(this, static (self, id) => { if (NitroxEntity.TryGetObjectFrom(id, out GameObject entityObject)) { - SortEntity(id, entityObject); + self.AddEntityToLookup(id, entityObject); return true; } return false; @@ -217,7 +221,7 @@ public void RemoveEntityMovementControl(GameObject gameObject, NitroxId entityId } /// - /// Notifies the server of the latest known position of this entity if the local player is simulating it. + /// Notifies the server of the latest known position of this entity if the local player is simulating it. /// public void SendLastUpdateAndDropOwnership(GameObject gameObject) { @@ -234,7 +238,8 @@ public void SendLastUpdateAndDropOwnership(GameObject gameObject) // Clean up in case there remains an update that wasn't sent yet splineUpdatesById.Remove(entityId); entityTransformUpdate = new SplineTransformUpdate(entityId, entityTransform.position.ToDto(), entityTransform.rotation.ToDto(), splineFollowing.targetPosition.ToDto(), splineFollowing.targetDirection.ToDto(), splineFollowing.medianSpeed); - } else if (regularEntities.ContainsKey(entityId)) + } + else if (regularEntities.ContainsKey(entityId)) { entityTransformUpdate = new RawTransformUpdate(entityId, entityTransform.position.ToDto(), entityTransform.rotation.ToDto()); }