Files
RobustToolbox/Robust.Shared/EntitySerialization/EntitySerializer.cs
T
83c2a1be11 [Dependency] source generator part 2 (#6550)
* [Dependency] source generator

No more reflection, no more codegen at runtime

Also various changes to Roslyn helpers to make this easier to write.

Requires all types with dependencies to be partial and not have readonly dependency fields. An analyzer enforces this at warning level, the previous injection strategies have remained in the code *for now* as a fallback.

No fallback is available for [field: Dependency] properties, due to a Roslyn bug.

Code Fixes exist. We love Roslyn

* Apply dependencies generator changes to all code

* Release notes

* Preprocessor got hands

* Handle nullable dependencies

These are bad but gotta deal with it.

* Apply suggestions from code review

Co-authored-by: Moony <moony@hellomouse.net>

* Fine, let's not use collection expressions

---------

Co-authored-by: Moony <moony@hellomouse.net>
2026-05-08 12:38:33 +02:00

1135 lines
42 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 partial 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 EntityManager EntMan = default!;
[Dependency] public IGameTiming Timing = default!;
[Dependency] private IComponentFactory _factory = default!;
[Dependency] private ISerializationManager _serialization = default!;
[Dependency] private ITileDefinitionManager _tileDef = default!;
[Dependency] private IConfigurationManager _conf = default!;
[Dependency] private ILogManager _logMan = default!;
[Dependency] private 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 &amp; 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
}