Files
RobustToolbox/Robust.Shared/EntitySerialization/EntitySerializer.cs
Leon Friedrich 64baee0a22 Add support for including map/game saves in replays. (#6189)
* Improve map serialization error logging

* Prevent remove children of erroring entities

* better logging

* Improve error tolerance

* Even more exception tolerance

* missing !

* Add WriteYaml and WriteObject to IReplayFileWriter

* Add MapLoaderSystem.TrySaveAllEntities()

* On second thought, WriteObject will just be abused

* I forgot to commit

* Add default implementation to avoid breaking changes

* release notes

* fix merge issues

---------

Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
2025-11-13 22:14:56 +11:00

1135 lines
43 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using Robust.Shared.Configuration;
using Robust.Shared.EntitySerialization.Components;
using Robust.Shared.EntitySerialization.Systems;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
using Robust.Shared.Serialization.Manager;
using Robust.Shared.Serialization.Markdown;
using Robust.Shared.Serialization.Markdown.Mapping;
using Robust.Shared.Serialization.Markdown.Sequence;
using Robust.Shared.Serialization.Markdown.Validation;
using Robust.Shared.Serialization.Markdown.Value;
using Robust.Shared.Serialization.TypeSerializers.Interfaces;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Robust.Shared.EntitySerialization;
/// <summary>
/// This class provides methods for serializing entities into yaml. It provides some more control over
/// serialization than the methods provided by <see cref="MapLoaderSystem"/>.
/// </summary>
/// <remarks>
/// There are several methods (e.g., <see cref="SerializeEntityRecursive"/> that serialize entities into a
/// per-entity <see cref="MappingDataNode"/> stored in the <see cref="EntityData"/> dictionary, which is indexed by the
/// entity's assigned yaml id (see <see cref="GetYamlUid"/>. The generated data can then be written to a larger yaml
/// document using the various "Write" methods. (e.g., <see cref="WriteEntitySection"/>). After a one has finished using
/// the generated data, the serializer needs to be reset (<see cref="Reset"/>) using it again to serialize other entities.
/// </remarks>
public sealed class EntitySerializer : ISerializationContext,
ITypeSerializer<EntityUid, ValueDataNode>,
ITypeSerializer<NetEntity, ValueDataNode>,
ITypeSerializer<MapId, ValueDataNode>
{
public const int MapFormatVersion = 7;
// v6->v7: PR #5572 - Added more metadata, List maps/grids/orphans, include some life-stage information
// v5->v6: PR #4307 - Converted Tile.TypeId from ushort to int
// v4->v5: PR #3992 - Removed name & author fields
// v3->v4: PR #3913 - Grouped entities by prototype
// v2->v3: PR #3468
public SerializationManager.SerializerProvider SerializerProvider { get; } = new();
[Dependency] public readonly EntityManager EntMan = default!;
[Dependency] public readonly IGameTiming Timing = default!;
[Dependency] private readonly IComponentFactory _factory = default!;
[Dependency] private readonly ISerializationManager _serialization = default!;
[Dependency] private readonly ITileDefinitionManager _tileDef = default!;
[Dependency] private readonly IConfigurationManager _conf = default!;
[Dependency] private readonly ILogManager _logMan = default!;
[Dependency] private readonly SharedMapSystem _map = default!;
private readonly ISawmill _log;
public readonly Dictionary<EntityUid, int> YamlUidMap = new();
public readonly HashSet<int> YamlIds = new();
public readonly ValueDataNode InvalidNode = new("invalid");
public string? CurrentComponent { get; private set; }
public Entity<MetaDataComponent>? CurrentEntity { get; private set; }
public int CurrentEntityYamlUid { get; private set; }
/// <summary>
/// Tile ID -> yaml tile ID mapping.
/// </summary>
private readonly Dictionary<int, int> _tileMap = new();
private readonly HashSet<int> _yamlTileIds = new();
/// <inheritdoc/>
public bool WritingReadingPrototypes { get; private set; }
/// <summary>
/// If set, the serializer will refuse to serialize the given entity and will orphan any entity that is parented to
/// it. This is useful for serializing things like a grid (or multiple grids & entities) that are parented to a map
/// without actually serializing the map itself.
/// </summary>
public EntityUid Truncate { get; private set; }
/// <summary>
/// List of all entities that have previously been ignored via <see cref="Truncate"/>.
/// </summary>
/// <remarks>
/// This is tracked in case somebody does something weird, like trying to save a grid w/o its map, and then later on
/// including the map in the file. AFAIK, that should work in principle, though it would lead to a weird file where
/// the grid is orphaned and not on the map where it should be.
/// </remarks>
public readonly HashSet<EntityUid> Truncated = new();
public readonly SerializationOptions Options;
/// <summary>
/// Cached prototype data. This is used to avoid writing redundant data that is already specified in an entity's
/// prototype.
/// </summary>
public readonly Dictionary<string, Dictionary<string, MappingDataNode>> PrototypeCache = new();
/// <summary>
/// The serialized entity data.
/// </summary>
public readonly Dictionary<int, (EntityUid Uid, MappingDataNode Node)> EntityData = new();
/// <summary>
/// <see cref="EntityData"/> indices grouped by their entity prototype ids.
/// </summary>
public readonly Dictionary<string, List<int>> Prototypes = new();
/// <summary>
/// Set of entities that have encountered issues during serialization and are now being ignored.
/// </summary>
public HashSet<EntityUid> ErroringEntities = new();
/// <summary>
/// Yaml ids of all serialized map entities.
/// </summary>
public readonly List<int> Maps = new();
/// <summary>
/// Yaml ids of all serialized null-space entities.
/// This only includes entities that were initially in null-space, it does not include entities that were
/// serialized without their parents. Those are in <see cref="Orphans"/>.
/// </summary>
public readonly List<int> Nullspace = new();
/// <summary>
/// Yaml ids of all serialized grid entities.
/// </summary>
public readonly List<int> Grids = new();
/// <summary>
/// Yaml ids of all serialized entities in the file whose parents were not serialized. This does not include
/// entities that did not have a parent (e.g., maps or null-space entities). I.e., these are the entities that
/// need to be attached to a new parent when loading the file, unless you want to load them into null-space.
/// </summary>
public readonly List<int> Orphans = new();
private readonly string _metaName;
private readonly string _xformName;
private readonly MappingDataNode _emptyMetaNode;
private readonly MappingDataNode _emptyXformNode;
private int _nextYamlUid = 1;
private int _nextYamlTileId;
private readonly List<EntityUid> _autoInclude = new();
private readonly EntityQuery<YamlUidComponent> _yamlQuery;
private readonly EntityQuery<MapGridComponent> _gridQuery;
private readonly EntityQuery<MapComponent> _mapQuery;
private readonly EntityQuery<MetaDataComponent> _metaQuery;
private readonly EntityQuery<TransformComponent> _xformQuery;
/// <summary>
/// C# event for checking whether an entity is serializable. Can be used by content to prevent specific entities
/// from getting serialized.
/// </summary>
public event IsSerializableDelegate? OnIsSerializeable;
public delegate void IsSerializableDelegate(Entity<MetaDataComponent> ent, ref bool serializable);
public EntitySerializer(IDependencyCollection dependency, SerializationOptions options)
{
dependency.InjectDependencies(this);
_log = _logMan.GetSawmill("entity_serializer");
SerializerProvider.RegisterSerializer(this);
_metaName = _factory.GetComponentName<MetaDataComponent>();
_xformName = _factory.GetComponentName<TransformComponent>();
_emptyMetaNode = _serialization.WriteValueAs<MappingDataNode>(typeof(MetaDataComponent), new MetaDataComponent(), alwaysWrite: true, context: this);
CurrentComponent = _xformName;
_emptyXformNode = _serialization.WriteValueAs<MappingDataNode>(typeof(TransformComponent), new TransformComponent(), alwaysWrite: true, context: this);
CurrentComponent = null;
_yamlQuery = EntMan.GetEntityQuery<YamlUidComponent>();
_gridQuery = EntMan.GetEntityQuery<MapGridComponent>();
_mapQuery = EntMan.GetEntityQuery<MapComponent>();
_metaQuery = EntMan.GetEntityQuery<MetaDataComponent>();
_xformQuery = EntMan.GetEntityQuery<TransformComponent>();
Options = options;
}
public bool IsSerializable(Entity<MetaDataComponent?> ent)
{
if (ent.Comp == null && !EntMan.TryGetComponent(ent.Owner, out ent.Comp))
return false;
if (ent.Comp.EntityPrototype?.MapSavable == false)
return false;
bool serializable = true;
OnIsSerializeable?.Invoke(ent!, ref serializable);
return serializable;
}
#region Serialize API
/// <summary>
/// Serialize a single entity. This does not automatically include
/// children, though depending on the setting of <see cref="SerializationOptions.MissingEntityBehaviour"/> it may
/// auto-include additional entities aside from the one provided.
/// </summary>
public void SerializeEntity(EntityUid uid)
{
if (!IsSerializable(uid))
throw new Exception($"{EntMan.ToPrettyString(uid)} is not serializable");
DebugTools.AssertNull(CurrentEntity);
ReserveYamlId(uid);
SerializeEntityInternal(uid);
DebugTools.AssertNull(CurrentEntity);
if (_autoInclude.Count != 0)
ProcessAutoInclude();
}
/// <summary>
/// Serialize a set of entities. This does not automatically include children or parents, though depending on the
/// setting of <see cref="SerializationOptions.MissingEntityBehaviour"/> it may auto-include additional entities
/// aside from the one provided.
/// </summary>
/// <param name="entities">The set of entities to serialize</param>
public void SerializeEntities(HashSet<EntityUid> entities)
{
foreach (var uid in entities)
{
if (!IsSerializable(uid))
throw new Exception($"{EntMan.ToPrettyString(uid)} is not serializable");
}
ReserveYamlIds(entities);
SerializeEntitiesInternal(entities);
}
/// <summary>
/// Serializes an entity and all of its serializable children. Note that this will not automatically serialize the
/// entity's parents.
/// </summary>
public void SerializeEntityRecursive(EntityUid root)
{
if (!IsSerializable(root))
throw new Exception($"{EntMan.ToPrettyString(root)} is not serializable");
Truncate = _xformQuery.GetComponent(root).ParentUid;
Truncated.Add(Truncate);
InitializeTileMap(root);
HashSet<EntityUid> entities = new();
RecursivelyIncludeChildren(root, entities);
ReserveYamlIds(entities);
SerializeEntitiesInternal(entities);
Truncate = EntityUid.Invalid;
}
/// <summary>
/// Serializes several entities and all of their children. Note that this will not automatically serialize the
/// entity's parents.
/// </summary>
public void SerializeEntityRecursive(HashSet<EntityUid> roots)
{
if (roots.Count == 0)
return;
InitializeTileMap(roots.First());
HashSet<EntityUid> allEntities = new();
List<(EntityUid Root, HashSet<EntityUid> Children)> entities = new();
foreach(var root in roots)
{
if (!IsSerializable(root))
throw new Exception($"{EntMan.ToPrettyString(root)} is not serializable");
var ents = new HashSet<EntityUid>();
RecursivelyIncludeChildren(root, ents);
entities.Add((root, ents));
allEntities.UnionWith(ents);
}
ReserveYamlIds(allEntities);
foreach (var (root, children) in entities)
{
Truncate = _xformQuery.GetComponent(root).ParentUid;
Truncated.Add(Truncate);
SerializeEntitiesInternal(children);
Truncate = EntityUid.Invalid;
}
}
#endregion
/// <summary>
/// Initialize the <see cref="_tileMap"/> that is used to serialize grid chunks using
/// <see cref="MapChunkSerializer"/>. This initialization just involves checking to see if any of the entities being
/// serialized were previously deserialized. If they were, it will re-use the old tile map. This is not actually required,
/// and is just meant to prevent large map file diffs when the internal tile ids change. I.e., you can serialize entities
/// without initializing the tile map.
/// </summary>
private void InitializeTileMap(EntityUid root)
{
if (!FindSavedTileMap(root, out var savedMap))
return;
// Note: some old maps were saved with duplicate id strings.
// I.e, multiple integers that correspond to the same prototype id.
// Hence the TryAdd()
//
// Though now we also need to use TryAdd in case InitializeTileMap() is called multiple times.
// E.g., if different grids get added separately to a single save file, in which case the
// tile map may already be partially populated.
foreach (var (origId, prototypeId) in savedMap)
{
if (_tileDef.TryGetDefinition(prototypeId, out var definition))
{
_tileMap.TryAdd(definition.TileId, origId);
_yamlTileIds.Add(origId); // Make sure we record the IDs we're using so when we need to reserve new ones we can
}
}
}
private bool FindSavedTileMap(EntityUid root, [NotNullWhen(true)] out Dictionary<int, string>? map)
{
// Try and fetch the mapping directly
if (EntMan.TryGetComponent(root, out MapSaveTileMapComponent? comp))
{
map = comp.TileMap;
return true;
}
map = null;
// if this is a map, iterate over all of its children and grab the first grid with a mapping
if (!_mapQuery.HasComponent(root))
return false;
var xform = _xformQuery.GetComponent(root);
foreach (var child in xform._children)
{
if (!EntMan.TryGetComponent(child, out MapSaveTileMapComponent? cComp))
continue;
map = cComp.TileMap;
return true;
}
return false;
}
#region AutoInclude
private void ProcessAutoInclude()
{
DebugTools.AssertEqual(_autoInclude.ToHashSet().Count, _autoInclude.Count);
var ents = new HashSet<EntityUid>();
switch (Options.MissingEntityBehaviour)
{
case MissingEntityBehaviour.PartialInclude:
// Include the entity and any of its direct parents
foreach (var uid in _autoInclude)
{
RecursivelyIncludeParents(uid, ents);
}
break;
case MissingEntityBehaviour.IncludeNullspace:
case MissingEntityBehaviour.AutoInclude:
// Find the root transform of all the included entities
var roots = new HashSet<EntityUid>();
foreach (var uid in _autoInclude)
{
GetRootNode(uid, roots);
}
// Recursively include all children of these root nodes.
foreach (var root in roots)
{
RecursivelyIncludeChildren(root, ents);
}
break;
default:
throw new ArgumentOutOfRangeException();
}
_autoInclude.Clear();
SerializeEntitiesInternal(ents);
}
private void RecursivelyIncludeChildren(EntityUid uid, HashSet<EntityUid> ents)
{
if (!IsSerializable(uid))
return;
ents.Add(uid);
var xform = _xformQuery.GetComponent(uid);
foreach (var child in xform._children)
{
RecursivelyIncludeChildren(child, ents);
}
}
private void GetRootNode(EntityUid uid, HashSet<EntityUid> ents)
{
if (!IsSerializable(uid))
throw new NotSupportedException($"Attempted to auto-include an unserializable entity: {EntMan.ToPrettyString(uid)}");
var xform = _xformQuery.GetComponent(uid);
while (xform.ParentUid.IsValid() && xform.ParentUid != Truncate)
{
uid = xform.ParentUid;
xform = _xformQuery.GetComponent(uid);
if (!IsSerializable(uid))
throw new NotSupportedException($"Encountered an un-serializable parent entity: {EntMan.ToPrettyString(uid)}");
}
ents.Add(uid);
}
private void RecursivelyIncludeParents(EntityUid uid, HashSet<EntityUid> ents)
{
while (uid.IsValid() && uid != Truncate)
{
if (!ents.Add(uid))
break;
if (!IsSerializable(uid))
throw new NotSupportedException($"Encountered an un-serializable parent entity: {EntMan.ToPrettyString(uid)}");
uid = _xformQuery.GetComponent(uid).ParentUid;
}
}
#endregion
private void SerializeEntitiesInternal(HashSet<EntityUid> entities)
{
foreach (var uid in entities)
{
DebugTools.AssertNull(CurrentEntity);
SerializeEntityInternal(uid);
}
DebugTools.AssertNull(CurrentEntity);
if (_autoInclude.Count != 0)
ProcessAutoInclude();
}
/// <summary>
/// Serialize a single entity, and store the results in <see cref="EntityData"/>.
/// </summary>
private void SerializeEntityInternal(EntityUid uid)
{
var saveId = GetYamlUid(uid);
DebugTools.Assert(!EntityData.ContainsKey(saveId));
// It might be possible that something could cause an entity to be included twice.
// E.g., if someone serializes a grid w/o its map, and then tries to separately include the map and all its children.
// In that case, the grid would already have been serialized as an orphan.
// uhhh.... I guess its fine?
if (EntityData.ContainsKey(saveId))
return;
var meta = _metaQuery.GetComponent(uid);
var protoId = meta.EntityPrototype?.ID ?? string.Empty;
switch (meta.EntityLifeStage)
{
case <= EntityLifeStage.Initializing:
_log.Error($"Encountered an uninitialized entity: {EntMan.ToPrettyString(uid)}");
break;
case >= EntityLifeStage.Terminating:
_log.Error($"Encountered terminating or deleted entity: {EntMan.ToPrettyString(uid)}");
break;
}
CurrentEntityYamlUid = saveId;
CurrentEntity = (uid, meta);
Prototypes.GetOrNew(protoId).Add(saveId);
var xform = _xformQuery.GetComponent(uid);
if (_mapQuery.HasComp(uid))
Maps.Add(saveId);
else if (xform.ParentUid == EntityUid.Invalid)
Nullspace.Add(saveId);
if (_gridQuery.HasComp(uid))
{
// The current assumption is that grids cannot be in null-space, because the rest of the code
// (broadphase, etc) don't support grids without maps.
DebugTools.Assert(xform.ParentUid != EntityUid.Invalid || _mapQuery.HasComp(uid));
Grids.Add(saveId);
}
var entData = new MappingDataNode
{
{"uid", saveId.ToString(CultureInfo.InvariantCulture)}
};
EntityData[saveId] = (uid, entData);
var cache = GetProtoCache(meta.EntityPrototype);
// Store information about whether a given entity has been map-initialized.
// In principle, if a map has been map-initialized, then all entities on that map should also be map-initialized.
// But technically there is nothing that prevents someone from moving a post-init entity onto a pre-init map and vice-versa.
// Also, we need to record this information even if the map is not being serialized.
// In 99% of cases, this data is probably redundant and just bloats the file, but I can't think of a better way of handling it.
// At least it should only bloat post-init maps, which aren't really getting used so far.
if (meta.EntityLifeStage == EntityLifeStage.MapInitialized)
{
if (Options.ExpectPreInit)
_log.Error($"Expected all entities to be pre-mapinit, but encountered post-init entity: {EntMan.ToPrettyString(uid)}");
entData.Add("mapInit", "true");
// If an entity has been map-initialized, we assume it is un-paused.
// If it is paused, we have to specify it.
if (meta.EntityPaused)
entData.Add("paused", "true");
}
else
{
// If an entity has not yet been map-initialized, we assume it is paused.
// I don't know in what situations it wouldn't be, but might as well future proof this.
if (!meta.EntityPaused)
entData.Add("paused", "false");
}
var components = new SequenceDataNode();
if (xform.NoLocalRotation && xform.LocalRotation != 0)
{
_log.Error($"Encountered a no-rotation entity with non-zero local rotation: {EntMan.ToPrettyString(uid)}");
xform._localRotation = 0;
}
try
{
SerializeComponents(uid, cache, components);
}
catch(Exception e)
{
if (Options.EntityExceptionBehaviour == EntityExceptionBehaviour.Rethrow)
{
_log.Error($"Caught exception while serializing component {CurrentComponent} of entity {EntMan.ToPrettyString(uid)}");
throw;
}
_log.Error($"Caught exception while serializing component {CurrentComponent} of entity {EntMan.ToPrettyString(uid)}:\n{e}");
CurrentEntityYamlUid = 0;
CurrentEntity = null;
CurrentComponent = null;
RemoveErroringEntity(uid);
return;
}
CurrentComponent = null;
if (components.Count != 0)
entData.Add("components", components);
// TODO ENTITY SERIALIZATION
// Consider adding a Action<EntityUid, MappingDataNode>? OnEntitySerialized
// I.e., allow content to modify the per-entity data? I don't know if that would actually be useful, as content
// could just as easily append a separate entity dictionary to the output that has the extra per-entity data they
// want to serialize.
if (meta.EntityPrototype == null)
{
CurrentEntityYamlUid = 0;
CurrentEntity = null;
return;
}
// an entity may have fewer components than the original prototype, so we need to check if any are missing.
SequenceDataNode? missingComponents = null;
foreach (var (name, comp) in meta.EntityPrototype.Components)
{
// try comp instead of has-comp as it checks whether the component is supposed to have been
// deleted.
if (EntMan.TryGetComponent(uid, comp.Component.GetType(), out _))
continue;
missingComponents ??= new();
missingComponents.Add(new ValueDataNode(name));
}
if (missingComponents != null)
entData.Add("missingComponents", missingComponents);
CurrentEntityYamlUid = 0;
CurrentEntity = null;
}
/// <summary>
/// Remove an exception throwing entity (and possibly its children) from the serialized data.
/// </summary>
private void RemoveErroringEntity(EntityUid uid)
{
if (Options.EntityExceptionBehaviour == EntityExceptionBehaviour.IgnoreEntityAndChildren)
{
foreach (var child in _xformQuery.GetComponent(uid)._children)
{
RemoveErroringEntity(child);
}
}
ErroringEntities.Add(uid);
if (!YamlUidMap.TryGetValue(uid, out var yamlId))
return;
Nullspace.Remove(yamlId);
Orphans.Remove(yamlId);
Maps.Remove(yamlId);
Grids.Remove(yamlId);
EntityData.Remove(yamlId);
if (_metaQuery.TryGetComponent(uid, out var meta)
&& meta.EntityPrototype != null
&& Prototypes.TryGetValue(meta.EntityPrototype.ID, out var proto))
{
proto.Remove(yamlId);
}
}
private void SerializeComponents(EntityUid uid, Dictionary<string, MappingDataNode>? cache, SequenceDataNode components)
{
foreach (var component in EntMan.GetComponentsInternal(uid))
{
var compType = component.GetType();
var reg = _factory.GetRegistration(compType);
if (reg.Unsaved)
continue;
CurrentComponent = reg.Name;
MappingDataNode? compMapping;
MappingDataNode? protoMapping = null;
if (cache != null && cache.TryGetValue(reg.Name, out protoMapping))
{
// If this has a prototype, we need to use alwaysWrite: true.
// E.g., an anchored prototype might have anchored: true. If we we are saving an un-anchored
// instance of this entity, and if we have alwaysWrite: false, then compMapping would not include
// the anchored data-field (as false is the default for this bool data field), so the entity would
// implicitly be saved as anchored.
compMapping = _serialization.WriteValueAs<MappingDataNode>(compType, component, alwaysWrite: true, context: this);
// This will not recursively call Except() on the values of the mapping. It will only remove
// key-value pairs if both the keys and values are equal.
compMapping = compMapping.Except(protoMapping);
if(compMapping == null)
continue;
}
else
{
compMapping = _serialization.WriteValueAs<MappingDataNode>(compType, component, alwaysWrite: false, context: this);
}
// Don't need to write it if nothing was written! Note that if this entity has no associated
// prototype, we ALWAYS want to write the component, because merely the fact that it exists is
// information that needs to be written.
if (compMapping.Children.Count == 0 && protoMapping != null)
continue;
compMapping.InsertAt(0, "type", new ValueDataNode(reg.Name));
components.Add(compMapping);
}
}
private Dictionary<string, MappingDataNode>? GetProtoCache(EntityPrototype? proto)
{
if (proto == null)
return null;
if (PrototypeCache.TryGetValue(proto.ID, out var cache))
return cache;
PrototypeCache[proto.ID] = cache = new(proto.Components.Count);
WritingReadingPrototypes = true;
foreach (var (compName, comp) in proto.Components)
{
CurrentComponent = compName;
cache.Add(compName, _serialization.WriteValueAs<MappingDataNode>(comp.Component.GetType(), comp.Component, alwaysWrite: true, context: this));
}
CurrentComponent = null;
WritingReadingPrototypes = false;
cache.TryAdd(_metaName, _emptyMetaNode);
cache.TryAdd(_xformName, _emptyXformNode);
return cache;
}
#region Write
public MappingDataNode Write()
{
DebugTools.AssertEqual(Maps.ToHashSet().Count, Maps.Count, "Duplicate maps?");
DebugTools.AssertEqual(Grids.ToHashSet().Count, Grids.Count, "Duplicate grids?");
DebugTools.AssertEqual(Orphans.ToHashSet().Count, Orphans.Count, "Duplicate orphans?");
DebugTools.AssertEqual(Nullspace.ToHashSet().Count, Nullspace.Count, "Duplicate nullspace?");
return new MappingDataNode
{
{"meta", WriteMetadata()},
{"maps", WriteIds(Maps)},
{"grids", WriteIds(Grids)},
{"orphans", WriteIds(Orphans)},
{"nullspace", WriteIds(Nullspace)},
{"tilemap", WriteTileMap()},
{"entities", WriteEntitySection()},
};
}
public MappingDataNode WriteMetadata()
{
return new MappingDataNode
{
{"format", MapFormatVersion.ToString(CultureInfo.InvariantCulture)},
{"category", GetCategory().ToString()},
{"engineVersion", _conf.GetCVar(CVars.BuildEngineVersion) },
{"forkId", _conf.GetCVar(CVars.BuildForkId)},
{"forkVersion", _conf.GetCVar(CVars.BuildVersion)},
{"time", DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)},
{"entityCount", EntityData.Count.ToString(CultureInfo.InvariantCulture)}
};
}
public SequenceDataNode WriteIds(List<int> ids)
{
var result = new SequenceDataNode();
foreach (var id in ids)
{
result.Add(new ValueDataNode(id.ToString(CultureInfo.InvariantCulture)));
}
return result;
}
/// <summary>
/// Serialize the <see cref="_tileMap"/> to yaml. This data is required to deserialize any serialized grid chunks using <see cref="MapChunkSerializer"/>.
/// </summary>
public MappingDataNode WriteTileMap()
{
var map = new MappingDataNode();
foreach (var (tileId, yamlTileId) in _tileMap.OrderBy(x => x.Key))
{
// This can come up if tests try to serialize test maps with custom / placeholder tile ids without registering them with the tile def manager..
if (!_tileDef.TryGetDefinition(tileId, out var def))
throw new Exception($"Attempting to serialize a tile {tileId} with no valid tile definition.");
var yamlId = yamlTileId.ToString(CultureInfo.InvariantCulture);
map.Add(yamlId, def.ID);
}
return map;
}
public SequenceDataNode WriteEntitySection()
{
// Check that EntityData contains the expected number of entities.
if (Options.EntityExceptionBehaviour != EntityExceptionBehaviour.IgnoreEntity
&& Options.EntityExceptionBehaviour != EntityExceptionBehaviour.IgnoreEntityAndChildren
&& (YamlIds.Count != YamlUidMap.Count || YamlIds.Count != EntityData.Count))
{
// Maybe someone reserved a yaml id with ReserveYamlId() or implicitly with GetId() without actually
// ever serializing the entity, This can lead to references to non-existent entities.
throw new Exception($"Entity count mismatch");
}
var prototypes = new SequenceDataNode();
var protos = Prototypes.Keys.ToList();
protos.Sort(StringComparer.InvariantCulture);
foreach (var protoId in protos)
{
var entities = new SequenceDataNode();
var node = new MappingDataNode
{
{ "proto", protoId },
{ "entities", entities},
};
prototypes.Add(node);
var saveIds = Prototypes[protoId];
saveIds.Sort();
foreach (var saveId in saveIds)
{
var entData = EntityData[saveId].Node;
entities.Add(entData);
}
}
return prototypes;
}
/// <summary>
/// Get the category that the serialized data belongs to. If one was specified in the
/// <see cref="SerializationOptions"/> it will use that after validating it, otherwise it will attempt to infer a
/// category.
/// </summary>
public FileCategory GetCategory()
{
switch (Options.Category)
{
case FileCategory.Save:
return FileCategory.Save;
case FileCategory.Map:
return Maps.Count == 1 ? FileCategory.Map : FileCategory.Unknown;
case FileCategory.Grid:
if (Maps.Count > 0 || Grids.Count != 1)
return FileCategory.Unknown;
return FileCategory.Grid;
case FileCategory.Entity:
if (Maps.Count > 0 || Grids.Count > 0 || Orphans.Count != 1)
return FileCategory.Unknown;
return FileCategory.Entity;
default:
if (Maps.Count == 1)
{
// Contains a single map, and no orphaned entities that need reparenting.
if (Orphans.Count == 0)
return FileCategory.Map;
}
else if (Grids.Count == 1)
{
// Contains a single orphaned grid.
if (Orphans.Count == 1 && Grids[0] == Orphans[0])
return FileCategory.Grid;
}
else if (Orphans.Count == 1)
{
// A lone orphaned entity.
return FileCategory.Entity;
}
return FileCategory.Unknown;
}
}
#endregion
#region YamlIds
/// <summary>
/// Get (or allocate) the integer id that will be used in the serialized file to refer to the given entity.
/// </summary>
public int GetYamlUid(EntityUid uid)
{
return !YamlUidMap.TryGetValue(uid, out var id) ? AllocateYamlUid(uid) : id;
}
private int AllocateYamlUid(EntityUid uid)
{
if (Truncated.Contains(uid))
{
_log.Error(
"Including a previously truncated entity within the serialization process? Something probably wrong");
}
DebugTools.Assert(!YamlUidMap.ContainsKey(uid));
while (!YamlIds.Add(_nextYamlUid))
{
_nextYamlUid++;
}
YamlUidMap.Add(uid, _nextYamlUid);
return _nextYamlUid++;
}
/// <summary>
/// Get (or allocate) the integer id that will be used in the serialized file to refer to the given grid tile id.
/// </summary>
public int GetYamlTileId(int tileId)
{
if (_tileMap.TryGetValue(tileId, out var yamlId))
return yamlId;
return AllocateYamlTileId(tileId);
}
private int AllocateYamlTileId(int tileId)
{
while (!_yamlTileIds.Add(_nextYamlTileId))
{
_nextYamlTileId++;
}
_tileMap[tileId] = _nextYamlTileId;
return _nextYamlTileId++;
}
/// <summary>
/// This method ensures that the given entities have a yaml ids assigned. If the entities have a
/// <see cref="YamlUidComponent"/>, they will attempt to use that id, which exists to prevent large map file diffs
/// due to changing yaml ids.
/// </summary>
public void ReserveYamlIds(HashSet<EntityUid> entities)
{
List<EntityUid> needIds = new();
foreach (var uid in entities)
{
if (YamlUidMap.ContainsKey(uid))
continue;
if (_yamlQuery.TryGetComponent(uid, out var comp) && comp.Uid > 0 && YamlIds.Add(comp.Uid))
{
if (Truncated.Contains(uid))
{
_log.Error(
"Including a previously truncated entity within the serialization process? Something probably wrong");
}
YamlUidMap.Add(uid, comp.Uid);
}
else
{
needIds.Add(uid);
}
}
foreach (var uid in needIds)
{
AllocateYamlUid(uid);
}
}
/// <summary>
/// This method ensures that the given entity has a yaml id assigned to it. If the entity has a
/// <see cref="YamlUidComponent"/>, it will attempt to use that id, which exists to prevent large map file diffs due
/// to changing yaml ids.
/// </summary>
public void ReserveYamlId(EntityUid uid)
{
if (YamlUidMap.ContainsKey(uid))
return;
if (_yamlQuery.TryGetComponent(uid, out var comp) && comp.Uid > 0 && YamlIds.Add(comp.Uid))
{
if (Truncated.Contains(uid))
{
_log.Error(
"Including a previously truncated entity within the serialization process? Something probably wrong");
}
YamlUidMap.Add(uid, comp.Uid);
}
else
AllocateYamlUid(uid);
}
#endregion
#region ITypeSerializer
ValidationNode ITypeValidator<EntityUid, ValueDataNode>.Validate(
ISerializationManager serializationManager,
ValueDataNode node,
IDependencyCollection dependencies,
ISerializationContext? context)
{
if (node.Value == "invalid")
return new ValidatedValueNode(node);
if (!int.TryParse(node.Value, out _))
return new ErrorNode(node, "Invalid EntityUid");
return new ValidatedValueNode(node);
}
public DataNode Write(
ISerializationManager serializationManager,
EntityUid value,
IDependencyCollection dependencies,
bool alwaysWrite = false,
ISerializationContext? context = null)
{
if (YamlUidMap.TryGetValue(value, out var yamlId))
return new ValueDataNode(yamlId.ToString(CultureInfo.InvariantCulture));
if (CurrentComponent == _xformName)
{
if (value == EntityUid.Invalid)
return InvalidNode;
DebugTools.Assert(!Orphans.Contains(CurrentEntityYamlUid));
Orphans.Add(CurrentEntityYamlUid);
if (Options.ErrorOnOrphan && CurrentEntity != null && value != Truncate && !ErroringEntities.Contains(value))
_log.Error($"Serializing entity {EntMan.ToPrettyString(CurrentEntity)} without including its parent {EntMan.ToPrettyString(value)}");
return InvalidNode;
}
if (ErroringEntities.Contains(value))
{
// Referenced entity already logged an error, so we just silently fail.
return InvalidNode;
}
if (value == EntityUid.Invalid)
{
if (Options.MissingEntityBehaviour != MissingEntityBehaviour.Ignore)
_log.Error($"Encountered an invalid entityUid reference.");
return InvalidNode;
}
if (value == Truncate)
{
_log.Error(
$"{EntMan.ToPrettyString(CurrentEntity)}:{CurrentComponent} is attempting to serialize references to a truncated entity {EntMan.ToPrettyString(Truncate)}.");
}
switch (Options.MissingEntityBehaviour)
{
case MissingEntityBehaviour.Error:
_log.Error(EntMan.Deleted(value)
? $"Encountered a reference to a deleted entity {value} while serializing {EntMan.ToPrettyString(CurrentEntity)}."
: $"Encountered a reference to a missing entity: {value} while serializing {EntMan.ToPrettyString(CurrentEntity)}.");
return InvalidNode;
case MissingEntityBehaviour.Ignore:
return InvalidNode;
case MissingEntityBehaviour.IncludeNullspace:
if (!EntMan.TryGetComponent(value, out TransformComponent? xform)
|| xform.ParentUid != EntityUid.Invalid
|| _gridQuery.HasComp(value)
|| _mapQuery.HasComp(value))
{
goto case MissingEntityBehaviour.Error;
}
goto case MissingEntityBehaviour.AutoInclude;
case MissingEntityBehaviour.PartialInclude:
case MissingEntityBehaviour.AutoInclude:
if (Options.LogAutoInclude is {} level)
_log.Log(level, $"Auto-including entity {EntMan.ToPrettyString(value)} referenced by {EntMan.ToPrettyString(CurrentEntity)}");
_autoInclude.Add(value);
var id = GetYamlUid(value);
return new ValueDataNode(id.ToString(CultureInfo.InvariantCulture));
default:
throw new ArgumentOutOfRangeException();
}
}
EntityUid ITypeReader<EntityUid, ValueDataNode>.Read(
ISerializationManager serializationManager,
ValueDataNode node,
IDependencyCollection dependencies,
SerializationHookContext hookCtx,
ISerializationContext? context,
ISerializationManager.InstantiationDelegate<EntityUid>? _)
{
return node.Value == "invalid" ? EntityUid.Invalid : EntityUid.Parse(node.Value);
}
public ValidationNode Validate(
ISerializationManager serializationManager,
ValueDataNode node,
IDependencyCollection dependencies,
ISerializationContext? context = null)
{
if (node.Value == "invalid")
return new ValidatedValueNode(node);
if (!int.TryParse(node.Value, out _))
return new ErrorNode(node, "Invalid NetEntity");
return new ValidatedValueNode(node);
}
public NetEntity Read(
ISerializationManager serializationManager,
ValueDataNode node,
IDependencyCollection dependencies,
SerializationHookContext hookCtx,
ISerializationContext? context = null,
ISerializationManager.InstantiationDelegate<NetEntity>? instanceProvider = null)
{
return node.Value == "invalid" ? NetEntity.Invalid : NetEntity.Parse(node.Value);
}
public DataNode Write(
ISerializationManager serializationManager,
NetEntity value,
IDependencyCollection dependencies,
bool alwaysWrite = false,
ISerializationContext? context = null)
{
var uid = EntMan.GetEntity(value);
return serializationManager.WriteValue(uid, alwaysWrite, context);
}
ValidationNode ITypeValidator<MapId, ValueDataNode>.Validate(
ISerializationManager seri,
ValueDataNode node,
IDependencyCollection deps,
ISerializationContext? context)
{
return seri.ValidateNode<EntityUid>(node, context);
}
MapId ITypeReader<MapId, ValueDataNode>.Read(
ISerializationManager seri,
ValueDataNode node,
IDependencyCollection deps,
SerializationHookContext hookCtx,
ISerializationContext? ctx,
ISerializationManager.InstantiationDelegate<MapId>? instanceProvider)
{
return EntMan.TryGetComponent(seri.Read<EntityUid>(node, ctx), out MapComponent? mapComp)
? mapComp.MapId
: MapId.Nullspace;
}
DataNode ITypeWriter<MapId>.Write(
ISerializationManager seri,
MapId value,
IDependencyCollection deps,
bool alwaysWrite,
ISerializationContext? ctx)
{
if (_map.TryGetMap(value, out var uid))
return seri.WriteValue(uid, alwaysWrite, ctx);
_log.Error($"Attempted to serialize invalid map id {value} while serializing component '{CurrentComponent}' on entity '{EntMan.ToPrettyString(uid)}'");
return new ValueDataNode("invalid");
}
#endregion
}