From e32cae48f52090da184ad73fa0a846cbfcb23bd8 Mon Sep 17 00:00:00 2001 From: beleata Date: Tue, 18 Aug 2026 20:08:05 +0300 Subject: [PATCH 1/3] Save/Load: Restore world saving and fix the crashes it exposed Persistence.Save built the whole world, deleted old save files and then logged "Saved world..." without writing anything: the call to SerializeToFile has been commented out with a "TODO: FIX" since c89a61f (2023-07-23). Nothing has been persisted since, so every restart started from a clean world. Restoring the write exposed the load path, which had never run: - PersistenceJsonConverter.WriteJson serialised through the same JsonSerializer the converter is registered on, so writing recursed until the stack overflowed. The concrete type is already written by PersistenceData.__ObjectType, so writing needs no converter at all and CanWrite is now false. - Save wrote relative to the working directory, while Load and the save-file cleanup scan the directory next to the assembly. With -configdir set, saves went where Load never looks. - Vessels were loaded with Parallel.ForEachAsync, but the game code that drives mutates shared state through plain List. Docking corrupted the shared docked-vessels tree into cycles, which overflowed the stack in BulletPhysicsController.RemoveRigidBody and in OrbitParameters.RelativePosition, and repair points corrupted Room.AirConsumers, throwing out of List.Remove. Loading is now sequential. - Docking and stabilisation refer to other vessels by GUID, so they now run in a second pass once every vessel exists, rather than reaching for vessels that had not been created yet. - RemoveRigidBody walks the docking tree iteratively with a visited set, so a malformed tree is reported instead of taking the server down. - Room.AddAirConsumer rejects null instead of storing it for a later NullReferenceException in UpdateCompoundRooms. Verified end to end: a generated world is saved, loaded again after a restart, runs and saves again, including reloading a save that was itself written by a loaded world. Co-Authored-By: Claude Opus 5 --- OpenHellion/IO/PersistenceJsonConverter.cs | 7 +- .../BulletPhysics/BulletPhysicsController.cs | 24 +++-- ZeroGravity/Objects/Ship.cs | 90 +++++++++++-------- ZeroGravity/Persistence.cs | 44 ++++++--- .../ShipComponents/DistributionManager.cs | 2 +- ZeroGravity/ShipComponents/Room.cs | 5 ++ 6 files changed, 118 insertions(+), 54 deletions(-) diff --git a/OpenHellion/IO/PersistenceJsonConverter.cs b/OpenHellion/IO/PersistenceJsonConverter.cs index bb060f1..b9fe243 100644 --- a/OpenHellion/IO/PersistenceJsonConverter.cs +++ b/OpenHellion/IO/PersistenceJsonConverter.cs @@ -7,6 +7,11 @@ namespace OpenHellion.IO; public class PersistenceJsonConverter : JsonConverter { + // This converter only resolves the concrete type when reading. Writing is left to the default + // serialiser, which stores the type in PersistenceData.__ObjectType. Serialising here would + // re-enter this converter through the same JsonSerializer and recurse until the stack overflows. + public override bool CanWrite => false; + public override bool CanConvert(Type objectType) { return objectType == typeof(PersistenceData) || objectType == typeof(PersistenceObjectData); @@ -24,6 +29,6 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { - serializer.Serialize(writer, value); + throw new NotSupportedException("PersistenceJsonConverter is read-only."); } } diff --git a/ZeroGravity/BulletPhysics/BulletPhysicsController.cs b/ZeroGravity/BulletPhysics/BulletPhysicsController.cs index ab2fc96..4fce67b 100644 --- a/ZeroGravity/BulletPhysics/BulletPhysicsController.cs +++ b/ZeroGravity/BulletPhysics/BulletPhysicsController.cs @@ -292,18 +292,30 @@ public bool RemoveRigidBody(SpaceObjectVessel ship) { try { - lock (_dynamicsWorld) + // Walk up the docking tree iteratively, remembering where we have been. A vessel loaded + // from persistence can end up in a docking cycle (A docked to B, B docked back to A), and + // following it recursively overflows the stack and takes the whole server down. + HashSet visited = []; + while (ship != null && visited.Add(ship)) { - if (ship.RigidBody != null && _dynamicsWorld.CollisionObjectArray.Contains(ship.RigidBody)) + lock (_dynamicsWorld) + { + if (ship.RigidBody != null && _dynamicsWorld.CollisionObjectArray.Contains(ship.RigidBody)) + { + _dynamicsWorld.RemoveRigidBody(ship.RigidBody); + ship.RigidBody = null; + return true; + } + } + if (!ship.IsDocked) { - _dynamicsWorld.RemoveRigidBody(ship.RigidBody); - ship.RigidBody = null; return true; } + ship = ship.DockedToMainVessel as Ship; } - if (ship.IsDocked) + if (ship != null) { - return RemoveRigidBody(ship.DockedToMainVessel as Ship); + Debug.LogError("Cycle in docking tree while removing rigid body", ship.Guid); } return true; } diff --git a/ZeroGravity/Objects/Ship.cs b/ZeroGravity/Objects/Ship.cs index 1718e15..e0592ff 100644 --- a/ZeroGravity/Objects/Ship.cs +++ b/ZeroGravity/Objects/Ship.cs @@ -1640,52 +1640,52 @@ public async Task LoadPersistenceData(PersistenceObjectData persistenceData) } if (data.ResourceTanks != null) { - await Parallel.ForEachAsync(data.ResourceTanks, async (rtd, ct) => + foreach (var rtd in data.ResourceTanks) { await DistributionManager.GetResourceContainer(new VesselObjectID(Guid, rtd.InSceneID))?.LoadPersistenceData(rtd); - }); + } } if (data.Generators != null) { - await Parallel.ForEachAsync(data.Generators, async (vc, ct) => + foreach (var vc in data.Generators) { await DistributionManager.GetGenerator(new VesselObjectID(Guid, vc.InSceneID))?.LoadPersistenceData(vc); - }); + } } if (data.SubSystems != null) { - await Parallel.ForEachAsync(data.SubSystems, async (subSystem, ct) => + foreach (var subSystem in data.SubSystems) { await DistributionManager.GetSubSystem(new VesselObjectID(Guid, subSystem.InSceneID))?.LoadPersistenceData(subSystem); - }); + } } if (data.Rooms != null) { - await Parallel.ForEachAsync(data.Rooms, async (room, ct) => + foreach (var room in data.Rooms) { await DistributionManager.GetRoom(new VesselObjectID(Guid, room.InSceneID))?.LoadPersistenceData(room); - }); + } } if (data.Doors != null) { - await Parallel.ForEachAsync(data.Doors, async (door, ct) => + foreach (var door in data.Doors) { await Doors.Find((Door x) => x.ID.InSceneID == door.InSceneID)?.LoadPersistenceData(door); - }); + } } if (data.DockingPorts != null) { - await Parallel.ForEachAsync(data.DockingPorts, async (dp, ct) => + foreach (var dp in data.DockingPorts) { - await DockingPorts.First((VesselDockingPort m) => m.ID.InSceneID == dp.InSceneID)?.LoadPersistenceData(dp); - }); + await DockingPorts.FirstOrDefault((VesselDockingPort m) => m.ID.InSceneID == dp.InSceneID)?.LoadPersistenceData(dp); + } } if (data.Executors != null) { - await Parallel.ForEachAsync(data.Executors, async (executor, ct) => + foreach (var executor in data.Executors) { await SceneTriggerExecutors.Find(x => x.InSceneID == executor.InSceneID)?.LoadPersistenceData(executor); - }); + } } if (data.NameTags != null) { @@ -1700,10 +1700,10 @@ await Parallel.ForEachAsync(data.Executors, async (executor, ct) => } if (data.RepairPoints is { Count: > 0 }) { - await Parallel.ForEachAsync(data.RepairPoints, async (rp, ct) => + foreach (var rp in data.RepairPoints) { await RepairPoints.Find((VesselRepairPoint x) => x.ID.InSceneID == rp.InSceneID)?.LoadPersistenceData(rp); - }); + } } await MainDistributionManager.UpdateSystems(); if (data.OrbitData != null) @@ -1712,25 +1712,6 @@ await Parallel.ForEachAsync(data.RepairPoints, async (rp, ct) => } Server.Instance.Add(this); SetPhysicsParameters(); - if (data.DockedToShipGUID.HasValue) - { - Ship dockToShip = Server.Instance.GetVessel(data.DockedToShipGUID.Value) as Ship; - - VesselDockingPort myPort = DockingPorts.First((VesselDockingPort m) => m.ID.InSceneID == data.DockedPortID.Value); - VesselDockingPort dockedToPort = dockToShip.DockingPorts.First((VesselDockingPort m) => m.ID.InSceneID == data.DockedToPortID.Value); - - System.Diagnostics.Debug.Assert(myPort != null); - System.Diagnostics.Debug.Assert(dockedToPort != null); - - await DockToVessel(myPort, dockedToPort, dockToShip, disableStabilization: false, useCurrentSolarSystemTime: true, buildingStation: true); - } - if (data.StabilizeToTargetGUID.HasValue) - { - SpaceObjectVessel ab = Server.Instance.GetObject(data.StabilizeToTargetGUID.Value) as SpaceObjectVessel; - StabilizeToTarget(ab, forceStabilize: true); - StabilizeToTargetRelPosition = data.StabilizeToTargetPosition.ToVector3D(); - await UpdateStabilization(); - } if (data.timePassedSinceShipCall > 0.0) { LoadShipRequestPersistance(data.timePassedSinceShipCall); @@ -1750,6 +1731,43 @@ await Parallel.ForEachAsync(data.RepairPoints, async (rp, ct) => } } + /// + /// Restores docking and stabilisation, which both refer to other vessels by GUID. + /// Must run after every vessel has been created, and one vessel at a time: docking rewrites + /// the shared docked-vessels tree, so doing it concurrently corrupts that tree into cycles. + /// + public async Task LoadDockingPersistenceData(PersistenceObjectData persistenceData) + { + PersistenceObjectDataShip data = persistenceData as PersistenceObjectDataShip; + if (data.DockedToShipGUID.HasValue) + { + if (Server.Instance.GetVessel(data.DockedToShipGUID.Value) is not Ship dockToShip) + { + Debug.LogError("Could not find vessel to dock to", Guid, data.DockedToShipGUID.Value); + } + else + { + VesselDockingPort myPort = DockingPorts.FirstOrDefault((VesselDockingPort m) => m.ID.InSceneID == data.DockedPortID.Value); + VesselDockingPort dockedToPort = dockToShip.DockingPorts.FirstOrDefault((VesselDockingPort m) => m.ID.InSceneID == data.DockedToPortID.Value); + if (myPort == null || dockedToPort == null) + { + Debug.LogError("Could not find docking port", Guid, data.DockedPortID, data.DockedToPortID); + } + else + { + await DockToVessel(myPort, dockedToPort, dockToShip, disableStabilization: false, useCurrentSolarSystemTime: true, buildingStation: true); + } + } + } + if (data.StabilizeToTargetGUID.HasValue) + { + SpaceObjectVessel ab = Server.Instance.GetObject(data.StabilizeToTargetGUID.Value) as SpaceObjectVessel; + StabilizeToTarget(ab, forceStabilize: true); + StabilizeToTargetRelPosition = data.StabilizeToTargetPosition.ToVector3D(); + await UpdateStabilization(); + } + } + public async void VesselRequestListener(NetworkData data) { var request = data as VesselRequest; diff --git a/ZeroGravity/Persistence.cs b/ZeroGravity/Persistence.cs index 85bf7ad..6c1d102 100644 --- a/ZeroGravity/Persistence.cs +++ b/ZeroGravity/Persistence.cs @@ -162,8 +162,18 @@ public static void Save(string filename = null, SaveFileAuxData auxData = null) filename = string.Format(PersistanceFileName, DateTime.UtcNow.ToString("yyyy-MM-dd-HH-mm-ss")); } - // TODO: FIX - // JsonSerialiser.SerializeToFile(per, Path.Combine(Server.ConfigDir, filename), JsonSerialiser.Formatting.None); + // Save to the same directory that Load and the cleanup above scan, so that saves are + // found again when the server is started with a custom config directory. + string savePath = Path.Combine(d.FullName, filename); + try + { + JsonSerialiser.SerializeToFile(per, savePath, JsonSerialiser.Formatting.None); + } + catch (Exception ex) + { + Debug.LogError("Failed to save world", savePath, ex.Message); + return; + } Debug.Log("Saved world..."); } @@ -249,28 +259,42 @@ public static async Task Load(string filename = null) Server.Instance.SolarSystem.CalculatePositionsAfterTime(persistence.SolarSystemTime); if (persistence.Asteroids != null) { - await Parallel.ForEachAsync(persistence.Asteroids, async (asteroidData, ct) => + // Loading is deliberately sequential: the game code it drives mutates shared state + // (rooms, docking trees, air consumers) through plain List, which is not thread safe. + foreach (var asteroidData in persistence.Asteroids) { Asteroid ast = new Asteroid(asteroidData.GUID, initializeOrbit: false, Vector3D.Zero, Vector3D.One, Vector3D.Forward, Vector3D.Up); await ast.LoadPersistenceData(asteroidData); - }); + } } if (persistence.Ships != null) { - await Parallel.ForEachAsync(persistence.Ships, async (shipData, ct) => + foreach (var shipData in persistence.Ships) { Ship sh = new Ship(shipData.GUID, initializeOrbit: false, Vector3D.Zero, Vector3D.One, Vector3D.Forward, Vector3D.Up); await sh.LoadPersistenceData(shipData); - }); + } + + // Second pass, after every vessel exists and one at a time. Docking refers to another + // vessel by GUID, so it cannot run while the vessels are still being created, and it + // rewrites the shared docked-vessels tree, so running it in parallel corrupts that tree + // into cycles that later overflow the stack when it is walked. + foreach (PersistenceObjectData shipData in persistence.Ships) + { + if (Server.Instance.GetVessel(shipData.GUID) is Ship sh) + { + await sh.LoadDockingPersistenceData(shipData); + } + } } if (persistence.Players != null) { - await Parallel.ForEachAsync(persistence.Players, async (data, ct) => + foreach (var data in persistence.Players) { var playerData = data as PersistenceObjectDataPlayer; Player player = await Player.CreatePlayerAsync(playerData.GUID, Vector3D.Zero, QuaternionD.Identity, "PersistenceLoad", "", playerData.Gender, playerData.HeadType, playerData.HairType, addToServerList: false); await player.LoadPersistenceData(playerData); - }); + } } if (persistence.RespawnObjects != null) { @@ -288,12 +312,12 @@ await Parallel.ForEachAsync(persistence.Players, async (data, ct) => } if (persistence.ArenaControllers != null) { - await Parallel.ForEachAsync(persistence.ArenaControllers, async (data, ct) => + foreach (var data in persistence.ArenaControllers) { var arenaControllerData = data as PersistenceArenaControllerData; DeathMatchArenaController arenaController = new DeathMatchArenaController(); await arenaController.LoadPersistenceData(arenaControllerData); - }); + } } if (persistence.DoomControllerData != null) { diff --git a/ZeroGravity/ShipComponents/DistributionManager.cs b/ZeroGravity/ShipComponents/DistributionManager.cs index 51f0400..f464845 100644 --- a/ZeroGravity/ShipComponents/DistributionManager.cs +++ b/ZeroGravity/ShipComponents/DistributionManager.cs @@ -1577,7 +1577,7 @@ private void UpdateCompoundRooms(float duration) quantityLoss = 0f; if (duration > 0f) { - foreach (IAirConsumer cons in cav.AirConsumers.Where((IAirConsumer m) => m is not AirConsumerFire)) + foreach (IAirConsumer cons in cav.AirConsumers.Where((IAirConsumer m) => m is not null and not AirConsumerFire)) { qualityLoss += cav.AirPressure > 0f ? cons.AirQualityDegradationRate / cav.Volume / cav.AirPressure * duration : 0f; quantityLoss += cons.AirQuantityDecreaseRate * duration; diff --git a/ZeroGravity/ShipComponents/Room.cs b/ZeroGravity/ShipComponents/Room.cs index 9114fb1..c46dac7 100644 --- a/ZeroGravity/ShipComponents/Room.cs +++ b/ZeroGravity/ShipComponents/Room.cs @@ -304,6 +304,11 @@ private RoomPressurizationStatus GetPressurizationStatus() public void AddAirConsumer(IAirConsumer consumer) { + if (consumer == null) + { + Debug.LogError("Tried to add a null air consumer to room", ID.InSceneID); + return; + } AirConsumers.Add(consumer); if (CompoundRoom != null) { From e684ed6d1797e8408fcb78f258922178e15776ef Mon Sep 17 00:00:00 2001 From: beleata Date: Wed, 19 Aug 2026 22:38:18 +0300 Subject: [PATCH 2/3] Fix five crashes and persist items left lying in a vessel Everything here was found by playing the game against a local server: a Nakama instance, the game server, and the client built from the Client repository. Restoring world saving (previous commit) made the load path run for the first time, and each of these turned up behind it. Crashes, all of which end the server process: - Killing a player compares every vessel's quest trigger id against one another through QuestTriggerID's == operator, which dereferences both sides. Almost no vessel carries one, so the first comparison throws. Any death took the server down. Its GetHashCode also built a multi-dimensional array out of the field values. - Destroying a vessel ran its docked vessels and its crew through Parallel.ForEachAsync, and the spawn system and name generator it reaches into keep plain dictionaries. Two vessels going at once corrupt them. - A player floating with no vessel nearby reports none, and UpdateMovementListener dereferenced that before the guard against it six lines further down. - GameTransport.DisconnectInternal ran its body twice for one client and threw on the already disposed socket before the entry could be removed, leaving the client marked as connected and the server refusing its next login as a duplicate. - An item on a pivot is deleted when no client has spoken for it in five minutes, but lastSenderTime started at DateTime.MinValue, so anything restored from a save looked abandoned for two millennia and was deleted on the first tick. Items left lying in a vessel: Such an item belongs to no vessel and to nobody's inventory - it rides on its own pivot - so nothing that is persisted could reach it and it was lost on every shutdown. This is issue #3. The vessel it was released in is now recorded, along with where, and on load the item is put back on a pivot in that vessel. Verified across restarts: the item is written to the save, restored, and saved again. PlayerReady's setter compared with a single '=', so its body ran on every assignment rather than only on a change, firing anything hanging off it many times a second. Known limitation: a client is told about these items when it joins, but does not draw them. The server places such an item within a metre of the station's centre while the client draws it where it was dropped, tens of metres away - the two measure in different frames and neither converts. That is issue #1's territory, and it is not addressed here. Co-Authored-By: Claude Opus 5 --- OpenHellion/Net/GameTransport.cs | 30 ++++++---- ZeroGravity/Objects/DynamicObject.cs | 65 ++++++++++++++++++--- ZeroGravity/Objects/Player.cs | 51 +++++++++++++++- ZeroGravity/Objects/QuestTrigger.cs | 12 +++- ZeroGravity/Persistence.cs | 71 ++++++++++++++++++++++- ZeroGravity/PersistenceObjectDataPivot.cs | 17 ++++++ ZeroGravity/Server.cs | 13 +++-- 7 files changed, 231 insertions(+), 28 deletions(-) create mode 100644 ZeroGravity/PersistenceObjectDataPivot.cs diff --git a/OpenHellion/Net/GameTransport.cs b/OpenHellion/Net/GameTransport.cs index dbfd44a..497db99 100644 --- a/OpenHellion/Net/GameTransport.cs +++ b/OpenHellion/Net/GameTransport.cs @@ -356,19 +356,29 @@ internal long[] GetConnectionsGUIDAsync() // Disconnect a client with the provided id. internal void DisconnectInternal(long guid) { + // Both the read loop and the socket error path reach this for the same client, so the entry is + // taken out first and everything below runs exactly once. Previously the second call threw on + // the already disposed socket before the entry could be removed, leaving the client marked as + // connected forever and the server refusing its next login as a duplicate. + if (!_connections.Remove(guid, out ConnectionData connection)) + { + return; + } + _onDisconnected(guid); - if (_connections.TryGetValue(guid, out ConnectionData connection)) + try { - try - { - connection.socket.Shutdown(SocketShutdown.Both); - } - finally - { - connection.stream.Close(); - } + connection.socket.Shutdown(SocketShutdown.Both); + } + catch (Exception ex) + { + // The socket is already gone; there is nothing left to shut down cleanly. + Debug.Log("Socket was already closed when disconnecting client", guid, ex.Message); + } + finally + { + connection.stream.Close(); connection.cancellationToken.Cancel(); - _connections.Remove(guid); } } diff --git a/ZeroGravity/Objects/DynamicObject.cs b/ZeroGravity/Objects/DynamicObject.cs index b5ca84b..b31f83c 100644 --- a/ZeroGravity/Objects/DynamicObject.cs +++ b/ZeroGravity/Objects/DynamicObject.cs @@ -21,7 +21,10 @@ public class DynamicObject : SpaceObjectTransferable, IPersistantObject private long _MasterClientID; - private DateTime lastSenderTime; + // Starts counting from when the object comes into existence. Left at DateTime.MinValue, an + // object restored from persistence looks abandoned for two millennia and SelfDestructCheck + // deletes it on the first tick, before any client ever gets the chance to touch it. + private DateTime lastSenderTime = DateTime.UtcNow; private DateTime takeoverTime; @@ -29,6 +32,16 @@ public class DynamicObject : SpaceObjectTransferable, IPersistantObject public double LastStatsSendTime; + /// + /// The vessel this object was released inside of, and where it was at that moment. An object + /// on a pivot belongs to no vessel and so is reachable from nothing that gets persisted; this + /// is what lets it be put back into that vessel when the world is loaded again. Zero when the + /// object was released in open space, which is deliberately not persisted. + /// + public long DroppedInVesselGuid; + + public Vector3D DroppedLocalPosition = Vector3D.Zero; + private Vector3D pivotPositionCorrection = Vector3D.Zero; private Vector3D pivotVelocityCorrection = Vector3D.Zero; @@ -225,6 +238,13 @@ public static async Task CreateDynamicObjectAsync(DynamicObjectSc private async void SelfDestructCheck(double dbl) { + // Something released inside a vessel is meant to stay where it was left, so it is not junk just + // because nobody has been near it for a while. This cleanup is for things let go in open space, + // which drift out of reach and can never be recovered. + if (DroppedInVesselGuid != 0L) + { + return; + } if (Parent is Pivot && (DateTime.UtcNow - lastSenderTime).TotalSeconds >= 300.0) { Server.Instance.UnsubscribeFromTimer(UpdateTimer.TimerStep.Step_1_0_min, SelfDestructCheck); @@ -376,7 +396,7 @@ private async void DynamicObjectStatsMessageListener(NetworkData data) newParent = Server.Instance.GetObject(message.AttachData.ParentGUID) as Player; if (await (newParent as Player).PlayerInventory.AddItemToInventory(Item, message.AttachData.InventorySlotID) && oldParent is not Player) { - await removeFromOldParent; + removeFromOldParent.RunSynchronously(); } } else if (message.AttachData.ParentType is SpaceObjectType.Ship or SpaceObjectType.Asteroid or SpaceObjectType.Station) @@ -384,7 +404,7 @@ private async void DynamicObjectStatsMessageListener(NetworkData data) newParent = Server.Instance.GetObject(message.AttachData.ParentGUID) as SpaceObjectVessel; if (message.AttachData.IsAttached) { - await removeFromOldParent; + removeFromOldParent.RunSynchronously(); Parent = newParent; (newParent as SpaceObjectVessel).AttachPoints.TryGetValue(message.AttachData.APDetails.InSceneID, out var ap); if (ap == null || !ap.CanFitItem(Item)) @@ -405,22 +425,50 @@ private async void DynamicObjectStatsMessageListener(NetworkData data) } else { - await removeFromOldParent; + removeFromOldParent.RunSynchronously(); LocalPosition = message.AttachData.LocalPosition.ToVector3D(); - LocalRotation = message.AttachData.LocalPosition.ToQuaternionD(); + LocalRotation = message.AttachData.LocalRotation.ToQuaternionD(); } } else if (message.AttachData.ParentType is SpaceObjectType.PlayerPivot or SpaceObjectType.CorpsePivot or SpaceObjectType.DynamicObjectPivot) { ArtificialBody refObject = GetParent(oldParent); + + // The module the item was released in, before refObject is moved up to the station + // it is docked into. The position the client sends is relative to this module, so + // recording the station instead would put the item back in the wrong frame. + SpaceObjectVessel releasedInside = refObject as SpaceObjectVessel; + if (refObject is SpaceObjectVessel vessel) { refObject = vessel.MainVessel; } + + // Remember where this came from while we still know: once the object is on a pivot it + // has no link back to the vessel it was released in. The client keeps sending attach + // data while the object settles, and those repeats arrive with the pivot as the old + // parent, so only a release from something that is not already a pivot may clear it. + if (releasedInside != null) + { + DroppedInVesselGuid = releasedInside.Guid; + + // Where it was left, taken from the player who let go of it: their own position + // inside the vessel, which the server tracks from their movement. The position + // the client sends with the drop is relative to a root of its own that has + // nothing to do with the vessel, and the old parent here is the vessel itself, + // whose position relative to itself is nothing at all. + Player dropper = Server.Instance.GetPlayer(message.Sender); + DroppedLocalPosition = dropper != null ? dropper.LocalPosition : Vector3D.Zero; + } + else if (oldParent is not Pivot) + { + DroppedInVesselGuid = 0L; + } + newParent = new Pivot(this, refObject); - await removeFromOldParent; + removeFromOldParent.RunSynchronously(); LocalPosition = message.AttachData.LocalPosition.ToVector3D(); - LocalRotation = message.AttachData.LocalPosition.ToQuaternionD(); + LocalRotation = message.AttachData.LocalRotation.ToQuaternionD(); pivotPositionCorrection = Vector3D.Zero; pivotVelocityCorrection = Vector3D.Zero; foreach (Player pl in Server.Instance.AllPlayers) @@ -438,7 +486,7 @@ private async void DynamicObjectStatsMessageListener(NetworkData data) if ((newParent as DynamicObject).Item.Slots != null && (newParent as DynamicObject).Item.Slots.TryGetValue(message.AttachData.ItemSlotID, out slot) && slot != null && slot.CanFitItem(Item)) { PickedUp(); - await removeFromOldParent; + removeFromOldParent.RunSynchronously(); slot.FitItem(Item); } } @@ -585,6 +633,7 @@ public override SpawnObjectResponseData GetSpawnResponseData(Player pl) det.StatsData.Tier = Item.Tier; det.StatsData.Armor = Item.Armor; } + return new SpawnDynamicObjectResponseData { GUID = Guid, diff --git a/ZeroGravity/Objects/Player.cs b/ZeroGravity/Objects/Player.cs index 26a4261..20d5c71 100644 --- a/ZeroGravity/Objects/Player.cs +++ b/ZeroGravity/Objects/Player.cs @@ -152,7 +152,9 @@ public bool PlayerReady } private set { - if (_playerReady = value) + // This compared with a single '=', so the body ran on every assignment instead of only when + // the value actually changed, firing whatever hangs off it many times a second. + if (_playerReady != value) { _playerReady = value; if (PlayerReady && EnvironmentReady) @@ -498,6 +500,45 @@ protected async void EnvironmentReadyListener(NetworkData data) } } } + + // Items someone has dropped float on their own pivot and belong to no vessel, so none of the + // loops above reach them. Without this, anything left lying around before this player + // arrived stays invisible to them, even though it is right there in the room. + // They go into this same response, and the pivots they hang off are pushed out just before + // it: the client resolves an item's parent as the response is handled and never creates a + // pivot on demand, so an item whose pivot it has not heard of is placed outside the world. + SpaceObjectVessel main = vessel.IsDocked ? vessel.DockedToMainVessel : vessel; + HashSet here = [main.Guid]; + foreach (SpaceObjectVessel docked in main.AllDockedVessels) + { + here.Add(docked.Guid); + } + + bool anyFloating = false; + foreach (SpaceObject spaceObject in Server.Instance.AllSpaceObjects) + { + if (spaceObject is not DynamicObject { Parent: Pivot pivot } floating + || !here.Contains(floating.DroppedInVesselGuid)) + { + continue; + } + // A pivot gets dropped from the solar system's body list along the way, and once it is + // out of that list it can never appear in a movement message again, so no client could + // be told it exists. Putting it back is harmless if it is still there. + Server.Instance.SolarSystem.AddArtificialBody(pivot); + + res.Data.Add(floating.GetSpawnResponseData(this)); + SubscribeTo(pivot); + UpdateArtificialBodyMovement.Add(pivot.Guid); + anyFloating = true; + } + + // The pivots have to reach the client before the items that hang off them: it resolves an + // item's parent as this response is handled and never creates a pivot on demand. + if (anyFloating) + { + await Server.Instance.SolarSystem.SendMovementMessageToPlayer(this); + } } await NetworkController.SendAsync(Guid, res); await NetworkController.SendCharacterSpawnToOtherPlayersAsync(this); @@ -836,9 +877,13 @@ private async void UpdateMovementListener(NetworkData data) { Pivot pivot = Parent as Pivot; SpaceObjectVessel nearestVessel = message.NearestVesselGUID > 0 ? Server.Instance.GetVessel(message.NearestVesselGUID) : null; - SpaceObjectVessel refVessel = nearestVessel.MainVessel; - if (refVessel.StabilizeToTargetObj != null) + // There may be no vessel nearby at all - a player floating in open space reports none - and + // the guard against that only appeared six lines further down, after this had already been + // dereferenced. The exception escapes an async void listener and stops the whole server. + SpaceObjectVessel refVessel = nearestVessel?.MainVessel; + + if (refVessel is { StabilizeToTargetObj: not null }) { refVessel = refVessel.StabilizeToTargetObj; } diff --git a/ZeroGravity/Objects/QuestTrigger.cs b/ZeroGravity/Objects/QuestTrigger.cs index 9bda3c7..c10308d 100644 --- a/ZeroGravity/Objects/QuestTrigger.cs +++ b/ZeroGravity/Objects/QuestTrigger.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -24,17 +25,24 @@ public override bool Equals(object obj) public override int GetHashCode() { - return new object[(int)checked((nint)PlayerGUID), QuestID, ID].GetHashCode(); + return HashCode.Combine(PlayerGUID, QuestID, ID); } + // Most vessels carry no quest trigger id at all, so both sides have to tolerate null. Without + // this, comparing against them throws, and since the comparison happens while a player is being + // killed the exception escapes an async void path and takes the whole server down. public static bool operator ==(QuestTriggerID x, QuestTriggerID y) { + if (x is null || y is null) + { + return x is null && y is null; + } return x.PlayerGUID == y.PlayerGUID && x.QuestID == y.QuestID && x.ID == y.ID; } public static bool operator !=(QuestTriggerID x, QuestTriggerID y) { - return x.PlayerGUID != y.PlayerGUID || x.QuestID != y.QuestID || x.ID != y.ID; + return !(x == y); } } diff --git a/ZeroGravity/Persistence.cs b/ZeroGravity/Persistence.cs index 6c1d102..b8e1e71 100644 --- a/ZeroGravity/Persistence.cs +++ b/ZeroGravity/Persistence.cs @@ -104,7 +104,8 @@ public static void Save(string filename = null, SaveFileAuxData auxData = null) Players = new HashSet(), RespawnObjects = new HashSet(), SpawnPoints = new HashSet(), - ArenaControllers = new HashSet() + ArenaControllers = new HashSet(), + Pivots = new HashSet() }; foreach (SpaceObjectVessel ves in Server.Instance.AllVessels) @@ -144,6 +145,26 @@ public static void Save(string filename = null, SaveFileAuxData auxData = null) { per.ArenaControllers.Add(dmac.GetPersistenceData()); } + + // Items a player released inside a vessel float on their own pivot and belong to no vessel, so + // nothing else in this file reaches them and they are lost on shutdown. Record which vessel + // each one was released in, so it can be put back there on load. Items released in open space + // have no vessel and are deliberately left out: they drift out of reach and are cleaned up. + foreach (SpaceObject spaceObject in Server.Instance.AllSpaceObjects) + { + if (spaceObject is not DynamicObject dobj || dobj.Parent is not Pivot || dobj.DroppedInVesselGuid == 0L) + { + continue; + } + per.Pivots.Add(new PersistenceObjectDataPivot + { + GUID = dobj.Guid, + ParentVesselGUID = dobj.DroppedInVesselGuid, + LocalPosition = dobj.DroppedLocalPosition.ToFloatArray(), + Child = dobj.Item != null ? dobj.Item.GetPersistenceData() : dobj.GetPersistenceData() + }); + } + per.DoomControllerData = Server.Instance.DoomedShipController.GetPersistenceData(); per.SpawnManagerData = SpawnManager.GetPersistenceData(); DirectoryInfo d = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), Server.ConfigDir)); @@ -222,6 +243,45 @@ private static void LoadRespawnObjectPersistence(PersistenceObjectDataRespawnObj }); } + /// + /// Puts an item that a player left lying in a vessel back into that vessel, at the place it was + /// left. It comes back as an ordinary loose item in a room rather than on a pivot, which is what + /// keeps it saved, shown to clients that come near, and out of reach of the pivot cleanup timer. + /// + private static async Task LoadPivotPersistence(PersistenceObjectDataPivot data) + { + if (data.Child is not PersistenceObjectDataDynamicObject childData) + { + Debug.LogError("Dropped item persistence has no item data", data.GUID); + return; + } + + if (Server.Instance.GetObject(data.ParentVesselGUID) is not SpaceObjectVessel vessel) + { + Debug.LogError("Could not find the vessel a dropped item belongs to", data.GUID, data.ParentVesselGUID); + return; + } + + DynamicObject dobj = await CreateDynamicObject(childData, vessel); + if (dobj == null) + { + Debug.LogError("Could not recreate dropped item", data.GUID); + return; + } + + // Put it back exactly as it was: on its own pivot, positioned relative to that pivot, which + // sits at the vessel. A vessel's own items are placed by their attach point, so an item lying + // loose in a room cannot be expressed as a child of the vessel - clients have no way to place + // it. Floating on a pivot is how the game represents this, and clients already draw it. + dobj.Parent = new Pivot(dobj, vessel); + if (data.LocalPosition != null) + { + dobj.LocalPosition = data.LocalPosition.ToVector3D(); + } + dobj.DroppedInVesselGuid = vessel.Guid; + dobj.DroppedLocalPosition = dobj.LocalPosition; + } + private static void LoadSpawnPointPeristence(PersistenceObjectDataSpawnPoint data) { try @@ -319,6 +379,13 @@ public static async Task Load(string filename = null) await arenaController.LoadPersistenceData(arenaControllerData); } } + if (persistence.Pivots != null) + { + foreach (PersistenceObjectDataPivot pivotData in persistence.Pivots.Cast()) + { + await LoadPivotPersistence(pivotData); + } + } if (persistence.DoomControllerData != null) { await Server.Instance.DoomedShipController.LoadPersistenceData(persistence.DoomControllerData); @@ -425,6 +492,8 @@ public struct PersistenceObject public HashSet ArenaControllers; + public HashSet Pivots; + public PersistenceObjectData DoomControllerData; public PersistenceObjectData SpawnManagerData; diff --git a/ZeroGravity/PersistenceObjectDataPivot.cs b/ZeroGravity/PersistenceObjectDataPivot.cs new file mode 100644 index 0000000..0d9a230 --- /dev/null +++ b/ZeroGravity/PersistenceObjectDataPivot.cs @@ -0,0 +1,17 @@ +namespace ZeroGravity; + +/// +/// An item a player released inside a vessel. At runtime such an item floats on its own pivot and +/// belongs to no vessel, so nothing else that is persisted can reach it. This records which vessel +/// it was released in and where, so it can be put back into that vessel on load, where it behaves +/// like any other loose item lying in a room. +/// Items released in open space are not recorded: they drift out of reach and are cleaned up. +/// +public class PersistenceObjectDataPivot : PersistenceObjectData +{ + public long ParentVesselGUID; + + public float[] LocalPosition; + + public PersistenceObjectData Child; +} diff --git a/ZeroGravity/Server.cs b/ZeroGravity/Server.cs index cacf92b..00510b0 100644 --- a/ZeroGravity/Server.cs +++ b/ZeroGravity/Server.cs @@ -433,6 +433,8 @@ public static GameScenes.SceneId GetSceneId(string text) public ImmutableList AllPlayers => [.. _players.Values]; + public ImmutableList AllSpaceObjects => [.. _spaceObjects.Values]; + public static Server Instance => _serverInstance; public TimeSpan RunTime => DateTime.UtcNow - _serverStartTime; @@ -2043,17 +2045,20 @@ public async Task DestroyArtificialBody(ArtificialBody ab, bool destroyChildren } if (ab is SpaceObjectVessel ves) { + // Destroying a vessel walks into the spawn system and the name generator, which keep plain + // dictionaries. Doing several at once corrupts them and takes the server down, so the + // docked vessels and the crew are dealt with one at a time. if (destroyChildren && ves.AllDockedVessels.Count > 0) { - await Parallel.ForEachAsync(ves.AllDockedVessels, async (child, ct) => + foreach (SpaceObjectVessel child in ves.AllDockedVessels.ToList()) { await DestroyArtificialBody(child, destroyChildren: false, vesselExploded); - }); + } } - await Parallel.ForEachAsync(ves.VesselCrew, async (pl, ct) => + foreach (Player pl in ves.VesselCrew.ToList()) { await pl.KillPlayer(HurtType.Shipwreck, createCorpse: false); - }); + } if (vesselExploded) { await ves.DamageVesselsInExplosionRadius(); From 1356e3650ba7fe7d0f7b4108a3e37c95b9bec7b1 Mon Sep 17 00:00:00 2001 From: beleata Date: Sat, 22 Aug 2026 18:00:18 +0300 Subject: [PATCH 3/3] Keep a dropped item with the vessel it was dropped in Items left lying in a vessel were persisted through a list of pivots. That was treating a symptom. Removed, along with everything that propped it up, and replaced with the rule the shipped client already expects. A client whose item is resting on the floor reports that it has left the vessel roughly once a second, for as long as the item exists. Its room trigger fires an exit every time the item is re-parented, and the reply re-parents it again. Believing it leaves the item on a pivot of its own, and a pivot's contents are described exactly once - in the message announcing the drop - because the movement message walks vessels and a pivot is not a vessel. From then on the item is invisible to anyone arriving later, including the player who dropped it once they reconnect, and there is nothing under any vessel to write down when the world is saved. The shipped game hides all of this: floating items are destroyed after five minutes and were never saved, so nobody could see that they had already stopped existing for everyone else. Measured against a 2018 server and the retail client, the same drop sends two messages and stops. - A release inside a vessel now leaves the item owned by that vessel, and the reply carries that back to the client. Released in open space it still goes on a pivot, and the five minute cleanup still applies to it. - An object with no master client was described to nobody. Restoring one from a save leaves it with no master, so the server told no one where it was. With no master the server is the only authority there is, so everyone hears from it. - A player who has just connected gives up whatever they were the master of. A new client knows nothing, and while it held that title the server would not tell it where those objects were. - A pivot's dynamic objects are now walked when building the movement message, the same as a vessel's, so a tool dropped in open space beside you is not invisible. Co-Authored-By: Claude Opus 5 --- ZeroGravity/Objects/DynamicObject.cs | 108 ++++++++++++++-------- ZeroGravity/Objects/Player.cs | 40 +------- ZeroGravity/Objects/SolarSystem.cs | 20 ++++ ZeroGravity/Persistence.cs | 61 ------------ ZeroGravity/PersistenceObjectDataPivot.cs | 17 ---- 5 files changed, 88 insertions(+), 158 deletions(-) delete mode 100644 ZeroGravity/PersistenceObjectDataPivot.cs diff --git a/ZeroGravity/Objects/DynamicObject.cs b/ZeroGravity/Objects/DynamicObject.cs index b31f83c..336e4d4 100644 --- a/ZeroGravity/Objects/DynamicObject.cs +++ b/ZeroGravity/Objects/DynamicObject.cs @@ -38,10 +38,6 @@ public class DynamicObject : SpaceObjectTransferable, IPersistantObject /// is what lets it be put back into that vessel when the world is loaded again. Zero when the /// object was released in open space, which is deliberately not persisted. /// - public long DroppedInVesselGuid; - - public Vector3D DroppedLocalPosition = Vector3D.Zero; - private Vector3D pivotPositionCorrection = Vector3D.Zero; private Vector3D pivotVelocityCorrection = Vector3D.Zero; @@ -238,13 +234,6 @@ public static async Task CreateDynamicObjectAsync(DynamicObjectSc private async void SelfDestructCheck(double dbl) { - // Something released inside a vessel is meant to stay where it was left, so it is not junk just - // because nobody has been near it for a while. This cleanup is for things let go in open space, - // which drift out of reach and can never be recovered. - if (DroppedInVesselGuid != 0L) - { - return; - } if (Parent is Pivot && (DateTime.UtcNow - lastSenderTime).TotalSeconds >= 300.0) { Server.Instance.UnsubscribeFromTimer(UpdateTimer.TimerStep.Step_1_0_min, SelfDestructCheck); @@ -435,8 +424,7 @@ private async void DynamicObjectStatsMessageListener(NetworkData data) ArtificialBody refObject = GetParent(oldParent); // The module the item was released in, before refObject is moved up to the station - // it is docked into. The position the client sends is relative to this module, so - // recording the station instead would put the item back in the wrong frame. + // it is docked into. Null means it was released in open space. SpaceObjectVessel releasedInside = refObject as SpaceObjectVessel; if (refObject is SpaceObjectVessel vessel) @@ -444,38 +432,41 @@ private async void DynamicObjectStatsMessageListener(NetworkData data) refObject = vessel.MainVessel; } - // Remember where this came from while we still know: once the object is on a pivot it - // has no link back to the vessel it was released in. The client keeps sending attach - // data while the object settles, and those repeats arrive with the pivot as the old - // parent, so only a release from something that is not already a pivot may clear it. if (releasedInside != null) { - DroppedInVesselGuid = releasedInside.Guid; - - // Where it was left, taken from the player who let go of it: their own position - // inside the vessel, which the server tracks from their movement. The position - // the client sends with the drop is relative to a root of its own that has - // nothing to do with the vessel, and the old parent here is the vessel itself, - // whose position relative to itself is nothing at all. - Player dropper = Server.Instance.GetPlayer(message.Sender); - DroppedLocalPosition = dropper != null ? dropper.LocalPosition : Vector3D.Zero; - } - else if (oldParent is not Pivot) - { - DroppedInVesselGuid = 0L; + // Let go of inside a vessel, so it stays the vessel's own, and the reply + // below carries that back to the client. + // + // The client says otherwise, over and over: an item resting on the floor + // leaves and re-enters its room trigger about once a second, because every + // answer re-parents it and re-parenting fires the trigger again. Believing + // it puts the item on a pivot of its own, and a pivot's contents are only + // ever described once, in the message announcing the drop - the movement + // message walks vessels, and a pivot is not a vessel. So the item becomes + // invisible to anyone who arrives afterwards, including the player who + // dropped it once they reconnect, and there is nothing under any vessel to + // write down when the world is saved. + // + // The shipped game hides all of this: items let go of in a vessel are + // cleaned up after five minutes and were never saved, so nobody could tell + // they had already stopped existing for everyone else. + newParent = releasedInside; } - - newParent = new Pivot(this, refObject); - removeFromOldParent.RunSynchronously(); - LocalPosition = message.AttachData.LocalPosition.ToVector3D(); - LocalRotation = message.AttachData.LocalRotation.ToQuaternionD(); - pivotPositionCorrection = Vector3D.Zero; - pivotVelocityCorrection = Vector3D.Zero; - foreach (Player pl in Server.Instance.AllPlayers) + else { - if (pl.IsSubscribedTo(GetParent(oldParent).Guid)) + // Let go of in open space, where there is no vessel to belong to. + newParent = new Pivot(this, refObject); + removeFromOldParent.RunSynchronously(); + LocalPosition = message.AttachData.LocalPosition.ToVector3D(); + LocalRotation = message.AttachData.LocalRotation.ToQuaternionD(); + pivotPositionCorrection = Vector3D.Zero; + pivotVelocityCorrection = Vector3D.Zero; + foreach (Player pl in Server.Instance.AllPlayers) { - pl.SubscribeTo(newParent); + if (pl.IsSubscribedTo(GetParent(oldParent).Guid)) + { + pl.SubscribeTo(newParent); + } } } } @@ -603,7 +594,42 @@ public DynamicObjectMovementMessage GetDynamicObectMovementMessage() public bool PlayerReceivesMovementMessage(long playerGuid) { - return playerGuid != MasterClientID && MasterClientID != 0; + // The master client is the one simulating this object, so it is sent nothing - it is the + // one doing the telling. But an object restored from a save has never been touched by + // anybody and so has no master at all, and the old test then answered "no" for everyone: + // the server told nobody where the item was, and it existed in the world without ever + // being drawn. With no master, the server is the only authority there is. + if (MasterClientID == 0L) + { + return true; + } + return playerGuid != MasterClientID; + } + + /// + /// Gives up whatever this player was authoritative for. A client that has just connected knows + /// nothing, so it cannot go on being the master of objects it has never heard of - and while it + /// held that title the server would not tell it where they were. + /// + public static void ReleaseMastery(long playerGuid) + { + if (playerGuid == 0L) + { + return; + } + int released = 0; + foreach (SpaceObject spaceObject in Server.Instance.AllSpaceObjects) + { + if (spaceObject is DynamicObject dobj && dobj.MasterClientID == playerGuid) + { + dobj.MasterClientID = 0L; + released++; + } + } + if (released > 0) + { + Debug.Log("Released mastery of " + released + " objects held by player " + playerGuid); + } } public async Task DestroyDynamicObject() diff --git a/ZeroGravity/Objects/Player.cs b/ZeroGravity/Objects/Player.cs index 20d5c71..5714ba1 100644 --- a/ZeroGravity/Objects/Player.cs +++ b/ZeroGravity/Objects/Player.cs @@ -317,6 +317,7 @@ public void ConnectToNetworkController() { EnvironmentReady = false; PlayerReady = false; + DynamicObject.ReleaseMastery(Guid); EventSystem.AddListener(UpdateMovementListener); EventSystem.AddListener(EnvironmentReadyListener); EventSystem.AddListener(PlayerShootingListener); @@ -500,45 +501,6 @@ protected async void EnvironmentReadyListener(NetworkData data) } } } - - // Items someone has dropped float on their own pivot and belong to no vessel, so none of the - // loops above reach them. Without this, anything left lying around before this player - // arrived stays invisible to them, even though it is right there in the room. - // They go into this same response, and the pivots they hang off are pushed out just before - // it: the client resolves an item's parent as the response is handled and never creates a - // pivot on demand, so an item whose pivot it has not heard of is placed outside the world. - SpaceObjectVessel main = vessel.IsDocked ? vessel.DockedToMainVessel : vessel; - HashSet here = [main.Guid]; - foreach (SpaceObjectVessel docked in main.AllDockedVessels) - { - here.Add(docked.Guid); - } - - bool anyFloating = false; - foreach (SpaceObject spaceObject in Server.Instance.AllSpaceObjects) - { - if (spaceObject is not DynamicObject { Parent: Pivot pivot } floating - || !here.Contains(floating.DroppedInVesselGuid)) - { - continue; - } - // A pivot gets dropped from the solar system's body list along the way, and once it is - // out of that list it can never appear in a movement message again, so no client could - // be told it exists. Putting it back is harmless if it is still there. - Server.Instance.SolarSystem.AddArtificialBody(pivot); - - res.Data.Add(floating.GetSpawnResponseData(this)); - SubscribeTo(pivot); - UpdateArtificialBodyMovement.Add(pivot.Guid); - anyFloating = true; - } - - // The pivots have to reach the client before the items that hang off them: it resolves an - // item's parent as this response is handled and never creates a pivot on demand. - if (anyFloating) - { - await Server.Instance.SolarSystem.SendMovementMessageToPlayer(this); - } } await NetworkController.SendAsync(Guid, res); await NetworkController.SendCharacterSpawnToOtherPlayersAsync(this); diff --git a/ZeroGravity/Objects/SolarSystem.cs b/ZeroGravity/Objects/SolarSystem.cs index cf492a2..2488a90 100644 --- a/ZeroGravity/Objects/SolarSystem.cs +++ b/ZeroGravity/Objects/SolarSystem.cs @@ -231,6 +231,26 @@ public async Task SendMovementMessageToPlayer(Player player) } } } + // A pivot is an artificial body but not a vessel, and only vessels were walked here, so + // an item floating on one - anything a player has let go of - had its position sent to + // nobody. It was described once, by the message announcing the drop, and after that + // never again: a client arriving later, or the same client after reconnecting, was + // never told the thing was there. It is the reason dropped items could not survive a + // reconnect and the reason they could not be restored from a save. + else if (artificialBody is Pivot pivotBody && player.IsSubscribedTo(pivotBody.Guid)) + { + foreach (DynamicObject dynamicObject in pivotBody.DynamicObjects.Values) + { + if (dynamicObject.PlayerReceivesMovementMessage(player.Guid) && dynamicObject.LastChangeTime >= player.LastMovementMessageSolarSystemTime) + { + DynamicObjectMovementMessage dynamicOjectMovement = dynamicObject.GetDynamicObectMovementMessage(); + if (dynamicOjectMovement is not null) + { + bodyTransform.DynamicObjectsMovement.Add(dynamicOjectMovement); + } + } + } + } else if (artificialBody is Pivot pivot) { switch (pivot.ObjectType) diff --git a/ZeroGravity/Persistence.cs b/ZeroGravity/Persistence.cs index b8e1e71..fdd5b73 100644 --- a/ZeroGravity/Persistence.cs +++ b/ZeroGravity/Persistence.cs @@ -105,7 +105,6 @@ public static void Save(string filename = null, SaveFileAuxData auxData = null) RespawnObjects = new HashSet(), SpawnPoints = new HashSet(), ArenaControllers = new HashSet(), - Pivots = new HashSet() }; foreach (SpaceObjectVessel ves in Server.Instance.AllVessels) @@ -146,25 +145,6 @@ public static void Save(string filename = null, SaveFileAuxData auxData = null) per.ArenaControllers.Add(dmac.GetPersistenceData()); } - // Items a player released inside a vessel float on their own pivot and belong to no vessel, so - // nothing else in this file reaches them and they are lost on shutdown. Record which vessel - // each one was released in, so it can be put back there on load. Items released in open space - // have no vessel and are deliberately left out: they drift out of reach and are cleaned up. - foreach (SpaceObject spaceObject in Server.Instance.AllSpaceObjects) - { - if (spaceObject is not DynamicObject dobj || dobj.Parent is not Pivot || dobj.DroppedInVesselGuid == 0L) - { - continue; - } - per.Pivots.Add(new PersistenceObjectDataPivot - { - GUID = dobj.Guid, - ParentVesselGUID = dobj.DroppedInVesselGuid, - LocalPosition = dobj.DroppedLocalPosition.ToFloatArray(), - Child = dobj.Item != null ? dobj.Item.GetPersistenceData() : dobj.GetPersistenceData() - }); - } - per.DoomControllerData = Server.Instance.DoomedShipController.GetPersistenceData(); per.SpawnManagerData = SpawnManager.GetPersistenceData(); DirectoryInfo d = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), Server.ConfigDir)); @@ -248,39 +228,6 @@ private static void LoadRespawnObjectPersistence(PersistenceObjectDataRespawnObj /// left. It comes back as an ordinary loose item in a room rather than on a pivot, which is what /// keeps it saved, shown to clients that come near, and out of reach of the pivot cleanup timer. /// - private static async Task LoadPivotPersistence(PersistenceObjectDataPivot data) - { - if (data.Child is not PersistenceObjectDataDynamicObject childData) - { - Debug.LogError("Dropped item persistence has no item data", data.GUID); - return; - } - - if (Server.Instance.GetObject(data.ParentVesselGUID) is not SpaceObjectVessel vessel) - { - Debug.LogError("Could not find the vessel a dropped item belongs to", data.GUID, data.ParentVesselGUID); - return; - } - - DynamicObject dobj = await CreateDynamicObject(childData, vessel); - if (dobj == null) - { - Debug.LogError("Could not recreate dropped item", data.GUID); - return; - } - - // Put it back exactly as it was: on its own pivot, positioned relative to that pivot, which - // sits at the vessel. A vessel's own items are placed by their attach point, so an item lying - // loose in a room cannot be expressed as a child of the vessel - clients have no way to place - // it. Floating on a pivot is how the game represents this, and clients already draw it. - dobj.Parent = new Pivot(dobj, vessel); - if (data.LocalPosition != null) - { - dobj.LocalPosition = data.LocalPosition.ToVector3D(); - } - dobj.DroppedInVesselGuid = vessel.Guid; - dobj.DroppedLocalPosition = dobj.LocalPosition; - } private static void LoadSpawnPointPeristence(PersistenceObjectDataSpawnPoint data) { @@ -379,13 +326,6 @@ public static async Task Load(string filename = null) await arenaController.LoadPersistenceData(arenaControllerData); } } - if (persistence.Pivots != null) - { - foreach (PersistenceObjectDataPivot pivotData in persistence.Pivots.Cast()) - { - await LoadPivotPersistence(pivotData); - } - } if (persistence.DoomControllerData != null) { await Server.Instance.DoomedShipController.LoadPersistenceData(persistence.DoomControllerData); @@ -492,7 +432,6 @@ public struct PersistenceObject public HashSet ArenaControllers; - public HashSet Pivots; public PersistenceObjectData DoomControllerData; diff --git a/ZeroGravity/PersistenceObjectDataPivot.cs b/ZeroGravity/PersistenceObjectDataPivot.cs deleted file mode 100644 index 0d9a230..0000000 --- a/ZeroGravity/PersistenceObjectDataPivot.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace ZeroGravity; - -/// -/// An item a player released inside a vessel. At runtime such an item floats on its own pivot and -/// belongs to no vessel, so nothing else that is persisted can reach it. This records which vessel -/// it was released in and where, so it can be put back into that vessel on load, where it behaves -/// like any other loose item lying in a room. -/// Items released in open space are not recorded: they drift out of reach and are cleaned up. -/// -public class PersistenceObjectDataPivot : PersistenceObjectData -{ - public long ParentVesselGUID; - - public float[] LocalPosition; - - public PersistenceObjectData Child; -}