diff --git a/Content.Server/_HL/Shipyard/ShipSaveYamlSanitizer.cs b/Content.Server/_HL/Shipyard/ShipSaveYamlSanitizer.cs
new file mode 100644
index 00000000000..78b7a4777bf
--- /dev/null
+++ b/Content.Server/_HL/Shipyard/ShipSaveYamlSanitizer.cs
@@ -0,0 +1,727 @@
+using System;
+using System.Collections.Generic;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.Markdown.Mapping;
+using Robust.Shared.Serialization.Markdown.Sequence;
+using Robust.Shared.Serialization.Markdown.Value;
+
+namespace Content.Server._HL.Shipyard;
+
+///
+/// Centralized ship-save YAML sanitization logic used by shipyard save/export flows.
+///
+public static class ShipSaveYamlSanitizer
+{
+ // Implants that should not persist when found inside implanters during ship save.
+ private static readonly HashSet BlockedContainedImplantPrototypes = new(StringComparer.Ordinal)
+ {
+ "DeathRattleImplantColcomm",
+ "RadioImplantColcomm",
+ "UplinkImplant",
+ };
+
+ // Components stripped from all entities during ship-save export.
+ // Add new always-remove component types here.
+ private static readonly HashSet FilteredTypes = new(StringComparer.Ordinal)
+ {
+ "Joint",
+ "StationMember",
+ "NavMap",
+ "ShuttleDeed",
+ "IFF",
+ "LinkedLifecycleGridParent",
+ "AccessReader",
+ "DeviceNetwork",
+ "DeviceNetworkComponent",
+ "UserInterface",
+ "Docking",
+ "ActionGrant",
+ "Mind",
+ "MindContainer",
+ "VendingMachine",
+ "Forensics",
+ };
+
+ // Fill components that are normally removed from ship saves.
+ private static readonly HashSet FillComponentTypes = new(StringComparer.Ordinal)
+ {
+ "StorageFill",
+ "ContainerFill",
+ "EntityTableContainerFill",
+ "SurplusBundle",
+ };
+
+ // Prototype-level exceptions that are allowed to keep fill components.
+ // Add prototype IDs here when forced fill removal breaks a specific entity type.
+ private static readonly HashSet FillComponentWhitelistPrototypes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "AirAlarm",
+ };
+
+ // Components that should be marked as missing after removal so reconstruction follows prototype defaults.
+ private static readonly HashSet ForcedMissingComponents = new(StringComparer.Ordinal)
+ {
+ "StorageFill",
+ "ContainerFill",
+ "EntityTableContainerFill",
+ "SurplusBundle",
+ };
+
+ // Prototype IDs that should never be included in ship exports.
+ // Add non-ship entities here to drop them entirely.
+ private static readonly HashSet FilteredPrototypes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ // Machines & circuitboards
+ "MachineFlatpacker",
+ "CommsComputerCircuitboard",
+ "ComputerDNAScanner",
+ "ComputerExpeditionDiskPrinter",
+ "ComputerFundingAllocation",
+ "ComputerPsionicsRecords",
+ "ComputerRoboticsControl",
+ "ComputerShuttleRecords",
+ "ComputerTabletopShuttleAntag",
+ "DnaScannerConsoleComputerCircuitboard",
+ "IDComputerCircuitboard",
+ "StationAiUploadComputer",
+ // Vending machines
+ "DEBUGVendingMachineAmmoBoxes",
+ "DEBUGVendingMachineMagazines",
+ "DEBUGVendingMachineRangedWeapons",
+ "VendingMachineAmmoPOI",
+ "VendingMachineAstroVendPOI",
+ "VendingMachineBoozePOI",
+ "VendingMachineBountyVendPOI",
+ "VendingMachineCigsPOI",
+ "VendingMachineEngivendPOI",
+ "VendingMachineExpeditionaryFlatpackVend",
+ "VendingMachineFlatpackVend",
+ "VendingMachineFuelVend",
+ "VendingMachineGamesPOI",
+ "LessLethalVendingMachinePOI",
+ "VendingMachineMediDrobePOI",
+ "VendingMachineMercVend",
+ "VendingMachinePickNPackPOI",
+ "VendingMachinePottedPlantVendPOI",
+ "VendingMachineSalvagePOI",
+ "VendingMachineSyndieContraband",
+ "VendingMachineTankDispenserEVAPOI",
+ "VendingMachineVendomatPOI",
+ "VendingMachineYouToolPOI",
+ // Everything else
+ "PortalBlue",
+ "PortalRed",
+ "ReactorGasPipe",
+ "ShipShield",
+ };
+
+ // Entity-level exclusion by component signature.
+ // If an entity has any of these components it is removed from export,
+ // unless allowed by ComponentExclusionExceptions below.
+ private static readonly HashSet FilteredEntityByComponentTypes = new(StringComparer.Ordinal)
+ {
+ "CommunicationsConsole",
+ "ContrabandPalletConsole",
+ "CriminalRecordsConsole",
+ "DnaSequenceInjector",
+ "DoorRemote",
+ "EmergencyShuttleConsole",
+ "GeneralStationRecordConsole",
+ "GeneticAnalyzer",
+ "Ghost",
+ "GhostRole",
+ "HumanoidAppearance",
+ "IdCard",
+ "IdCardConsole",
+ "MarketConsole",
+ "NFCargoOrderConsole",
+ "Pda",
+ "ShipyardConsole",
+ "Store",
+ };
+
+ // Component exclusion exceptions: keep entity when both component and its paired exception component exist.
+ private static readonly Dictionary ComponentExclusionExceptions = new(StringComparer.Ordinal)
+ {
+ ["ShipyardConsole"] = "ShipyardListing",
+ };
+
+ public static void SanitizeShipSaveNode(MappingDataNode root, IPrototypeManager prototypeManager)
+ {
+ // Keep serialized nullspace empty so ship exports stay scoped to the grid payload.
+ try
+ {
+ root["nullspace"] = new SequenceDataNode();
+ }
+ catch
+ {
+ // Best effort: if nullspace cannot be overwritten, continue with entity-level sanitation.
+ }
+
+ if (!root.TryGet("entities", out SequenceDataNode? protoSeq) || protoSeq == null)
+ return;
+
+ // Track entity UIDs removed during sanitation so we can prune stale container/storage references.
+ var removedEntityUids = new HashSet(StringComparer.Ordinal);
+ var blockedContainedImplantEntityUids = CollectBlockedContainedImplantEntityUids(protoSeq);
+
+ foreach (var protoNode in protoSeq)
+ {
+ if (protoNode is not MappingDataNode protoMap)
+ continue;
+
+ if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null)
+ continue;
+
+ for (var i = 0; i < entitiesSeq.Count; i++)
+ {
+ if (entitiesSeq[i] is not MappingDataNode entMap)
+ continue;
+
+ // Remove runtime-only map flags from exported entities.
+ entMap.Remove("mapInit");
+ entMap.Remove("paused");
+
+ var hasProtoGroup = false;
+ var allowFillComponents = false;
+ HashSet? protoMissing = null;
+ var dropByPrototypeComponent = false;
+ EntityPrototype? entityProto = null;
+
+ if (protoMap.TryGet("proto", out ValueDataNode? protoIdNode) && protoIdNode != null)
+ {
+ hasProtoGroup = true;
+ var protoId = protoIdNode.Value;
+ allowFillComponents = FillComponentWhitelistPrototypes.Contains(protoId);
+
+ if (FilteredPrototypes.Contains(protoId))
+ {
+ if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull)
+ removedEntityUids.Add(removedUidNode.Value);
+
+ entitiesSeq.RemoveAt(i);
+ i--;
+ continue;
+ }
+
+ if (prototypeManager.TryIndex(protoId, out var proto))
+ {
+ entityProto = proto;
+
+ foreach (var componentName in FilteredEntityByComponentTypes)
+ {
+ if (!proto.Components.ContainsKey(componentName))
+ continue;
+
+ if (ComponentExclusionExceptions.TryGetValue(componentName, out var exceptionComponent)
+ && proto.Components.ContainsKey(exceptionComponent))
+ continue;
+
+ dropByPrototypeComponent = true;
+ break;
+ }
+
+ if (!allowFillComponents && proto.Components.ContainsKey("Door"))
+ allowFillComponents = true;
+
+ if (!allowFillComponents)
+ {
+ foreach (var name in ForcedMissingComponents)
+ {
+ if (!proto.Components.ContainsKey(name))
+ continue;
+
+ protoMissing ??= new HashSet(StringComparer.Ordinal);
+ protoMissing.Add(name);
+ }
+ }
+ }
+ }
+
+ if (!entMap.TryGet("components", out SequenceDataNode? comps) || comps == null)
+ {
+ if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull)
+ removedEntityUids.Add(removedUidNode.Value);
+
+ entitiesSeq.RemoveAt(i);
+ i--;
+ continue;
+ }
+
+ // Remove implanters containing blocked implant entities.
+ var isImplanter = entityProto?.Components.ContainsKey("Implanter") == true || HasComponentNode(comps, "Implanter");
+ if (isImplanter && HasBlockedContainedImplant(entMap, blockedContainedImplantEntityUids))
+ {
+ if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull)
+ removedEntityUids.Add(removedUidNode.Value);
+
+ entitiesSeq.RemoveAt(i);
+ i--;
+ continue;
+ }
+
+ var dropByComponent = false;
+ foreach (var c in comps)
+ {
+ if (c is not MappingDataNode cm)
+ continue;
+
+ if (!cm.TryGet("type", out ValueDataNode? t) || t == null)
+ continue;
+
+ if (!FilteredEntityByComponentTypes.Contains(t.Value))
+ continue;
+
+ if (ComponentExclusionExceptions.TryGetValue(t.Value, out var exceptionComponent)
+ && (HasComponentNode(comps, exceptionComponent)
+ || entityProto?.Components.ContainsKey(exceptionComponent) == true))
+ continue;
+
+ dropByComponent = true;
+ break;
+ }
+
+ if (dropByComponent)
+ {
+ if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull)
+ removedEntityUids.Add(removedUidNode.Value);
+
+ entitiesSeq.RemoveAt(i);
+ i--;
+ continue;
+ }
+
+ if (dropByPrototypeComponent)
+ {
+ if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull)
+ removedEntityUids.Add(removedUidNode.Value);
+
+ entitiesSeq.RemoveAt(i);
+ i--;
+ continue;
+ }
+
+ // Grid root gets slightly different Transform sanitation.
+ var hasMapGrid = false;
+ var compsNotNull = comps;
+ var paintStylePrototype = GetPaintStylePrototype(compsNotNull);
+
+ foreach (var c in compsNotNull)
+ {
+ if (c is MappingDataNode cm && cm.TryGet("type", out ValueDataNode? t) && t != null && t.Value == "MapGrid")
+ {
+ hasMapGrid = true;
+ break;
+ }
+ }
+
+ var hasDoorComponent = false;
+ foreach (var c in compsNotNull)
+ {
+ if (c is MappingDataNode cm && cm.TryGet("type", out ValueDataNode? t) && t != null && t.Value == "Door")
+ {
+ hasDoorComponent = true;
+ break;
+ }
+ }
+
+ if (hasDoorComponent)
+ {
+ allowFillComponents = true;
+ protoMissing = null;
+ }
+
+ // Build sanitized component list for this entity.
+ var newComps = new SequenceDataNode();
+ var removedFromPrototype = hasProtoGroup ? new HashSet(StringComparer.Ordinal) : null;
+
+ foreach (var compNode in compsNotNull)
+ {
+ if (compNode is not MappingDataNode compMap)
+ continue;
+
+ if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null)
+ {
+ newComps.Add(compMap);
+ continue;
+ }
+
+ var typeName = typeNode.Value;
+
+ if (FilteredTypes.Contains(typeName))
+ {
+ if (allowFillComponents && FillComponentTypes.Contains(typeName))
+ {
+ newComps.Add(compMap);
+ continue;
+ }
+
+ if (ForcedMissingComponents.Contains(typeName))
+ removedFromPrototype?.Add(typeName);
+
+ continue;
+ }
+
+ if (typeName == "Transform" && hasMapGrid)
+ compMap.Remove("rot");
+
+ if (typeName == "Appearance" && paintStylePrototype != null)
+ ApplyPaintStyleToAppearance(compMap, paintStylePrototype);
+
+ if (typeName == "SpreaderGrid")
+ {
+ compMap.Remove("updateAccumulator");
+ compMap.Remove("UpdateAccumulator");
+ }
+
+ if (typeName == "VendingMachine")
+ {
+ compMap.Remove("Inventory");
+ compMap.Remove("EmaggedInventory");
+ compMap.Remove("ContrabandInventory");
+ compMap.Remove("Contraband");
+ compMap.Remove("EjectEnd");
+ compMap.Remove("DenyEnd");
+ compMap.Remove("DispenseOnHitEnd");
+ compMap.Remove("NextEmpEject");
+ compMap.Remove("EjectRandomCounter");
+ }
+
+ if (typeName == "ResearchServer")
+ {
+ compMap.Remove("points");
+ compMap.Remove("Points");
+ compMap.Remove("pointsPerSecond");
+ compMap.Remove("PointsPerSecond");
+ }
+
+ if (typeName == "TechnologyDatabase")
+ {
+ compMap.Remove("unlockedTechnologies");
+ compMap.Remove("UnlockedTechnologies");
+ compMap.Remove("unlockedRecipes");
+ compMap.Remove("UnlockedRecipes");
+ compMap.Remove("currentTechnologyCards");
+ compMap.Remove("CurrentTechnologyCards");
+ compMap.Remove("mainDiscipline");
+ compMap.Remove("MainDiscipline");
+ }
+
+ if (typeName == "Battery")
+ {
+ compMap["currentCharge"] = new ValueDataNode("0");
+ compMap["CurrentCharge"] = new ValueDataNode("0");
+ }
+
+ if (typeName == "DeviceNetwork")
+ {
+ compMap.Remove("devices");
+ compMap.Remove("Devices");
+ }
+
+ if (typeName == "Solution")
+ {
+ if (compMap.TryGetValue("solution", out var solutionNode)
+ && solutionNode is MappingDataNode solutionMap
+ && solutionMap.TryGetValue("name", out var nameNode)
+ && nameNode is ValueDataNode nameValue
+ && nameValue.Value == "buffer")
+ {
+ solutionMap["canReact"] = new ValueDataNode("false");
+ }
+ }
+
+ if (typeName == "ReagentDispenser")
+ {
+ compMap.Remove("storageSlots");
+ compMap.Remove("storageSlotIds");
+ compMap.Remove("autoLabel");
+ }
+
+ newComps.Add(compMap);
+ }
+
+ // If SprayPainted exists without Appearance, synthesize Appearance so paint style persists in saves.
+ if (paintStylePrototype != null && !HasComponentNode(newComps, "Appearance"))
+ {
+ var appearanceComp = new MappingDataNode
+ {
+ ["type"] = new ValueDataNode("Appearance")
+ };
+
+ ApplyPaintStyleToAppearance(appearanceComp, paintStylePrototype);
+ newComps.Add(appearanceComp);
+ }
+
+ if ((removedFromPrototype != null && removedFromPrototype.Count > 0) || (protoMissing != null && protoMissing.Count > 0))
+ {
+ var existingMissing = new HashSet(StringComparer.Ordinal);
+ if (entMap.TryGet("missingComponents", out SequenceDataNode? missingNode) && missingNode != null)
+ {
+ foreach (var missing in missingNode)
+ {
+ if (missing is ValueDataNode value)
+ existingMissing.Add(value.Value);
+ }
+ }
+
+ var mergedSet = new HashSet(existingMissing, StringComparer.Ordinal);
+ if (removedFromPrototype != null)
+ {
+ foreach (var name in removedFromPrototype)
+ mergedSet.Add(name);
+ }
+
+ if (protoMissing != null)
+ {
+ foreach (var name in protoMissing)
+ mergedSet.Add(name);
+ }
+
+ if (allowFillComponents)
+ {
+ foreach (var name in FillComponentTypes)
+ mergedSet.Remove(name);
+ }
+
+ if (mergedSet.Count > 0)
+ {
+ var mergedMissing = new SequenceDataNode();
+ foreach (var name in mergedSet)
+ mergedMissing.Add(new ValueDataNode(name));
+
+ entMap["missingComponents"] = mergedMissing;
+ }
+ }
+
+ if (newComps.Count > 0)
+ {
+ entMap["components"] = newComps;
+ }
+ else
+ {
+ if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull)
+ removedEntityUids.Add(removedUidNode.Value);
+
+ entitiesSeq.RemoveAt(i);
+ i--;
+ }
+ }
+ }
+
+ // Final pass: remove stale container/storage references to entities dropped above.
+ PruneContainerReferencesToRemovedEntities(protoSeq, removedEntityUids);
+ }
+
+ private static string? GetPaintStylePrototype(SequenceDataNode components)
+ {
+ foreach (var compNode in components)
+ {
+ if (compNode is not MappingDataNode compMap)
+ continue;
+
+ if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null || typeNode.Value != "SprayPainted")
+ continue;
+
+ if (!compMap.TryGet("paintedPrototype", out ValueDataNode? styleNode) || styleNode == null)
+ continue;
+
+ if (!string.IsNullOrWhiteSpace(styleNode.Value))
+ return styleNode.Value;
+ }
+
+ return null;
+ }
+
+ private static bool HasComponentNode(SequenceDataNode components, string componentType)
+ {
+ foreach (var compNode in components)
+ {
+ if (compNode is not MappingDataNode compMap)
+ continue;
+
+ if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null)
+ continue;
+
+ if (typeNode.Value == componentType)
+ return true;
+ }
+
+ return false;
+ }
+
+ private static HashSet CollectBlockedContainedImplantEntityUids(SequenceDataNode protoSeq)
+ {
+ // Resolve concrete entity UIDs for blocked implant prototypes so implanter checks are cheap per entity.
+ var blockedImplantUids = new HashSet(StringComparer.Ordinal);
+
+ foreach (var protoNode in protoSeq)
+ {
+ if (protoNode is not MappingDataNode protoMap)
+ continue;
+
+ if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null)
+ continue;
+
+ var protoIsBlockedImplant = false;
+ if (protoMap.TryGet("proto", out ValueDataNode? protoIdNode)
+ && protoIdNode != null
+ && !protoIdNode.IsNull)
+ {
+ protoIsBlockedImplant = BlockedContainedImplantPrototypes.Contains(protoIdNode.Value);
+ }
+
+ foreach (var entityNode in entitiesSeq)
+ {
+ if (entityNode is not MappingDataNode entMap)
+ continue;
+
+ if (!entMap.TryGet("uid", out ValueDataNode? uidNode) || uidNode == null || uidNode.IsNull)
+ continue;
+
+ if (protoIsBlockedImplant)
+ blockedImplantUids.Add(uidNode.Value);
+ }
+ }
+
+ return blockedImplantUids;
+ }
+
+ private static bool HasBlockedContainedImplant(MappingDataNode entMap, HashSet blockedContainedImplantEntityUids)
+ {
+ if (blockedContainedImplantEntityUids.Count == 0)
+ return false;
+
+ if (!entMap.TryGet("components", out SequenceDataNode? components) || components == null)
+ return false;
+
+ foreach (var compNode in components)
+ {
+ if (compNode is not MappingDataNode compMap)
+ continue;
+
+ if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null || typeNode.Value != "ContainerContainer")
+ continue;
+
+ if (!compMap.TryGet("containers", out MappingDataNode? containersMap) || containersMap == null)
+ continue;
+
+ if (!containersMap.TryGet("implanter_slot", out MappingDataNode? slotMap) || slotMap == null)
+ continue;
+
+ if (slotMap.TryGet("ent", out ValueDataNode? entNode) && entNode != null && !entNode.IsNull && blockedContainedImplantEntityUids.Contains(entNode.Value))
+ return true;
+
+ if (!slotMap.TryGet("ents", out SequenceDataNode? entsNode) || entsNode == null)
+ continue;
+
+ foreach (var entry in entsNode)
+ {
+ if (entry is not ValueDataNode valueNode || valueNode.IsNull)
+ continue;
+
+ if (blockedContainedImplantEntityUids.Contains(valueNode.Value))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static void PruneContainerReferencesToRemovedEntities(SequenceDataNode protoSeq, HashSet removedEntityUids)
+ {
+ if (removedEntityUids.Count == 0)
+ return;
+
+ // Remove dangling references from both ContainerContainer and Storage serialized structures.
+ foreach (var protoNode in protoSeq)
+ {
+ if (protoNode is not MappingDataNode protoMap)
+ continue;
+
+ if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null)
+ continue;
+
+ foreach (var entityNode in entitiesSeq)
+ {
+ if (entityNode is not MappingDataNode entMap)
+ continue;
+
+ if (!entMap.TryGet("components", out SequenceDataNode? comps) || comps == null)
+ continue;
+
+ foreach (var compNode in comps)
+ {
+ if (compNode is not MappingDataNode compMap)
+ continue;
+
+ if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null)
+ continue;
+
+ var componentType = typeNode.Value;
+
+ if (componentType == "ContainerContainer")
+ {
+ if (!compMap.TryGet("containers", out MappingDataNode? containersMap) || containersMap == null)
+ continue;
+
+ foreach (var (_, containerNode) in containersMap)
+ {
+ if (containerNode is not MappingDataNode containerMap)
+ continue;
+
+ if (containerMap.TryGet("ents", out SequenceDataNode? entsNode) && entsNode != null)
+ {
+ for (var idx = entsNode.Count - 1; idx >= 0; idx--)
+ {
+ if (entsNode[idx] is not ValueDataNode entValue || entValue.IsNull)
+ continue;
+
+ if (removedEntityUids.Contains(entValue.Value))
+ entsNode.RemoveAt(idx);
+ }
+ }
+
+ if (containerMap.TryGet("ent", out ValueDataNode? entNode) && entNode != null && !entNode.IsNull)
+ {
+ if (removedEntityUids.Contains(entNode.Value))
+ containerMap["ent"] = ValueDataNode.Null();
+ }
+ }
+
+ continue;
+ }
+
+ if (componentType == "Storage" && compMap.TryGet("storedItems", out MappingDataNode? storedItemsMap) && storedItemsMap != null)
+ {
+ var removeKeys = new List();
+ foreach (var (itemUid, _) in storedItemsMap)
+ {
+ if (removedEntityUids.Contains(itemUid))
+ removeKeys.Add(itemUid);
+ }
+
+ foreach (var key in removeKeys)
+ storedItemsMap.Remove(key);
+ }
+ }
+ }
+ }
+ }
+
+ private static void ApplyPaintStyleToAppearance(MappingDataNode appearanceComp, string stylePrototype)
+ {
+ MappingDataNode appearanceDataInit;
+ if (appearanceComp.TryGet("appearanceDataInit", out MappingDataNode? existing) && existing != null)
+ {
+ appearanceDataInit = existing;
+ }
+ else
+ {
+ appearanceDataInit = new MappingDataNode();
+ appearanceComp["appearanceDataInit"] = appearanceDataInit;
+ }
+
+ appearanceDataInit["enum.PaintableVisuals.Prototype"] = new ValueDataNode(stylePrototype);
+ }
+}
diff --git a/Content.Server/_NF/Shipyard/Systems/ShipyardGridSaveSystem.cs b/Content.Server/_NF/Shipyard/Systems/ShipyardGridSaveSystem.cs
index ce56e396f3b..66ef55603b8 100644
--- a/Content.Server/_NF/Shipyard/Systems/ShipyardGridSaveSystem.cs
+++ b/Content.Server/_NF/Shipyard/Systems/ShipyardGridSaveSystem.cs
@@ -1,49 +1,48 @@
+using System.IO;
+using System.Threading.Tasks;
+using Content.Server.Atmos.Piping.Components;
using Content.Server.Chemistry.Components;
-using Content.Shared.Chemistry.EntitySystems;
+using Content.Server.Construction.Components;
+using Content.Server.Power.Components;
+using Content.Server.Power.EntitySystems;
+using Content.Server.Store.Components; // HardLight
+using Content.Server._HL.Shipyard; // HardLight
+using Content.Shared._Common.Consent; // HardLight
+using Content.Shared._HL.Shipyard; // HardLight
using Content.Shared._NF.Shipyard.Components;
using Content.Shared._NF.Shipyard.Events;
+using Content.Shared.Chemistry.Components;
+using Content.Shared.Chemistry.Components.SolutionManager;
+using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.DeviceLinking;
using Content.Shared.DeviceLinking.Components;
+using Content.Shared.Implants.Components; // HardLight
+using Content.Shared.Light.Components; // HardLight
+using Content.Shared.Mind.Components; // HardLight
using Content.Shared.Shuttles.Save; // For SendShipSaveDataClientMessage
-using Content.Server.Atmos.Piping.Components;
-using Content.Server.Power.Components;
-using Content.Server.Power.EntitySystems;
+using Content.Shared.SprayPainter.Components; // HardLight
+using Content.Shared.SprayPainter.Prototypes; // HardLight
+using Content.Shared.Storage.Components;
using Content.Shared.VendingMachines;
-using Robust.Shared.Player;
-using Robust.Shared.Map.Components;
-using Robust.Shared.EntitySerialization.Systems;
-using Robust.Shared.Utility;
-using Robust.Shared.ContentPack;
+using Content.Shared.Wall; // WallMountComponent for preserving wall-mounted fixtures
+using Robust.Server.GameObjects; // HardLight
using Robust.Server.Player;
+using Robust.Shared.Containers;
+using Robust.Shared.ContentPack;
using Robust.Shared.EntitySerialization;
+using Robust.Shared.EntitySerialization.Systems;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Physics;
using Robust.Shared.Physics.Components;
-using Robust.Shared.Containers;
-using System.IO;
-using System.Threading.Tasks;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes; // HardLight
+using Robust.Shared.Serialization;
using Robust.Shared.Serialization.Markdown.Mapping;
using Robust.Shared.Serialization.Markdown.Sequence;
using Robust.Shared.Serialization.Markdown.Value;
-using YamlDotNet.RepresentationModel;
+using Robust.Shared.Utility;
using YamlDotNet.Core;
-using Robust.Shared.Serialization;
-using Content.Shared.Storage.Components;
-using Content.Shared.Wall; // WallMountComponent for preserving wall-mounted fixtures
-using Robust.Shared.Physics;
-using Content.Shared.Chemistry.Components;
-using Content.Shared.Chemistry.Components.SolutionManager;
-using Content.Server.Construction.Components;
-using Content.Shared._HL.Shipyard;
-// HardLight start
-using Content.Server.Store.Components;
-using Content.Shared._Common.Consent;
-using Content.Shared.Implants.Components;
-using Content.Shared.Light.Components;
-using Content.Shared.Mind.Components;
-using Content.Shared.SprayPainter.Components;
-using Content.Shared.SprayPainter.Prototypes;
-using Robust.Server.GameObjects;
-using Robust.Shared.Prototypes;
-// HardLight end
+using YamlDotNet.RepresentationModel;
namespace Content.Server._NF.Shipyard.Systems;
@@ -54,8 +53,7 @@ namespace Content.Server._NF.Shipyard.Systems;
///
public sealed class ShipyardGridSaveSystem : EntitySystem
{
- // HardLight start
- // List of currency prototypes that should be stripped from ship saves.
+ // HardLight: List of currency prototypes that should be stripped from ship saves.
private static readonly HashSet NonPersistentShipSaveCurrencies = new(StringComparer.Ordinal)
{
// "FrontierUplinkCoin",
@@ -64,15 +62,6 @@ public sealed class ShipyardGridSaveSystem : EntitySystem
// triad change rare enough for these who cares
};
- // Implants that should not persist when found inside implanters during ship save.
- private static readonly HashSet BlockedContainedImplantPrototypes = new(StringComparer.Ordinal)
- {
- "DeathRattleImplantColcomm",
- "RadioImplantColcomm",
- "UplinkImplant",
- };
- // HardLight end
-
[Dependency] private readonly IEntityManager _entityManager = default!;
[Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
@@ -742,827 +731,9 @@ private void StampSprayPaintedPrototypesOnGrid(EntityUid gridUid)
///
private void SanitizeShipSaveNode(MappingDataNode root)
{
- // Ensure nullspace is empty
- try
- {
- root["nullspace"] = new SequenceDataNode();
- }
- catch (Exception e)
- {
- _sawmill.Warning($"Failed to clear nullspace: {e.Message}");
- }
-
- if (!root.TryGet("entities", out SequenceDataNode? protoSeq) || protoSeq == null)
- return;
-
- var filteredTypes = new HashSet // HardLight: Components you want removed from entities on save and restored on load go here.
- {
- "Joint",
- "StationMember",
- "NavMap",
- "ShuttleDeed",
- "IFF",
- "LinkedLifecycleGridParent",
- "AccessReader", // Door logs
- // "DeviceList", Don't remove this it breaks air alarms.
- "DeviceNetwork",
- "DeviceNetworkComponent",
- "UserInterface", // Contains invalid EntityUid references
- "Docking", // Contains invalid EntityUid references to docked entities
- "ActionGrant", // Contains invalid EntityUid references to granted actions
- "Mind", // Contains player state that can't be serialized and isn't relevant to ship blueprints
- "MindContainer", // Contains player state that can't be serialized and isn't relevant to ship blueprints
- "VendingMachine", // Vending machines restock on ship load, sometimes infinitely; this should prevent that behavior
- "Forensics", // Entirely useless information that causes extreme ship file bloat
- };
-
- var fillComponentTypes = new HashSet(StringComparer.Ordinal) // HardLight: Components you want permanently removed from entities go here.
- {
- "StorageFill", // Remove refill-on-spawn behavior on ship save only
- "ContainerFill", // Remove refill-on-spawn behavior on ship save only
- "EntityTableContainerFill", // Remove refill-on-spawn behavior on ship save only
- "SurplusBundle", // Syndicate Surplus Crates refill on ship load; this should prevent that behavior
- };
-
- var fillComponentWhitelistPrototypes = new HashSet(StringComparer.InvariantCultureIgnoreCase) // HardLight: Entities you want to bypass the fill component removal for go here.
- {
- "AirAlarm",
- };
-
- ///
- /// HardLight: Explicitly preserve case-sensitive component names that are known to cause issues if included in ship saves, even if they exist on entities.
- /// This is a blunt tool but it ensures we won't accidentally break ship saves by adding new components in the future without remembering to filter them here.
- ///
- var forcedMissingComponents = new HashSet(StringComparer.Ordinal)
- {
- "StorageFill",
- "ContainerFill",
- "EntityTableContainerFill",
- "SurplusBundle",
- };
-
- // HardLight start
- // Prototype-level exclusions for obvious non-ship entities.
- // If we encounter these, we drop them entirely from the export.
- var filteredPrototypes = new HashSet(StringComparer.InvariantCultureIgnoreCase)
- {
- // Machines & circuit boards
- "MachineFlatpacker", // One day.
- "CommsComputerCircuitboard",
- "ComputerDNAScanner",
- "ComputerExpeditionDiskPrinter",
- "ComputerFundingAllocation",
- "ComputerPsionicsRecords",
- "ComputerRoboticsControl",
- "ComputerShuttleRecords",
- "ComputerTabletopShuttleAntag", // I'm a bit confused about this one.
- "DnaScannerConsoleComputerCircuitboard",
- "IDComputerCircuitboard",
- "StationAiUploadComputer",
- // Vending machines
- "DEBUGVendingMachineAmmoBoxes",
- "DEBUGVendingMachineMagazines",
- "DEBUGVendingMachineRangedWeapons",
- "VendingMachineAmmoPOI",
- "VendingMachineAstroVendPOI",
- "VendingMachineBoozePOI",
- "VendingMachineBountyVendPOI",
- "VendingMachineCigsPOI",
- "VendingMachineEngivendPOI",
- "VendingMachineExpeditionaryFlatpackVend",
- "VendingMachineFlatpackVend",
- "VendingMachineFuelVend",
- "VendingMachineGamesPOI",
- "LessLethalVendingMachinePOI",
- "VendingMachineMediDrobePOI",
- "VendingMachineMercVend",
- "VendingMachinePickNPackPOI",
- "VendingMachinePottedPlantVendPOI",
- "VendingMachineSalvagePOI",
- "VendingMachineSyndieContraband",
- "VendingMachineTankDispenserEVAPOI",
- "VendingMachineVendomatPOI",
- "VendingMachineYouToolPOI",
- // Everything else
- "ReactorGasPipe", // Nuclear reactors duplicate invisible inlet/outlet pipes on save.
- "ShipShield", // Ship shield emitters duplicate ship shield visuals on save.
- "BaseMercenaryUplinkRadio",
- "BaseUplinkRadio",
- "BaseUplinkRadio20TC",
- "BaseUplinkRadio25TC",
- "BaseUplinkRadio40TC",
- "BaseUplinkRadio60TC",
- "UplinkImplanter",
- "BaseSecurityUplinkRadio",
- "BaseSecurityUplinkRadioSheriff",
- "BaseSecurityUplinkRadioOfficer",
- "BaseSecurityUplinkRadioDeputy",
- "BasePirateUplink",
- "BasePirateUplinkRadioPirateCaptain",
- "BasePirateUplinkPirateCrew",
- };
-
- // Component-level exclusions for non-ship entities.
- // If an entity has ANY of these components, drop the entity from export entirely.
- var filteredEntityByComponentTypes = new HashSet(StringComparer.Ordinal)
- {
- "CommunicationsConsole",
- "ContrabandPalletConsole",
- "CriminalRecordsConsole",
- "DnaSequenceInjector",
- "DoorRemote",
- "EmergencyShuttleConsole",
- "GeneralStationRecordConsole",
- "GeneticAnalyzer",
- "Ghost",
- "GhostRole",
- "HumanoidAppearance",
- "IdCard",
- "IdCardConsole",
- "MarketConsole",
- "NFCargoOrderConsole",
- "Pda",
- "ShipyardConsole",
- "Store",
- };
-
- // Exclusion exceptions: if the excluded component is present alongside its paired allow component,
- // keep the entity.
- var componentExclusionExceptions = new Dictionary(StringComparer.Ordinal)
- {
- ["ShipyardConsole"] = "ShipyardListing",
- };
-
- // Track removed entities so we can prune stale container references afterward.
- var removedEntityUids = new HashSet(StringComparer.Ordinal);
-
- // UIDs of implant entities that should be stripped from implanters on ship save.
- // Matching is based on explicit prototype IDs.
- var blockedContainedImplantEntityUids = CollectBlockedContainedImplantEntityUids(protoSeq);
- // HardLight end
-
- foreach (var protoNode in protoSeq)
- {
- if (protoNode is not MappingDataNode protoMap)
- continue;
-
- if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null)
- continue;
-
- for (var i = 0; i < entitiesSeq.Count; i++)
- {
- if (entitiesSeq[i] is not MappingDataNode entMap)
- continue;
-
- // Remove map initialization flags
- entMap.Remove("mapInit");
- entMap.Remove("paused");
-
- // Optional: Drop entities that are clearly unrelated by prototype id.
- // Each proto group node contains a "proto" key with the prototype id string.
- var hasProtoGroup = false;
- var allowFillComponents = false; // HardLight
- HashSet? protoMissing = null;
- var dropByPrototypeComponent = false; // HardLight
- EntityPrototype? entityProto = null; // HardLight
- if (protoMap.TryGet("proto", out ValueDataNode? protoIdNode) && protoIdNode != null)
- {
- hasProtoGroup = true;
- var protoId = protoIdNode.Value;
- allowFillComponents = fillComponentWhitelistPrototypes.Contains(protoId); // HardLight
- if (filteredPrototypes.Contains(protoId))
- {
- // Remove this entity entirely
- if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) // HardLight
- removedEntityUids.Add(removedUidNode.Value); // HardLight
- entitiesSeq.RemoveAt(i);
- i--;
- continue;
- }
-
- // HardLight start: If this prototype is on the fill component whitelist, allow fill components to remain even if they are on the forced missing list.
- // This ensures critical entities like air alarms keep their fill components for proper functionality.
- if (_prototypeManager.TryIndex(protoId, out var proto))
- {
- entityProto = proto;
-
- foreach (var componentName in filteredEntityByComponentTypes)
- {
- if (!proto.Components.ContainsKey(componentName))
- continue;
-
- if (componentExclusionExceptions.TryGetValue(componentName, out var exceptionComponent)
- && proto.Components.ContainsKey(exceptionComponent))
- continue;
-
- dropByPrototypeComponent = true;
- break;
- }
-
- if (!allowFillComponents && proto.Components.ContainsKey("Door"))
- allowFillComponents = true;
-
- if (!allowFillComponents) // If a prototype contains components that are forced missing, track them so we can remove those components from all entities of that prototype below.
- {
- foreach (var name in forcedMissingComponents)
- {
- if (proto.Components.ContainsKey(name))
- {
- protoMissing ??= new HashSet(StringComparer.Ordinal);
- protoMissing.Add(name);
- }
- }
- }
- }
- // HardLight end
- }
-
- // Components cleanup
- if (!entMap.TryGet("components", out SequenceDataNode? comps) || comps == null)
- {
- // If there are no components left, this entity is empty and can be removed
- // HardLight start
- if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull)
- removedEntityUids.Add(removedUidNode.Value);
- entitiesSeq.RemoveAt(i);
- i--;
- continue;
- // HardLight end
- }
-
- // HardLight start
- // If this entity is an implanter currently containing a filtered implant entity,
- // remove the implanter from export.
- var isImplanter = entityProto?.Components.ContainsKey("Implanter") == true || HasComponentNode(comps, "Implanter");
- if (isImplanter && HasBlockedContainedImplant(entMap, blockedContainedImplantEntityUids))
- {
- if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull)
- removedEntityUids.Add(removedUidNode.Value);
- entitiesSeq.RemoveAt(i);
- i--;
- continue;
- }
-
- // Optional: Drop entities by component signature.
- // If an entity has any component in filteredEntityByComponentTypes, remove the entire entity.
- var dropByComponent = false;
- foreach (var c in comps)
- {
- if (c is not MappingDataNode cm)
- continue;
-
- if (!cm.TryGet("type", out ValueDataNode? t) || t == null)
- continue;
-
- if (!filteredEntityByComponentTypes.Contains(t.Value))
- continue;
-
- if (componentExclusionExceptions.TryGetValue(t.Value, out var exceptionComponent)
- && (HasComponentNode(comps, exceptionComponent)
- || entityProto?.Components.ContainsKey(exceptionComponent) == true))
- continue;
-
- dropByComponent = true;
- break;
- }
-
- if (dropByComponent)
- {
- if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull)
- removedEntityUids.Add(removedUidNode.Value);
- entitiesSeq.RemoveAt(i);
- i--;
- continue;
- }
- // HardLight end
-
- if (dropByPrototypeComponent) // HardLight
- {
- if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) // HardLight
- removedEntityUids.Add(removedUidNode.Value); // HardLight
- entitiesSeq.RemoveAt(i);
- i--;
- continue;
- }
-
- // Determine if this entity is the grid root (has MapGrid component)
- var hasMapGrid = false;
- var compsNotNull = comps!; // Assert non-null for analyzer; guarded above.
- var paintStylePrototype = GetPaintStylePrototype(compsNotNull); // HardLight
- foreach (var c in compsNotNull)
- {
- if (c is MappingDataNode cm && cm.TryGet("type", out ValueDataNode? t) && t != null && t.Value == "MapGrid")
- {
- hasMapGrid = true;
- break;
- }
- }
-
- // HardLight start: If this entity has a Door component, we should allow fill components to remain even if they are on the forced missing list,
- // since many doors require a StorageFill or ContainerFill to function properly.
- var hasDoorComponent = false;
- foreach (var c in compsNotNull)
- {
- if (c is MappingDataNode cm && cm.TryGet("type", out ValueDataNode? t) && t != null && t.Value == "Door")
- {
- hasDoorComponent = true;
- break;
- }
- }
-
- if (hasDoorComponent)
- {
- allowFillComponents = true;
- // Door-based allow list should not mark fill components as missing.
- protoMissing = null;
- }
- // HardLight end
-
- var newComps = new SequenceDataNode();
- var removedFromPrototype = hasProtoGroup ? new HashSet(StringComparer.Ordinal) : null; // HardLight: Track which components were removed due to prototype-level filtering so we can log them at the end.
- foreach (var compNode in compsNotNull)
- {
- if (compNode is not MappingDataNode compMap)
- continue;
-
- if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null)
- {
- newComps.Add(compMap);
- continue;
- }
-
- var typeName = typeNode.Value;
-
- // Filter out undesired component types entirely
- if (filteredTypes.Contains(typeName))
- {
- // HardLight start: If this component is on a prototype with forced missing components, track it so we can remove those components from all entities of that prototype below.
- if (allowFillComponents && fillComponentTypes.Contains(typeName))
- {
- newComps.Add(compMap);
- continue;
- }
-
- if (forcedMissingComponents.Contains(typeName))
- removedFromPrototype?.Add(typeName);
- continue;
- // HardLight end
- }
-
- // Transform: remove rotation on the grid root to match blueprint expectations
- if (typeName == "Transform" && hasMapGrid)
- {
- compMap.Remove("rot");
- }
-
- // HardLight - Appearance: if this entity has an Appearance component and we found a paint style prototype from SprayPainted,
- // apply that paint style to the Appearance component so it gets saved with the ship.
- if (typeName == "Appearance" && paintStylePrototype != null)
- {
- ApplyPaintStyleToAppearance(compMap, paintStylePrototype);
- }
-
- // Gravity: preserve enabled state so gravity persists on ship load
- // (Removed stripping of enabled field)
-
- // SpreaderGrid: strip accumulator fields
- if (typeName == "SpreaderGrid")
- {
- compMap.Remove("updateAccumulator");
- compMap.Remove("UpdateAccumulator");
- }
-
- // VendingMachine: strip runtime inventory & timers to match blueprint expectations
- if (typeName == "VendingMachine")
- {
- compMap.Remove("Inventory");
- compMap.Remove("EmaggedInventory");
- compMap.Remove("ContrabandInventory");
- compMap.Remove("Contraband");
- compMap.Remove("EjectEnd");
- compMap.Remove("DenyEnd");
- compMap.Remove("DispenseOnHitEnd");
- compMap.Remove("NextEmpEject");
- compMap.Remove("EjectRandomCounter");
- }
-
- // DeviceLink: Keep device links intact - they should persist
- // (Removed clearing of linkedPorts and links)
-
- // Solution/SolutionContainer: Keep solution contents - they should persist
- // (Removed clearing of solutions and contents)
-
- // ResearchServer: reset research server state
- if (typeName == "ResearchServer")
- {
- compMap.Remove("points");
- compMap.Remove("Points");
- compMap.Remove("pointsPerSecond");
- compMap.Remove("PointsPerSecond");
- }
-
- // TechnologyDatabase: reset unlocked technologies and recipes
- if (typeName == "TechnologyDatabase")
- {
- compMap.Remove("unlockedTechnologies");
- compMap.Remove("UnlockedTechnologies");
- compMap.Remove("unlockedRecipes");
- compMap.Remove("UnlockedRecipes");
- compMap.Remove("currentTechnologyCards");
- compMap.Remove("CurrentTechnologyCards");
- compMap.Remove("mainDiscipline");
- compMap.Remove("MainDiscipline");
- }
-
- // Battery: reset charge to 0
- // Battery: reset charge to 0
- if (typeName == "Battery")
- {
- compMap["currentCharge"] = new ValueDataNode("0");
- compMap["CurrentCharge"] = new ValueDataNode("0");
- }
-
- // SolutionContainerManager: DO NOT MODIFY - preserve all solution data
- // This is critical for ChemMaster buffers and other solution containers
- if (typeName == "SolutionContainerManager")
- {
- // Explicitly DO NOTHING - let the solution data pass through unchanged
- // The bug was that solutions were being modified or cleared somewhere
-
- // Log the solution data for debugging
- if (compMap.TryGetValue("solutions", out var solutionsNode) && solutionsNode is MappingDataNode solutionsMap)
- {
- foreach (var (solutionName, solutionData) in solutionsMap)
- {
- Logger.GetSawmill("hardlight").Info($"Preserving solution '{solutionName}' in SolutionContainerManager");
-
- if (solutionData is MappingDataNode solutionMap)
- {
- if (solutionMap.TryGetValue("contents", out var contentsNode) && contentsNode is SequenceDataNode contents)
- {
- Logger.GetSawmill("hardlight").Info($" Solution has {contents.Count} reagent entries");
-
- // Verify each reagent entry maintains its structure
- foreach (var contentNode in contents)
- {
- if (contentNode is MappingDataNode reagentMap)
- {
- if (reagentMap.TryGetValue("ReagentId", out var reagentIdNode) && reagentIdNode is ValueDataNode reagentId &&
- reagentMap.TryGetValue("Quantity", out var quantityNode) && quantityNode is ValueDataNode quantity)
- {
- Logger.GetSawmill("hardlight").Info($" - ReagentId: {reagentId.Value}, Quantity: {quantity.Value}");
- }
- }
- }
- }
- }
- }
- }
- }
-
- // DeviceNetwork: clear device lists that contain invalid EntityUid references
- if (typeName == "DeviceNetwork")
- {
- compMap.Remove("devices");
- compMap.Remove("Devices");
- }
-
- // UserInterface: remove to prevent invalid EntityUid references
- // (This is handled by filteredTypes but adding explicit note)
-
- // Docking: remove to prevent invalid EntityUid references to docked entities
- // (This is handled by filteredTypes but adding explicit note)
-
- // ActionGrant: remove to prevent invalid EntityUid references to granted actions
- // (This is handled by filteredTypes but adding explicit note)
-
- // Solution: Force canReact to false to prevent reagent mixing on load
- // Only apply to solutions named "buffer" which are ChemMaster buffers, instead of literally everything
- if (typeName == "Solution")
- {
- // Check if this component has a solution field
- if (compMap.TryGetValue("solution", out var solutionNode) &&
- solutionNode is MappingDataNode solutionMap)
- {
- // Only set canReact to false for ChemMaster buffers
- // Check if the solution name is "buffer"
- if (solutionMap.TryGetValue("name", out var nameNode) &&
- nameNode is ValueDataNode nameValue &&
- nameValue.Value == "buffer")
- {
- // This is a ChemMaster buffer - prevent mixing
- solutionMap["canReact"] = new ValueDataNode("false");
- Logger.GetSawmill("hardlight").Info("Set ChemMaster buffer to non-reactive");
- }
- }
- }
-
- // ReagentDispenser: Clear storage slot data to force regeneration
- if (typeName == "ReagentDispenser")
- {
- compMap.Remove("storageSlots");
- compMap.Remove("storageSlotIds");
- compMap.Remove("autoLabel");
-
- Logger.GetSawmill("hardlight").Info("Cleared ReagentDispenser storage slots for regeneration");
- }
- newComps.Add(compMap);
- }
-
- // HardLight start
- // If we found a paint style prototype from SprayPainted but the Appearance component was missing,
- // add an Appearance component with the paint style data so the ship saves with the correct paint appearance.
- if (paintStylePrototype != null && !HasComponentNode(newComps, "Appearance"))
- {
- var appearanceComp = new MappingDataNode
- {
- ["type"] = new ValueDataNode("Appearance")
- };
- ApplyPaintStyleToAppearance(appearanceComp, paintStylePrototype);
- newComps.Add(appearanceComp);
- }
-
- // If a prototype contains components that are forced missing,
- // we need to remove those components from all entities of that prototype below and log them for debugging.
- if (removedFromPrototype != null && removedFromPrototype.Count > 0 || protoMissing != null && protoMissing.Count > 0)
- {
- var existingMissing = new HashSet(StringComparer.Ordinal);
- if (entMap.TryGet("missingComponents", out SequenceDataNode? missingNode) && missingNode != null)
- {
- foreach (var missing in missingNode)
- {
- if (missing is ValueDataNode value)
- existingMissing.Add(value.Value);
- }
- }
-
- var mergedSet = new HashSet(existingMissing, StringComparer.Ordinal);
- if (removedFromPrototype != null)
- {
- foreach (var name in removedFromPrototype)
- mergedSet.Add(name);
- }
-
- if (protoMissing != null)
- {
- foreach (var name in protoMissing)
- mergedSet.Add(name);
- }
-
- // If this entity is allowed to have fill components,
- // make sure they aren't marked as missing even if the prototype has them forced missing.
- if (allowFillComponents)
- {
- foreach (var name in fillComponentTypes)
- mergedSet.Remove(name);
- }
-
- if (mergedSet.Count > 0)
- {
- var mergedMissing = new SequenceDataNode();
- foreach (var name in mergedSet)
- mergedMissing.Add(new ValueDataNode(name));
- entMap["missingComponents"] = mergedMissing;
- }
- }
- // HardLight end
-
- if (newComps.Count > 0)
- {
- entMap["components"] = newComps;
- }
- else
- {
- // No components left; remove the entire entity
- if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) // HardLight
- removedEntityUids.Add(removedUidNode.Value); // HardLight
- entitiesSeq.RemoveAt(i);
- i--;
- }
- }
- }
-
- // HardLight: Remove stale references to any entities deleted above.
- // Without this, parent storages/containers can retain dangling links.
- PruneContainerReferencesToRemovedEntities(protoSeq, removedEntityUids); // HardLight
- }
-
- // HardLight start: Helper method to scan components for a SprayPainted component and extract the painted prototype if it exists,
- // so we can apply that paint style to the Appearance component for proper saving.
- private static string? GetPaintStylePrototype(SequenceDataNode components)
- {
- foreach (var compNode in components)
- {
- if (compNode is not MappingDataNode compMap)
- continue;
-
- if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null || typeNode.Value != "SprayPainted")
- continue;
-
- if (!compMap.TryGet("paintedPrototype", out ValueDataNode? styleNode) || styleNode == null)
- continue;
-
- if (!string.IsNullOrWhiteSpace(styleNode.Value))
- return styleNode.Value;
- }
-
- return null;
- }
-
- private static bool HasComponentNode(SequenceDataNode components, string componentType)
- {
- foreach (var compNode in components)
- {
- if (compNode is not MappingDataNode compMap)
- continue;
-
- if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null)
- continue;
-
- if (typeNode.Value == componentType)
- return true;
- }
-
- return false;
- }
-
- private HashSet CollectBlockedContainedImplantEntityUids(SequenceDataNode protoSeq)
- {
- var blockedImplantUids = new HashSet(StringComparer.Ordinal);
-
- foreach (var protoNode in protoSeq)
- {
- if (protoNode is not MappingDataNode protoMap)
- continue;
-
- if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null)
- continue;
-
- var protoIsBlockedImplant = false;
- if (protoMap.TryGet("proto", out ValueDataNode? protoIdNode)
- && protoIdNode != null
- && !protoIdNode.IsNull)
- {
- protoIsBlockedImplant = BlockedContainedImplantPrototypes.Contains(protoIdNode.Value);
- }
-
- foreach (var entityNode in entitiesSeq)
- {
- if (entityNode is not MappingDataNode entMap)
- continue;
-
- if (!entMap.TryGet("uid", out ValueDataNode? uidNode) || uidNode == null || uidNode.IsNull)
- continue;
-
- if (protoIsBlockedImplant)
- blockedImplantUids.Add(uidNode.Value);
- }
- }
-
- return blockedImplantUids;
- }
-
- private static bool HasBlockedContainedImplant(MappingDataNode entMap, HashSet blockedContainedImplantEntityUids)
- {
- if (blockedContainedImplantEntityUids.Count == 0)
- return false;
-
- if (!entMap.TryGet("components", out SequenceDataNode? components) || components == null)
- return false;
-
- foreach (var compNode in components)
- {
- if (compNode is not MappingDataNode compMap)
- continue;
-
- if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null || typeNode.Value != "ContainerContainer")
- continue;
-
- if (!compMap.TryGet("containers", out MappingDataNode? containersMap) || containersMap == null)
- continue;
-
- if (!containersMap.TryGet("implanter_slot", out MappingDataNode? slotMap) || slotMap == null)
- continue;
-
- if (slotMap.TryGet("ent", out ValueDataNode? entNode) && entNode != null && !entNode.IsNull && blockedContainedImplantEntityUids.Contains(entNode.Value))
- return true;
-
- if (!slotMap.TryGet("ents", out SequenceDataNode? entsNode) || entsNode == null)
- continue;
-
- foreach (var entry in entsNode)
- {
- if (entry is not ValueDataNode valueNode || valueNode.IsNull)
- continue;
-
- if (blockedContainedImplantEntityUids.Contains(valueNode.Value))
- return true;
- }
- }
-
- return false;
- }
-
- private static void PruneContainerReferencesToRemovedEntities(SequenceDataNode protoSeq, HashSet removedEntityUids)
- {
- if (removedEntityUids.Count == 0)
- return;
-
- foreach (var protoNode in protoSeq)
- {
- if (protoNode is not MappingDataNode protoMap)
- continue;
-
- if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null)
- continue;
-
- foreach (var entityNode in entitiesSeq)
- {
- if (entityNode is not MappingDataNode entMap)
- continue;
-
- if (!entMap.TryGet("components", out SequenceDataNode? comps) || comps == null)
- continue;
-
- foreach (var compNode in comps)
- {
- if (compNode is not MappingDataNode compMap)
- continue;
-
- if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null)
- continue;
-
- var componentType = typeNode.Value;
-
- if (componentType == "ContainerContainer")
- {
- if (!compMap.TryGet("containers", out MappingDataNode? containersMap) || containersMap == null)
- continue;
-
- foreach (var (_, containerNode) in containersMap)
- {
- if (containerNode is not MappingDataNode containerMap)
- continue;
-
- if (containerMap.TryGet("ents", out SequenceDataNode? entsNode) && entsNode != null)
- {
- for (var idx = entsNode.Count - 1; idx >= 0; idx--)
- {
- if (entsNode[idx] is not ValueDataNode entValue || entValue.IsNull)
- continue;
-
- if (removedEntityUids.Contains(entValue.Value))
- entsNode.RemoveAt(idx);
- }
- }
-
- if (containerMap.TryGet("ent", out ValueDataNode? entNode) && entNode != null && !entNode.IsNull)
- {
- if (removedEntityUids.Contains(entNode.Value))
- containerMap["ent"] = ValueDataNode.Null();
- }
- }
-
- continue;
- }
-
- // Storage component also serializes entity UID references in storedItems keys.
- if (componentType == "Storage" && compMap.TryGet("storedItems", out MappingDataNode? storedItemsMap) && storedItemsMap != null)
- {
- var removeKeys = new List();
- foreach (var (itemUid, _) in storedItemsMap)
- {
- if (removedEntityUids.Contains(itemUid))
- removeKeys.Add(itemUid);
- }
-
- foreach (var key in removeKeys)
- {
- storedItemsMap.Remove(key);
- }
- }
- }
- }
- }
+ ShipSaveYamlSanitizer.SanitizeShipSaveNode(root, _prototypeManager); // HardLight
}
- private static void ApplyPaintStyleToAppearance(MappingDataNode appearanceComp, string stylePrototype)
- {
- MappingDataNode appearanceDataInit;
- if (appearanceComp.TryGet("appearanceDataInit", out MappingDataNode? existing) && existing != null)
- {
- appearanceDataInit = existing;
- }
- else
- {
- appearanceDataInit = new MappingDataNode();
- appearanceComp["appearanceDataInit"] = appearanceDataInit;
- }
-
- appearanceDataInit["enum.PaintableVisuals.Prototype"] = new ValueDataNode(stylePrototype);
- }
- // HardLight end
-
private string WriteYamlToString(MappingDataNode node)
{
// Based on MapLoaderSystem.Write but to a string instead of file
diff --git a/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.Consoles.cs b/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.Consoles.cs
index 3cb51d4e754..d2bd8b8365f 100644
--- a/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.Consoles.cs
+++ b/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.Consoles.cs
@@ -496,7 +496,14 @@ public void OnLoadMessage(EntityUid uid, ShipyardConsoleComponent component, Shi
bool loaded = false;
try
{
- if (!string.IsNullOrWhiteSpace(args.SourceFilePath))
+ // HardLight start
+ if (!string.IsNullOrWhiteSpace(args.YamlData))
+ {
+ loaded = TryPurchaseShuttleFromYamlData(uid, args.YamlData, out shuttleUidOut);
+ }
+
+ if (!loaded && !string.IsNullOrWhiteSpace(args.SourceFilePath))
+ // HardLight end
{
// Normalize to a ResPath under /UserData
var norm = args.SourceFilePath!.Replace('\\', '/');
@@ -508,12 +515,6 @@ public void OnLoadMessage(EntityUid uid, ShipyardConsoleComponent component, Shi
var resPath = new ResPath(norm);
loaded = TryPurchaseShuttleFromFile(uid, resPath, out shuttleUidOut);
}
-
- // Fallback: write to a temp file and then load via purchase-from-file
- if (!loaded)
- {
- loaded = TryPurchaseShuttleFromYamlData(uid, args.YamlData, out shuttleUidOut);
- }
}
catch (Exception ex)
{
diff --git a/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.cs b/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.cs
index 47075b24817..f624c949607 100644
--- a/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.cs
+++ b/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.cs
@@ -2,6 +2,7 @@
using Content.Server.Shuttles.Components;
using Content.Shared.Station.Components;
using Content.Server.Cargo.Systems;
+using Content.Server.Shuttles.Save; // HardLight
using Robust.Shared.Timing; // For IGameTiming
using Content.Server.Station.Systems;
using Content.Shared._NF.Shipyard.Components;
@@ -12,8 +13,16 @@
using Content.Shared._NF.CCVar;
using Robust.Shared.Configuration;
using System.Diagnostics.CodeAnalysis;
+using System.IO; // HardLight
using System.Linq;
using System.Numerics;
+using System.Text; // HardLight
+using System.Text.RegularExpressions; // HardLight
+using Robust.Shared.Serialization; // HardLight
+using Robust.Shared.Serialization.Markdown; // HardLight
+using Robust.Shared.Serialization.Markdown.Mapping; // HardLight
+using Robust.Shared.Serialization.Markdown.Sequence; // HardLight
+using Robust.Shared.Serialization.Markdown.Value; // HardLight
using Content.Shared._NF.Shipyard.Events;
using Content.Shared.Mobs.Components;
using Robust.Shared.Containers;
@@ -27,11 +36,31 @@
using Robust.Shared.Physics;
using Robust.Shared.Map.Components;
using Robust.Shared.Physics.Components; // For GravitySystem
+using Robust.Shared.Map.Events; // HardLight
+using YamlDotNet.Core; // HardLight
+using YamlDotNet.RepresentationModel; // HardLight
namespace Content.Server._NF.Shipyard.Systems;
public sealed partial class ShipyardSystem : SharedShipyardSystem
{
+ private static readonly Regex ShipSaveProtoLineRegex = new(@"^(\s*)- proto:\s*(.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight
+ private static readonly Regex ShipSaveUidLineRegex = new(@"^\s*- uid:\s*\d+", RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight
+ private static readonly Regex ShipSaveUidCaptureLineRegex = new(@"^\s*-\s*uid:\s*(\d+)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight
+ private static readonly Regex ShipSaveEntitiesSectionRegex = new(@"^(\s*)entities\s*:\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight
+ private static readonly Regex ShipSaveLegacyUidLineRegex = new(@"^(\s*)-\s*uid\s*:\s*\d+\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight
+ private static readonly Regex ShipSaveLegacyTypeLineRegex = new(@"^\s*type\s*:\s*(.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight
+
+ // HardLight: Set of tokens that, if found as UIDs in the YAML, indicate a stale or invalid UID
+ // that should be sanitized during load to prevent deserialization failures.
+ private static readonly HashSet StaleSerializedUidTokens = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "invalid",
+ "null",
+ "~",
+ "0",
+ };
+
[Dependency] private readonly IConfigurationManager _configManager = default!;
[Dependency] private readonly DockingSystem _docking = default!;
[Dependency] private readonly PricingSystem _pricing = default!;
@@ -48,6 +77,7 @@ public sealed partial class ShipyardSystem : SharedShipyardSystem
[Dependency] private readonly UseDelaySystem _useDelay = default!;
[Dependency] private readonly EntityLookupSystem _lookup = default!;
[Dependency] private readonly IGameTiming _timing = default!; // For cooldown timing
+ [Dependency] private readonly ShipSerializationSystem _shipSerialization = default!; // HardLight
private EntityQuery _transformQuery;
@@ -58,6 +88,9 @@ public sealed partial class ShipyardSystem : SharedShipyardSystem
private bool _enabled;
private float _baseSaleRate;
private readonly Dictionary _lastLoadCharge = new(); // Per-player load charge cooldown
+ private readonly Dictionary _shipyardActionDelayUntil = new(); // HardLight
+ private static readonly TimeSpan ShipyardActionDelay = TimeSpan.FromSeconds(1); // HardLight
+ private HashSet? _activeLoadDeletedPrototypes; // HardLight
// The type of error from the attempted sale of a ship.
public enum ShipyardSaleError
@@ -101,6 +134,7 @@ public override void Initialize()
SubscribeLocalEvent(OnItemSlotChanged);
SubscribeLocalEvent(OnRoundRestart);
SubscribeLocalEvent(OnInitDeedSpawner);
+ SubscribeLocalEvent(OnBeforeEntityRead); // HardLight
}
public override void Shutdown()
@@ -120,6 +154,19 @@ private void OnRoundRestart(RoundRestartCleanupEvent ev)
CleanupShipyard();
}
+ // HardLight: Injects deleted prototype IDs into a temporary set used during ship load to allow YAML loads to succeed by ignoring missing prototypes,
+ // while still logging their absence for debugging and future cleanup.
+ private void OnBeforeEntityRead(BeforeEntityReadEvent ev)
+ {
+ if (_activeLoadDeletedPrototypes == null || _activeLoadDeletedPrototypes.Count == 0)
+ return;
+
+ foreach (var prototypeId in _activeLoadDeletedPrototypes)
+ {
+ ev.DeletedPrototypes.Add(prototypeId);
+ }
+ }
+
private void SetShipyardEnabled(bool value)
{
if (_enabled == value)
@@ -205,30 +252,215 @@ public bool TryPurchaseShuttle(EntityUid consoleUid, ResPath shuttlePath, [NotNu
/// The EntityUid of the shuttle that was loaded
public bool TryPurchaseShuttleFromFile(EntityUid consoleUid, ResPath shuttlePath, [NotNullWhen(true)] out EntityUid? shuttleEntityUid)
{
- // Get the grid the console is on
- if (!_transformQuery.TryComp(consoleUid, out var consoleXform) || consoleXform.GridUid == null)
+ if (!TryAddShuttle(shuttlePath, out var shuttleGrid)) // HardLight
{
shuttleEntityUid = null;
return false;
}
- if (!TryAddShuttle(shuttlePath, out var shuttleGrid))
+ return TryFinalizeLoadedShuttle(consoleUid, shuttleGrid.Value, out shuttleEntityUid); // HardLight
+ }
+
+ ///
+ /// HardLight: Loads a shuttle into the ShipyardMap from a file path
+ ///
+ /// The path to the grid file to load. Must be a grid file!
+ /// Returns the EntityUid of the shuttle
+ private bool TryAddShuttle(ResPath shuttlePath, [NotNullWhen(true)] out EntityUid? shuttleGrid)
+ {
+ shuttleGrid = null;
+ SetupShipyardIfNeeded();
+ if (ShipyardMap == null)
+ return false;
+
+ if (!_mapLoader.TryLoadGrid(ShipyardMap.Value, shuttlePath, out var grid, offset: new Vector2(500f + _shuttleIndex, 1f)))
{
- shuttleEntityUid = null;
+ //_sawmill.Error($"Unable to spawn shuttle {shuttlePath}");
return false;
}
- var grid = shuttleGrid.Value;
+ _shuttleIndex += grid.Value.Comp.LocalAABB.Width + ShuttleSpawnBuffer;
- if (!TryComp(grid, out var shuttleComponent))
+ shuttleGrid = grid.Value.Owner;
+ return true;
+ }
+
+ ///
+ /// HardLight: Writes YAML data to a temporary file and attempts the same initial strict load path as purchase-from-file.
+ /// If that fails, applies compatibility recovery stages before falling back to tolerant ship-data reconstruction.
+ ///
+ private bool TryPurchaseShuttleFromYamlData(EntityUid consoleUid, string yamlData, [NotNullWhen(true)] out EntityUid? shuttleEntityUid)
+ {
+ shuttleEntityUid = null;
+ ResPath tempPath = default;
+ try
{
- shuttleEntityUid = null;
+ // Create a temp path under UserData/ShipyardTemp
+ var fileName = $"shipyard_load_{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}_{Guid.NewGuid():N}.yml";
+ var dir = new ResPath("/") / "UserData" / "ShipyardTemp";
+ tempPath = dir / fileName;
+
+ // Ensure directory exists and write file
+ _resources.UserData.CreateDir(dir);
+ using (var writer = _resources.UserData.OpenWriteText(tempPath))
+ {
+ writer.Write(yamlData);
+ }
+
+ // Fast path: strict load with original YAML; no extra scanning work.
+ if (TryPurchaseShuttleFromFileSafe(consoleUid, tempPath, out shuttleEntityUid))
+ return true;
+
+ _sawmill.Debug("[ShipLoad] Strict grid YAML load failed; attempting compatibility recovery stages.");
+
+ var recoveryYaml = yamlData;
+
+ // Recovery stage A: sanitize missing prototypes and inject deleted prototype IDs.
+ var sanitizedYaml = SanitizeLoadYamlMissingPrototypes(yamlData, out var removedProtoBlocks, out var removedEntities);
+ var deletedPrototypeIds = FindMissingPrototypeIdsForLoad(sanitizedYaml);
+ var needsSanitizedRetry = removedProtoBlocks > 0
+ || deletedPrototypeIds.Count > 0
+ || !string.Equals(sanitizedYaml, yamlData, StringComparison.Ordinal);
+
+ if (needsSanitizedRetry)
+ {
+ if (removedProtoBlocks > 0)
+ {
+ _sawmill.Warning($"[ShipLoad] Removed {removedProtoBlocks} invalid prototype block(s) containing {removedEntities} entities from ship YAML before load.");
+ }
+
+ if (deletedPrototypeIds.Count > 0)
+ {
+ _sawmill.Warning($"[ShipLoad] Ignoring {deletedPrototypeIds.Count} missing prototype id(s) during ship load.");
+ }
+
+ _activeLoadDeletedPrototypes = deletedPrototypeIds.Count > 0 ? deletedPrototypeIds : null;
+ recoveryYaml = sanitizedYaml;
+
+ using (var retryWriter = _resources.UserData.OpenWriteText(tempPath))
+ {
+ retryWriter.Write(recoveryYaml);
+ }
+
+ if (TryPurchaseShuttleFromFileSafe(consoleUid, tempPath, out shuttleEntityUid))
+ return true;
+ }
+
+ // Recovery path: strip serialized component payloads and retry strict load.
+ // This salvages ships when component schemas changed between versions.
+ var strippedYaml = StripSerializedComponentsForRecovery(recoveryYaml);
+ if (!string.Equals(strippedYaml, recoveryYaml, StringComparison.Ordinal))
+ {
+ using (var retryWriter = _resources.UserData.OpenWriteText(tempPath))
+ {
+ retryWriter.Write(strippedYaml);
+ }
+
+ if (TryPurchaseShuttleFromFileSafe(consoleUid, tempPath, out shuttleEntityUid))
+ {
+ _sawmill.Warning("[ShipLoad] Loaded ship after stripping serialized component payloads for compatibility recovery.");
+ return true;
+ }
+
+ _sawmill.Debug("[ShipLoad] Component-stripped recovery load failed.");
+ }
+
+ // Fallback: ship-data YAML path tolerates per-entity failures and skips bad entities.
+ if (TryPurchaseShuttleFromShipDataYaml(consoleUid, recoveryYaml, out shuttleEntityUid))
+ return true;
+
+ _sawmill.Warning("[ShipLoad] Ship-data tolerant fallback also failed.");
+
return false;
}
+ catch (Exception ex)
+ {
+ _sawmill.Warning($"Failed to purchase shuttle from YAML data: {ex.Message}"); // HardLight: Error(grid, out var gridComp))
+ {
+ _sawmill.Warning("[ShipLoad] Ship-data fallback created no grid component.");
+ return false;
+ }
+
+ _shuttleIndex += gridComp.LocalAABB.Width + ShuttleSpawnBuffer;
+
+ if (!TryFinalizeLoadedShuttle(consoleUid, grid, out shuttleEntityUid))
+ {
+ SafeDelete(grid);
+ return false;
+ }
+
+ _sawmill.Info("[ShipLoad] Loaded ship via tolerant ship-data fallback path.");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _sawmill.Warning($"[ShipLoad] Ship-data fallback failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ // HardLight: Performs final setup and docking for a loaded shuttle, with error handling to prevent load crashes.
+ private bool TryFinalizeLoadedShuttle(EntityUid consoleUid, EntityUid grid, [NotNullWhen(true)] out EntityUid? shuttleEntityUid)
+ {
+ shuttleEntityUid = null;
- //_sawmill.Info($"Shuttle loaded from file {shuttlePath} at {ToPrettyString(consoleUid)}");
+ // Get the grid the console is on
+ if (!_transformQuery.TryComp(consoleUid, out var consoleXform) || consoleXform.GridUid == null)
+ return false;
+
+ if (!TryComp(grid, out var shuttleComponent))
+ return false;
+
+ var targetGrid = consoleXform.GridUid.Value;
// Ensure required components for docking and identification
EnsureComp(grid);
@@ -268,74 +500,484 @@ public bool TryPurchaseShuttleFromFile(EntityUid consoleUid, ResPath shuttlePath
}
///
- /// Loads a shuttle into the ShipyardMap from a file path
+ /// HardLight: Removes serialized entity groups whose prototype IDs no longer exist in code.
+ /// This lets old ship exports load even after content deprecations.
///
- /// The path to the grid file to load. Must be a grid file!
- /// Returns the EntityUid of the shuttle
- private bool TryAddShuttle(ResPath shuttlePath, [NotNullWhen(true)] out EntityUid? shuttleGrid)
+ private string SanitizeLoadYamlMissingPrototypes(string yamlData, out int removedPrototypeBlocks, out int removedEntities)
{
- shuttleGrid = null;
- SetupShipyardIfNeeded();
- if (ShipyardMap == null)
- return false;
+ removedPrototypeBlocks = 0;
+ removedEntities = 0;
- if (!_mapLoader.TryLoadGrid(ShipyardMap.Value, shuttlePath, out var grid, offset: new Vector2(500f + _shuttleIndex, 1f)))
+ if (string.IsNullOrWhiteSpace(yamlData))
+ return yamlData;
+
+ // Quick exit: if no grouped entity prototype declarations exist there is nothing to strip here.
+ if (yamlData.IndexOf("- proto:", StringComparison.Ordinal) < 0)
+ return yamlData;
+
+ var normalized = yamlData.Replace("\r\n", "\n");
+ var lines = normalized.Split('\n');
+ var output = new StringBuilder(normalized.Length);
+ var removedEntityUids = new HashSet(StringComparer.Ordinal);
+
+ for (var i = 0; i < lines.Length; i++)
{
- //_sawmill.Error($"Unable to spawn shuttle {shuttlePath}");
- return false;
+ var line = lines[i];
+ var protoMatch = ShipSaveProtoLineRegex.Match(line);
+
+ if (!protoMatch.Success)
+ {
+ output.AppendLine(line);
+ continue;
+ }
+
+ var indent = protoMatch.Groups[1].Value;
+ var rawProto = protoMatch.Groups[2].Value;
+ var commentIndex = rawProto.IndexOf('#');
+ if (commentIndex >= 0)
+ rawProto = rawProto[..commentIndex];
+
+ var protoId = rawProto.Trim().Trim('"', '\'');
+
+ // Empty proto blocks contain runtime/synthetic entities and should be retained.
+ var keepBlock = string.IsNullOrWhiteSpace(protoId) || _prototypeManager.TryIndex(protoId, out _);
+ if (keepBlock)
+ {
+ output.AppendLine(line);
+ continue;
+ }
+
+ removedPrototypeBlocks++;
+
+ // Skip this whole proto block until the next proto declaration at the same indentation level.
+ for (i += 1; i < lines.Length; i++)
+ {
+ var blockLine = lines[i];
+ if (ShipSaveUidLineRegex.IsMatch(blockLine))
+ {
+ removedEntities++;
+ var uidMatch = ShipSaveUidCaptureLineRegex.Match(blockLine);
+ if (uidMatch.Success)
+ removedEntityUids.Add(uidMatch.Groups[1].Value);
+ }
+
+ var nextProto = ShipSaveProtoLineRegex.Match(blockLine);
+ if (!nextProto.Success)
+ continue;
+
+ var nextIndent = nextProto.Groups[1].Value;
+ if (nextIndent != indent)
+ continue;
+
+ i -= 1;
+ break;
+ }
}
- _shuttleIndex += grid.Value.Comp.LocalAABB.Width + ShuttleSpawnBuffer;
+ var sanitizedYaml = output.ToString();
+ if (removedEntityUids.Count == 0)
+ return sanitizedYaml;
- shuttleGrid = grid.Value.Owner;
- return true;
+ // Keep parent containers while removing only missing-prototype entities by pruning stale UID references.
+ return PruneLoadYamlReferencesToRemovedEntities(sanitizedYaml, removedEntityUids);
}
///
- /// Writes YAML data to a temporary file and loads it using the exact same method as purchasing a shuttle from a file.
- /// Ensures identical setup/docking logic.
+ /// HardLight: Removes stale references to entities stripped during missing-prototype sanitation.
+ /// This preserves container/storage owner entities when only their contained items were removed.
///
- private bool TryPurchaseShuttleFromYamlData(EntityUid consoleUid, string yamlData, [NotNullWhen(true)] out EntityUid? shuttleEntityUid)
+ private static string PruneLoadYamlReferencesToRemovedEntities(string yamlData, HashSet removedEntityUids)
{
- shuttleEntityUid = null;
- ResPath tempPath = default;
+ if (string.IsNullOrWhiteSpace(yamlData) || removedEntityUids.Count == 0)
+ return yamlData;
+
+ // Structured path: parse YAML into a data node tree and prune stale UID references precisely.
+ // Falls back to line-based pruning if parsing fails on malformed legacy input.
try
{
- // Create a temp path under UserData/ShipyardTemp
- var fileName = $"shipyard_load_{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}_{Guid.NewGuid():N}.yml";
- var dir = new ResPath("/") / "UserData" / "ShipyardTemp";
- tempPath = dir / fileName;
+ using var reader = new StringReader(yamlData);
+ var documents = DataNodeParser.ParseYamlStream(reader).ToArray();
+ if (documents.Length != 1 || documents[0].Root is not MappingDataNode root)
+ return PruneLoadYamlReferencesToRemovedEntitiesLineBased(yamlData, removedEntityUids);
- // Ensure directory exists and write file
- _resources.UserData.CreateDir(dir);
- using (var writer = _resources.UserData.OpenWriteText(tempPath))
+ PruneLoadNodeReferencesToRemovedEntities(root, removedEntityUids);
+ return WriteLoadYamlNodeToString(root);
+ }
+ catch
+ {
+ return PruneLoadYamlReferencesToRemovedEntitiesLineBased(yamlData, removedEntityUids);
+ }
+ }
+
+ // HardLight: Structured node pass that prunes stale/invalid UID references from container/storage data during load recovery.
+ private static void PruneLoadNodeReferencesToRemovedEntities(MappingDataNode root, HashSet removedEntityUids)
+ {
+ if (!root.TryGet("entities", out SequenceDataNode? protoSeq) || protoSeq == null)
+ return;
+
+ foreach (var protoNode in protoSeq)
+ {
+ if (protoNode is not MappingDataNode protoMap)
+ continue;
+
+ if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null)
+ continue;
+
+ foreach (var entityNode in entitiesSeq)
{
- writer.Write(yamlData);
- }
+ if (entityNode is not MappingDataNode entMap)
+ continue;
- // Reuse purchase-from-file flow
- if (!TryPurchaseShuttleFromFile(consoleUid, tempPath, out shuttleEntityUid))
- return false;
+ if (!entMap.TryGet("components", out SequenceDataNode? comps) || comps == null)
+ continue;
- return true;
+ foreach (var compNode in comps)
+ {
+ if (compNode is not MappingDataNode compMap)
+ continue;
+
+ if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null)
+ continue;
+
+ var componentType = typeNode.Value;
+
+ if (componentType == "ContainerContainer")
+ {
+ if (!compMap.TryGet("containers", out MappingDataNode? containersMap) || containersMap == null)
+ continue;
+
+ foreach (var (_, containerNode) in containersMap)
+ {
+ if (containerNode is not MappingDataNode containerMap)
+ continue;
+
+ if (containerMap.TryGet("ents", out SequenceDataNode? entsNode) && entsNode != null)
+ {
+ for (var idx = entsNode.Count - 1; idx >= 0; idx--)
+ {
+ if (entsNode[idx] is not ValueDataNode entValue || entValue.IsNull)
+ continue;
+
+ if (IsStaleSerializedUidReference(entValue.Value, removedEntityUids))
+ entsNode.RemoveAt(idx);
+ }
+ }
+
+ if (containerMap.TryGet("ent", out ValueDataNode? entNode) && entNode != null && !entNode.IsNull)
+ {
+ if (IsStaleSerializedUidReference(entNode.Value, removedEntityUids))
+ containerMap["ent"] = ValueDataNode.Null();
+ }
+ }
+
+ continue;
+ }
+
+ if (componentType != "Storage"
+ || !compMap.TryGet("storedItems", out MappingDataNode? storedItemsMap)
+ || storedItemsMap == null)
+ {
+ continue;
+ }
+
+ var removeKeys = new List();
+ foreach (var (itemUid, _) in storedItemsMap)
+ {
+ if (IsStaleSerializedUidReference(itemUid, removedEntityUids))
+ removeKeys.Add(itemUid);
+ }
+
+ foreach (var key in removeKeys)
+ storedItemsMap.Remove(key);
+ }
+ }
}
- catch (Exception ex)
+ }
+
+ // HardLight: Serializes a YAML data node tree back into a string, ensuring consistent formatting.
+ private static string WriteLoadYamlNodeToString(MappingDataNode root)
+ {
+ var document = new YamlDocument(root.ToYaml());
+ using var writer = new StringWriter();
+ var stream = new YamlStream { document };
+ stream.Save(new YamlMappingFix(new Emitter(writer)), false);
+ return writer.ToString();
+ }
+
+ // HardLight: Line-based fallback for pruning stale UID references when YAML is too malformed for structured parsing.
+ private static string PruneLoadYamlReferencesToRemovedEntitiesLineBased(string yamlData, HashSet removedEntityUids)
+ {
+ if (string.IsNullOrWhiteSpace(yamlData))
+ return yamlData;
+
+ var normalized = yamlData.Replace("\r\n", "\n");
+ var lines = normalized.Split('\n');
+ var output = new StringBuilder(normalized.Length);
+
+ var entsIndent = -1;
+ var storedItemsIndent = -1;
+ var skipSubtreeIndent = -1;
+
+ for (var i = 0; i < lines.Length; i++)
{
- _sawmill.Warning($"Failed to purchase shuttle from YAML data: {ex.Message}"); // HardLight: Error= 0)
+ {
+ if (trimmed.Length == 0)
+ continue;
+
+ if (indent > skipSubtreeIndent)
+ continue;
+
+ skipSubtreeIndent = -1;
+ }
+
+ if (trimmed.Length == 0)
+ {
+ output.AppendLine(line);
+ continue;
+ }
+
+ if (entsIndent >= 0 && indent <= entsIndent)
+ entsIndent = -1;
+
+ if (storedItemsIndent >= 0 && indent <= storedItemsIndent)
+ storedItemsIndent = -1;
+
+ if (trimmed.StartsWith("ents:", StringComparison.Ordinal))
+ {
+ entsIndent = indent;
+ output.AppendLine(line);
+ continue;
+ }
+
+ if (trimmed.StartsWith("storedItems:", StringComparison.Ordinal))
+ {
+ storedItemsIndent = indent;
+ output.AppendLine(line);
+ continue;
+ }
+
+ // Prune sequence entries in ContainerContainer.ents lists.
+ if (entsIndent >= 0 && indent > entsIndent)
+ {
+ var listEntry = trimmed;
+ if (listEntry.StartsWith("- ", StringComparison.Ordinal))
+ listEntry = listEntry[2..].Trim();
+
+ if (IsStaleSerializedUidReference(listEntry, removedEntityUids))
+ continue;
+ }
+
+ // Null stale single-reference entries in ContainerContainer.ent fields.
+ if (trimmed.StartsWith("ent:", StringComparison.Ordinal))
+ {
+ var entValue = trimmed[4..].Trim();
+ if (IsStaleSerializedUidReference(entValue, removedEntityUids))
+ {
+ output.Append(' ', indent);
+ output.AppendLine("ent: null");
+ continue;
+ }
+ }
+
+ // Remove Storage.storedItems entries keyed by removed entity UID.
+ if (storedItemsIndent >= 0 && indent > storedItemsIndent)
+ {
+ var keySpan = trimmed;
+ var colonIndex = keySpan.IndexOf(':');
+ if (colonIndex > 0)
+ {
+ var rawKey = keySpan[..colonIndex].Trim().Trim('"', '\'');
+ if (IsStaleSerializedUidReference(rawKey, removedEntityUids))
+ {
+ skipSubtreeIndent = indent;
+ continue;
+ }
+ }
+ }
+
+ output.AppendLine(line);
+ }
+
+ return output.ToString();
+ }
+
+ // HardLight: Checks if a UID token from YAML matches known patterns of stale references to entities removed during load sanitation.
+ private static bool IsStaleSerializedUidReference(string uidToken, HashSet removedEntityUids)
+ {
+ var normalized = uidToken.Trim().Trim('"', '\'');
+ if (normalized.Length == 0)
return false;
+
+ if (removedEntityUids.Contains(normalized))
+ return true;
+
+ return StaleSerializedUidTokens.Contains(normalized);
+ }
+
+ ///
+ /// HardLight: Finds missing prototype IDs referenced by ship YAML in both grouped (proto) and legacy (uid/type) entity formats.
+ /// Returned IDs are used as a temporary deleted-prototype map during this specific load operation.
+ ///
+ private HashSet FindMissingPrototypeIdsForLoad(string yamlData)
+ {
+ var missing = new HashSet();
+
+ if (string.IsNullOrWhiteSpace(yamlData))
+ return missing;
+
+ // Quick exit when there is no entities section.
+ if (yamlData.IndexOf("entities:", StringComparison.OrdinalIgnoreCase) < 0)
+ return missing;
+
+ var normalized = yamlData.Replace("\r\n", "\n");
+ var lines = normalized.Split('\n');
+
+ var inEntities = false;
+ var entitiesIndent = -1;
+ var currentLegacyEntityIndent = -1;
+
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var line = lines[i];
+ var trimmed = line.TrimStart();
+
+ if (trimmed.Length == 0 || trimmed.StartsWith('#'))
+ continue;
+
+ var indent = line.Length - trimmed.Length;
+ var entitiesSection = ShipSaveEntitiesSectionRegex.Match(line);
+ if (!inEntities && entitiesSection.Success)
+ {
+ inEntities = true;
+ entitiesIndent = entitiesSection.Groups[1].Value.Length;
+ continue;
+ }
+
+ if (!inEntities)
+ continue;
+
+ // Left the entities section.
+ if (indent <= entitiesIndent)
+ {
+ inEntities = false;
+ currentLegacyEntityIndent = -1;
+ continue;
+ }
+
+ // Grouped format: "- proto: "
+ var protoMatch = ShipSaveProtoLineRegex.Match(line);
+ if (protoMatch.Success)
+ {
+ var protoId = ParseShipSavePrototypeValue(protoMatch.Groups[2].Value);
+ if (!string.IsNullOrWhiteSpace(protoId)
+ && !_prototypeManager.TryIndex(protoId, out _))
+ {
+ missing.Add(protoId);
+ }
+
+ currentLegacyEntityIndent = -1;
+ continue;
+ }
+
+ // Legacy format entity boundary: "- uid: "
+ var uidMatch = ShipSaveLegacyUidLineRegex.Match(line);
+ if (uidMatch.Success)
+ {
+ currentLegacyEntityIndent = uidMatch.Groups[1].Value.Length;
+ continue;
+ }
+
+ // If we're no longer in the current legacy entity block, clear it.
+ if (currentLegacyEntityIndent >= 0 && indent <= currentLegacyEntityIndent)
+ {
+ currentLegacyEntityIndent = -1;
+ }
+
+ if (currentLegacyEntityIndent < 0)
+ continue;
+
+ // Legacy format prototype: "type: " under the current uid block.
+ var legacyTypeMatch = ShipSaveLegacyTypeLineRegex.Match(line);
+ if (!legacyTypeMatch.Success)
+ continue;
+
+ var legacyProtoId = ParseShipSavePrototypeValue(legacyTypeMatch.Groups[1].Value);
+ if (!string.IsNullOrWhiteSpace(legacyProtoId)
+ && !_prototypeManager.TryIndex(legacyProtoId, out _))
+ {
+ missing.Add(legacyProtoId);
+ }
}
- finally
+
+ return missing;
+ }
+
+ // HardLight: Extracts the prototype ID from a raw YAML line value,
+ // stripping comments and extraneous whitespace/quotes.
+ private static string ParseShipSavePrototypeValue(string rawValue)
+ {
+ var commentIndex = rawValue.IndexOf('#');
+ if (commentIndex >= 0)
+ rawValue = rawValue[..commentIndex];
+
+ return rawValue.Trim().Trim('"', '\'');
+ }
+
+ ///
+ /// HardLight: Best-effort compatibility recovery; remove serialized component/missingComponents blocks so
+ /// entities can fall back to prototype defaults when component schemas drift across versions.
+ ///
+ private string StripSerializedComponentsForRecovery(string yamlData)
+ {
+ if (string.IsNullOrWhiteSpace(yamlData))
+ return yamlData;
+
+ if (yamlData.IndexOf("components:", StringComparison.Ordinal) < 0
+ && yamlData.IndexOf("missingComponents:", StringComparison.Ordinal) < 0)
+ return yamlData;
+
+ var normalized = yamlData.Replace("\r\n", "\n");
+ var lines = normalized.Split('\n');
+ var output = new StringBuilder(normalized.Length);
+
+ for (var i = 0; i < lines.Length; i++)
{
- try
+ var line = lines[i];
+ var trimmed = line.TrimStart();
+ var indent = line.Length - trimmed.Length;
+
+ var isComponentsStart = trimmed.StartsWith("components:", StringComparison.Ordinal)
+ || trimmed.StartsWith("missingComponents:", StringComparison.Ordinal);
+ if (!isComponentsStart)
{
- if (tempPath != default && _resources.UserData.Exists(tempPath))
- _resources.UserData.Delete(tempPath);
+ output.AppendLine(line);
+ continue;
}
- catch
+
+ // Skip this block and all deeper-indented lines that belong to it.
+ for (i += 1; i < lines.Length; i++)
{
- // Best-effort cleanup
+ var nextLine = lines[i];
+ var nextTrimmed = nextLine.TrimStart();
+
+ if (nextTrimmed.Length == 0)
+ continue;
+
+ var nextIndent = nextLine.Length - nextTrimmed.Length;
+ if (nextIndent > indent)
+ continue;
+
+ i -= 1;
+ break;
}
}
+
+ return output.ToString(); // HardLight
}
///
diff --git a/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs b/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs
index e69f0c2f7ad..a7c9faf3ce7 100644
--- a/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs
+++ b/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs
@@ -76,6 +76,9 @@ public IEnumerable GetHostiles(Entity ent
///
public void IgnoreEntity(Entity ent, Entity target)
{
+ if (!target.Owner.IsValid() || !EntityManager.EntityExists(target.Owner)) // HardLight
+ return;
+
ent.Comp ??= EnsureComp(ent);
ent.Comp.Ignored.Add(target);
target.Comp ??= EnsureComp(target);
@@ -99,6 +102,9 @@ public void IgnoreEntities(Entity ent, IEnumerable
public void AggroEntity(Entity ent, Entity target)
{
+ if (!target.Owner.IsValid() || !EntityManager.EntityExists(target.Owner)) // HardLight
+ return;
+
ent.Comp ??= EnsureComp(ent);
ent.Comp.Hostiles.Add(target);
target.Comp ??= EnsureComp(target);
diff --git a/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml b/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml
index 4294984a34d..0da20365bd8 100644
--- a/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml
+++ b/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml
@@ -75,6 +75,7 @@
- type: ActivatableUI
key: enum.BorgUiKey.Key
- type: Targeting # Shitmed
+ - type: SurgeryTarget # HardLight: Necessary for Cyborgs to perform surgery.
- type: SiliconLawBound
- type: ActionGrant
actions:
diff --git a/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/abyss_laser.png b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/abyss_laser.png
new file mode 100644
index 00000000000..e6d77eac978
Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/abyss_laser.png differ
diff --git a/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/impact_abyss_laser.png b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/impact_abyss_laser.png
new file mode 100644
index 00000000000..fa7db026e68
Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/impact_abyss_laser.png differ
diff --git a/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/meta.json b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/meta.json
index eb5844fe629..3c66150ca16 100644
--- a/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/meta.json
+++ b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/meta.json
@@ -102,6 +102,51 @@
0.060000002
]
]
+ },
+ {
+ "name": "abyss_laser",
+ "delays": [
+ [
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002
+ ]
+ ]
+ },
+ {
+ "name": "impact_abyss_laser",
+ "delays": [
+ [
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002
+ ]
+ ]
+ },
+ {
+ "name": "muzzle_abyss_laser",
+ "delays": [
+ [
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002,
+ 0.060000002
+ ]
+ ]
},
{
"name": "beamlight",
diff --git a/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/muzzle_abyss_laser.png b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/muzzle_abyss_laser.png
new file mode 100644
index 00000000000..fba09d4b4b5
Binary files /dev/null and b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/muzzle_abyss_laser.png differ