From 7b8c790820ecfb62caed0f83e22da71f603da76e Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Sun, 22 Dec 2024 15:13:48 +1300 Subject: [PATCH 01/27] Refactor map loading & saving --- RELEASE-NOTES.md | 17 +- .../GameObjects/EntitySystems/MapSystem.cs | 2 +- Robust.Server/Console/Commands/MapCommands.cs | 93 +- .../EntitySystems/MapLoaderSystem.cs | 1354 ----------------- .../GameObjects/EntitySystems/MapSystem.cs | 2 +- .../IServerEntityManagerInternal.cs | 21 - .../GameObjects/MapSaveIdComponent.cs | 17 - .../GameObjects/ServerEntityManager.cs | 28 +- Robust.Server/GameStates/PvsOverrideSystem.cs | 15 +- Robust.Server/GameStates/PvsSystem.Chunks.cs | 5 +- Robust.Server/GameStates/PvsSystem.cs | 2 +- Robust.Server/Maps/LoadedMapComponent.cs | 12 - Robust.Server/Maps/MapLoadOptions.cs | 59 - Robust.Server/Maps/YamlGridSerializer.cs | 0 Robust.Server/ServerIoC.cs | 1 - .../ComponentTrees/ComponentTreeSystem.cs | 7 +- Robust.Shared/Console/Commands/MapCommands.cs | 2 +- .../Components/LoadedMapComponent.cs | 12 + .../Components}/MapSaveTileMapComponent.cs | 3 +- .../Components/YamlUidComponent.cs | 18 + .../EntitySerialization/EntityDeserializer.cs | 995 ++++++++++++ .../EntitySerialization/EntitySerializer.cs | 872 +++++++++++ .../EntitySerialization/LoadResult.cs | 71 + .../MapChunkSerializer.cs | 38 +- Robust.Shared/EntitySerialization/Options.cs | 121 ++ .../EntitySerialization/SerializationEnums.cs | 82 + .../Systems/MapLoaderSystem.Load.cs | 505 ++++++ .../Systems/MapLoaderSystem.Save.cs | 131 ++ .../Systems/MapLoaderSystem.cs | 35 + Robust.Shared/GameObjects/EntityManager.cs | 28 +- .../GameObjects/Systems/EntityLookupSystem.cs | 6 +- .../Systems/SharedMapSystem.Map.cs | 124 +- .../Systems/SharedMapSystem.MapInit.cs | 9 +- .../Systems/SharedMapSystem.Pause.cs | 4 +- .../GameObjects/Systems/SharedMapSystem.cs | 19 + .../SharedTransformSystem.Component.cs | 12 +- .../Systems/SharedTransformSystem.cs | 2 +- Robust.Shared/Map/Components/MapComponent.cs | 2 +- .../Map/Events/MapSerializationEvents.cs | 7 + Robust.Shared/Map/IMapManager.cs | 14 +- Robust.Shared/Map/ITileDefinitionManager.cs | 10 - Robust.Shared/Map/MapId.cs | 2 +- .../Map/MapManager.GridCollection.cs | 3 + Robust.Shared/Map/MapManager.MapCollection.cs | 11 +- Robust.Shared/Map/MapManager.Pause.cs | 12 +- Robust.Shared/Map/MapManager.Queries.cs | 36 +- Robust.Shared/Map/MapManager.cs | 12 +- Robust.Shared/Map/MapSerializationContext.cs | 173 --- Robust.Shared/Map/TileDefinitionManager.cs | 42 - Robust.Shared/Physics/FixtureSerializer.cs | 7 +- Robust.Shared/Prototypes/EntityPrototype.cs | 3 +- .../Prototypes/TileAliasPrototype.cs | 7 +- .../Custom/TimeOffsetSerializer.cs | 50 +- Robust.UnitTesting/RobustUnitTest.cs | 13 +- .../GameObjects/Components/Transform_Test.cs | 4 +- .../Server/Maps/MapLoaderTest.cs | 128 +- .../Shared/GameObjects/ContainerTests.cs | 16 +- .../Systems/AnchoredSystemTests.cs | 1 - .../Shared/Map/EntityCoordinates_Tests.cs | 4 - .../Custom/TimeOffsetSerializer.cs | 111 -- 60 files changed, 3234 insertions(+), 2158 deletions(-) delete mode 100644 Robust.Server/GameObjects/EntitySystems/MapLoaderSystem.cs delete mode 100644 Robust.Server/GameObjects/IServerEntityManagerInternal.cs delete mode 100644 Robust.Server/GameObjects/MapSaveIdComponent.cs delete mode 100644 Robust.Server/Maps/LoadedMapComponent.cs delete mode 100644 Robust.Server/Maps/MapLoadOptions.cs delete mode 100644 Robust.Server/Maps/YamlGridSerializer.cs create mode 100644 Robust.Shared/EntitySerialization/Components/LoadedMapComponent.cs rename {Robust.Server/GameObjects => Robust.Shared/EntitySerialization/Components}/MapSaveTileMapComponent.cs (89%) create mode 100644 Robust.Shared/EntitySerialization/Components/YamlUidComponent.cs create mode 100644 Robust.Shared/EntitySerialization/EntityDeserializer.cs create mode 100644 Robust.Shared/EntitySerialization/EntitySerializer.cs create mode 100644 Robust.Shared/EntitySerialization/LoadResult.cs rename {Robust.Server/Maps => Robust.Shared/EntitySerialization}/MapChunkSerializer.cs (81%) create mode 100644 Robust.Shared/EntitySerialization/Options.cs create mode 100644 Robust.Shared/EntitySerialization/SerializationEnums.cs create mode 100644 Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs create mode 100644 Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs create mode 100644 Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs delete mode 100644 Robust.Shared/Map/MapSerializationContext.cs delete mode 100644 Robust.UnitTesting/Shared/Serialization/TypeSerializers/Custom/TimeOffsetSerializer.cs diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index a0daaf128e8..dea41456427 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -35,11 +35,21 @@ END TEMPLATE--> ### Breaking changes -*None yet* +* `ITileDefinitionManager.AssignAlias` and general tile alias functionality has been removed. `TileAliasPrototype` still exist, but are only used during entity deserialization. +* `IMapManager.AddUninitializedMap` has been removed. Use the map-init options on `CreateMap()` instead. +* Re-using a MapId will now log a warning. This may cause some integertion tests to fail if they are configured to fail + when warnings are logged. +* The minimum supported map format / version has been increased from 2 to 3. +* The server-side `MapLoaderSystem` and associated classes & structs has been moved to `Robust.Shared`, and has been significantly modified. + * The`TryLoad` and `Save` methods have been replaced with grid, map, generic entity variants. I.e, `SaveGrid`, `SaveMap`, and `SaveEntities`. + * Most of the serialization logic and methods have been moved out of `MapLoaderSystem` and into new `EntitySerializer` + and `EntityDeserializer` classes, which also replace the old `MapSerializationContext`. + * The `MapLoadOptions` class has been split into `MapLoadOptions`, `SerializationOptions`, and `DeserializationOptions` + structs. ### New features -*None yet* +* The current map format/version has increased from 6 to 7 and now contains more information to try support serialization of maps with null-space entities and full game saves. ### Bugfixes @@ -47,13 +57,12 @@ END TEMPLATE--> ### Other -*None yet* +* `MapChangedEvent` has been marked as obsolet, and should be replaced with `MapCreatedEvent` and `MapRemovedEvent. ### Internal *None yet* - ## 238.0.0 ### Breaking changes diff --git a/Robust.Client/GameObjects/EntitySystems/MapSystem.cs b/Robust.Client/GameObjects/EntitySystems/MapSystem.cs index 9077957e04e..31161d45a5c 100644 --- a/Robust.Client/GameObjects/EntitySystems/MapSystem.cs +++ b/Robust.Client/GameObjects/EntitySystems/MapSystem.cs @@ -17,7 +17,7 @@ protected override MapId GetNextMapId() { // Client-side map entities use negative map Ids to avoid conflict with server-side maps. var id = new MapId(--LastMapId); - while (MapManager.MapExists(id)) + while (MapExists(id) || UsedIds.Contains(id)) { id = new MapId(--LastMapId); } diff --git a/Robust.Server/Console/Commands/MapCommands.cs b/Robust.Server/Console/Commands/MapCommands.cs index 83a1bcf4769..3a8e5d822e2 100644 --- a/Robust.Server/Console/Commands/MapCommands.cs +++ b/Robust.Server/Console/Commands/MapCommands.cs @@ -1,14 +1,15 @@ using System.Linq; using System.Numerics; -using Robust.Server.GameObjects; -using Robust.Server.Maps; using Robust.Shared.Console; using Robust.Shared.ContentPack; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Systems; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Map; using Robust.Shared.Maths; +using Robust.Shared.Utility; namespace Robust.Server.Console.Commands { @@ -42,7 +43,7 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) return; } - _ent.System().Save(uid, args[1]); + _ent.System().SaveGrid(uid, args[1]); shell.WriteLine("Save successful. Look in the user data directory."); } @@ -63,7 +64,6 @@ public override CompletionResult GetCompletion(IConsoleShell shell, string[] arg public sealed class LoadGridCommand : LocalizedCommands { [Dependency] private readonly IEntitySystemManager _system = default!; - [Dependency] private readonly IMapManager _map = default!; [Dependency] private readonly IResourceManager _resource = default!; public override string Command => "loadgrid"; @@ -91,13 +91,14 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) return; } - if (!_map.MapExists(mapId)) + var sys = _system.GetEntitySystem(); + if (!sys.MapExists(mapId)) { shell.WriteError("Target map does not exist."); return; } - var loadOptions = new MapLoadOptions(); + Vector2 offset = default; if (args.Length >= 4) { if (!float.TryParse(args[2], out var x)) @@ -112,9 +113,10 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) return; } - loadOptions.Offset = new Vector2(x, y); + offset = new Vector2(x, y); } + Angle rot = default; if (args.Length >= 5) { if (!float.TryParse(args[4], out var rotation)) @@ -123,9 +125,10 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) return; } - loadOptions.Rotation = Angle.FromDegrees(rotation); + rot = Angle.FromDegrees(rotation); } + var opts = DeserializationOptions.Default; if (args.Length >= 6) { if (!bool.TryParse(args[5], out var storeUids)) @@ -134,10 +137,11 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) return; } - loadOptions.StoreMapUids = storeUids; + opts.StoreYamlUids = storeUids; } - _system.GetEntitySystem().Load(mapId, args[1], loadOptions); + var path = new ResPath(args[1]); + _system.GetEntitySystem().TryLoadGrid(mapId, path, out _, opts, offset, rot); } public override CompletionResult GetCompletion(IConsoleShell shell, string[] args) @@ -149,7 +153,6 @@ public override CompletionResult GetCompletion(IConsoleShell shell, string[] arg public sealed class SaveMap : LocalizedCommands { [Dependency] private readonly IEntitySystemManager _system = default!; - [Dependency] private readonly IMapManager _map = default!; [Dependency] private readonly IResourceManager _resource = default!; public override string Command => "savemap"; @@ -189,13 +192,14 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) if (mapId == MapId.Nullspace) return; - if (!_map.MapExists(mapId)) + var sys = _system.GetEntitySystem(); + if (!sys.MapExists(mapId)) { shell.WriteError(Loc.GetString("cmd-savemap-not-exist")); return; } - if (_map.IsMapInitialized(mapId) && + if (sys.IsInitialized(mapId) && ( args.Length < 3 || !bool.TryParse(args[2], out var force) || !force)) { shell.WriteError(Loc.GetString("cmd-savemap-init-warning")); @@ -211,7 +215,6 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) public sealed class LoadMap : LocalizedCommands { [Dependency] private readonly IEntitySystemManager _system = default!; - [Dependency] private readonly IMapManager _map = default!; [Dependency] private readonly IResourceManager _resource = default!; public override string Command => "loadmap"; @@ -267,61 +270,49 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) return; } - if (_map.MapExists(mapId)) + var sys = _system.GetEntitySystem(); + if (sys.MapExists(mapId)) { shell.WriteError(Loc.GetString("cmd-loadmap-exists", ("mapId", mapId))); return; } - var loadOptions = new MapLoadOptions(); - - float x = 0, y = 0; - if (args.Length >= 3) + float x = 0; + if (args.Length >= 3 && !float.TryParse(args[2], out x)) { - if (!float.TryParse(args[2], out x)) - { - shell.WriteError(Loc.GetString("cmd-parse-failure-float", ("arg", args[2]))); - return; - } + shell.WriteError(Loc.GetString("cmd-parse-failure-float", ("arg", args[2]))); + return; } - if (args.Length >= 4) + float y = 0; + if (args.Length >= 4 && !float.TryParse(args[3], out y)) { - - if (!float.TryParse(args[3], out y)) - { - shell.WriteError(Loc.GetString("cmd-parse-failure-float", ("arg", args[3]))); - return; - } + shell.WriteError(Loc.GetString("cmd-parse-failure-float", ("arg", args[3]))); + return; } + var offset = new Vector2(x, y); - loadOptions.Offset = new Vector2(x, y); - - if (args.Length >= 5) + float rotation = 0; + if (args.Length >= 5 && !float.TryParse(args[4], out rotation)) { - if (!float.TryParse(args[4], out var rotation)) - { - shell.WriteError(Loc.GetString("cmd-parse-failure-float", ("arg", args[4]))); - return; - } - - loadOptions.Rotation = new Angle(rotation); + shell.WriteError(Loc.GetString("cmd-parse-failure-float", ("arg", args[4]))); + return; } + var rot = new Angle(rotation); - if (args.Length >= 6) + bool storeUids = false; + if (args.Length >= 6 && !bool.TryParse(args[5], out storeUids)) { - if (!bool.TryParse(args[5], out var storeUids)) - { - shell.WriteError(Loc.GetString("cmd-parse-failure-bool", ("arg", args[5]))); - return; - } - - loadOptions.StoreMapUids = storeUids; + shell.WriteError(Loc.GetString("cmd-parse-failure-bool", ("arg", args[5]))); + return; } - _system.GetEntitySystem().TryLoad(mapId, args[1], out _, loadOptions); + var opts = new DeserializationOptions {StoreYamlUids = storeUids}; + + var path = new ResPath(args[1]); + _system.GetEntitySystem().TryLoadMapWithId(mapId, path, out _, out _, opts, offset, rot); - if (_map.MapExists(mapId)) + if (sys.MapExists(mapId)) shell.WriteLine(Loc.GetString("cmd-loadmap-success", ("mapId", mapId), ("path", args[1]))); else shell.WriteLine(Loc.GetString("cmd-loadmap-error", ("path", args[1]))); diff --git a/Robust.Server/GameObjects/EntitySystems/MapLoaderSystem.cs b/Robust.Server/GameObjects/EntitySystems/MapLoaderSystem.cs deleted file mode 100644 index 98644cb6128..00000000000 --- a/Robust.Server/GameObjects/EntitySystems/MapLoaderSystem.cs +++ /dev/null @@ -1,1354 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Numerics; -using Robust.Server.Maps; -using Robust.Shared.ContentPack; -using Robust.Shared.GameObjects; -using Robust.Shared.IoC; -using Robust.Shared.Log; -using Robust.Shared.Map; -using Robust.Shared.Map.Components; -using Robust.Shared.Map.Events; -using Robust.Shared.Maths; -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.Value; -using Robust.Shared.Timing; -using Robust.Shared.Utility; -using YamlDotNet.Core; -using YamlDotNet.RepresentationModel; - -namespace Robust.Server.GameObjects; - -public sealed class MapLoaderSystem : EntitySystem -{ - /* - * Not a partial of MapSystem so we don't have to deal with additional test dependencies. - */ - - [Dependency] private readonly IComponentFactory _factory = default!; - [Dependency] private readonly IGameTiming _timing = default!; - [Dependency] private readonly IMapManager _mapManager = default!; - [Dependency] private readonly IPrototypeManager _prototypeManager = default!; - [Dependency] private readonly IResourceManager _resourceManager = default!; - [Dependency] private readonly ISerializationManager _serManager = default!; - private IServerEntityManagerInternal _serverEntityManager = default!; - [Dependency] private readonly ITileDefinitionManager _tileDefManager = default!; - [Dependency] private readonly MetaDataSystem _meta = default!; - [Dependency] private readonly SharedMapSystem _mapSystem = default!; - [Dependency] private readonly SharedTransformSystem _transform = default!; - - private ISawmill _logLoader = default!; - private ISawmill _logWriter = default!; - - private const int MapFormatVersion = 6; - private const int BackwardsVersion = 2; - - private MapSerializationContext _context = default!; - private Stopwatch _stopwatch = new(); - - public override void Initialize() - { - base.Initialize(); - _serverEntityManager = (IServerEntityManagerInternal)EntityManager; - _logLoader = Logger.GetSawmill("loader"); - _logWriter = Logger.GetSawmill("writer"); - _logLoader.Level = LogLevel.Info; - _context = new MapSerializationContext(_serverEntityManager, _timing); - } - - #region Public - - [Obsolete("Use TryLoad")] - public EntityUid? LoadGrid(MapId mapId, string path, MapLoadOptions? options = null) - { - if (!TryLoad(mapId, path, out var grids, options)) - { - DebugTools.Assert(false); - return null; - } - - var actualGrids = new List(); - var gridQuery = GetEntityQuery(); - - foreach (var ent in grids) - { - if (!gridQuery.HasComponent(ent)) - continue; - - actualGrids.Add(ent); - } - - DebugTools.Assert(actualGrids.Count == 1); - return actualGrids[0]; - } - - [Obsolete("Use TryLoad")] - public IReadOnlyList LoadMap(MapId mapId, string path, MapLoadOptions? options = null) - { - if (TryLoad(mapId, path, out var grids, options)) - { - var actualGrids = new List(); - var gridQuery = GetEntityQuery(); - - foreach (var ent in grids) - { - if (!gridQuery.HasComponent(ent)) - continue; - - actualGrids.Add(ent); - } - - return actualGrids; - } - - DebugTools.Assert(false); - return new List(); - } - - public void Load(MapId mapId, string path, MapLoadOptions? options = null) - { - TryLoad(mapId, path, out _, options); - } - - /// - /// Tries to load the supplied path onto the supplied Mapid. - /// Will return false if something went wrong and needs handling. - /// - /// The Mapid to load onto. Depending on the supplied options this map may or may not already exist. - /// The resource path to the required map. - /// The root Uids of the map; not guaranteed to be grids! - /// The required options for loading. - /// - public bool TryLoad(MapId mapId, string path, [NotNullWhen(true)] out IReadOnlyList? rootUids, - MapLoadOptions? options = null) - { - options ??= new(); - - var resPath = new ResPath(path).ToRootedPath(); - - if (!TryGetReader(resPath, out var reader)) - { - rootUids = new List(); - return false; - } - - bool result; - - using (reader) - { - _logLoader.Info($"Loading Map: {resPath}"); - - _stopwatch.Restart(); - var data = new MapData(mapId, reader, options); - _logLoader.Debug($"Loaded yml stream in {_stopwatch.Elapsed}"); - var sw = new Stopwatch(); - sw.Start(); - result = Deserialize(data); - _logLoader.Debug($"Loaded map in {sw.Elapsed}"); - - var mapEnt = _mapSystem.GetMapOrInvalid(mapId); - var xformQuery = _serverEntityManager.GetEntityQuery(); - var rootEnts = new List(); - // aeoeoeieioe content - - if (HasComp(mapEnt)) - { - rootEnts.Add(mapEnt); - } - else - { - foreach (var ent in data.Entities) - { - if (xformQuery.GetComponent(ent).ParentUid == mapEnt) - rootEnts.Add(ent); - } - } - - rootUids = rootEnts; - } - - _context.Clear(); - -#if DEBUG - DebugTools.Assert(result); -#endif - - return result; - } - - public void Save(EntityUid uid, string ymlPath) - { - if (!Exists(uid)) - { - _logLoader.Error($"Unable to find entity {uid} for saving."); - return; - } - - if (Transform(uid).MapUid == null) - { - _logLoader.Error($"Found invalid map for {ToPrettyString(uid)}, aborting saving."); - return; - } - - _logLoader.Debug($"Saving entity {ToPrettyString(uid)} to {ymlPath}"); - - var document = new YamlDocument(GetSaveData(uid).ToYaml()); - - var resPath = new ResPath(ymlPath).ToRootedPath(); - _resourceManager.UserData.CreateDir(resPath.Directory); - - using var writer = _resourceManager.UserData.OpenWriteText(resPath); - { - var stream = new YamlStream { document }; - stream.Save(new YamlMappingFix(new Emitter(writer)), false); - } - - _logLoader.Info($"Saved {ToPrettyString(uid)} to {ymlPath}"); - } - - public void SaveMap(MapId mapId, string ymlPath) - { - if (!_mapSystem.TryGetMap(mapId, out var mapUid)) - { - _logLoader.Error($"Unable to find map {mapId}"); - return; - } - - Save(mapUid.Value, ymlPath); - } - - #endregion - - #region Loading - - private bool TryGetReader(ResPath resPath, [NotNullWhen(true)] out TextReader? reader) - { - // try user - if (!_resourceManager.UserData.Exists(resPath)) - { - _logLoader.Info($"No user map found: {resPath}"); - - // fallback to content - if (_resourceManager.TryContentFileRead(resPath, out var contentReader)) - { - reader = new StreamReader(contentReader); - } - else - { - _logLoader.Error($"No map found: {resPath}"); - reader = null; - return false; - } - } - else - { - reader = _resourceManager.UserData.OpenText(resPath); - } - - return true; - } - - private bool Deserialize(MapData data) - { - var ev = new BeforeEntityReadEvent(); - RaiseLocalEvent(ev); - - // First we load map meta data like version. - if (!ReadMetaSection(data)) - return false; - - // Verify that prototypes for all the entities exist - if (!VerifyEntitiesExist(data, ev)) - return false; - - // Tile map - ReadTileMapSection(data); - - // Alloc entities - var toDelete = AllocEntities(data, ev); - - // Load the prototype data onto entities, e.g. transform parents, etc. - LoadEntities(data); - - // Assign MapSaveTileMapComponent to all read grids. - SaveGridTileMap(data); - - // Build the scene graph / transform hierarchy to know the order to startup entities. - // This also allows us to swap out the root node up front if necessary. - BuildEntityHierarchy(data); - - // From hierarchy work out root node; if we're loading onto an existing map then see if we need to swap out - // the root from the yml. - SwapRootNode(data); - - ReadGrids(data); - - // grids prior to engine v175 might've been serialized with empty chunks which now throw debug asserts. - RemoveEmptyChunks(data); - - // Then, go hierarchically in order and do the entity things. - StartupEntities(data); - - // At the very end, delete entities belonging to removed prototypes. This is being done after startup just in - // case these entities have any children that somehow rely on startup in order to properly shut down. - // This is pretty cursed and might cause unexpected errors. - foreach (var uid in toDelete) - { - Del(uid); - data.Entities.Remove(uid); - } - - return true; - } - - private void RemoveEmptyChunks(MapData data) - { - var gridQuery = _serverEntityManager.GetEntityQuery(); - foreach (var uid in data.EntitiesToDeserialize.Keys) - { - if (!gridQuery.TryGetComponent(uid, out var gridComp)) - continue; - - foreach (var (index, chunk) in gridComp.Chunks) - { - if (chunk.FilledTiles > 0) - continue; - - Log.Warning($"Encountered empty chunk while deserializing map. Grid: {ToPrettyString(uid)}. Chunk index: {index}"); - gridComp.Chunks.Remove(index); - } - } - } - - private bool VerifyEntitiesExist(MapData data, BeforeEntityReadEvent ev) - { - _stopwatch.Restart(); - var fail = false; - var reportedError = new HashSet(); - var key = data.Version >= 4 ? "proto" : "type"; - var entities = data.RootMappingNode.Get("entities"); - - foreach (var metaDef in entities.Cast()) - { - if (!metaDef.TryGet(key, out var typeNode)) - continue; - - var type = typeNode.Value; - if (string.IsNullOrWhiteSpace(type)) - continue; - - if (ev.RenamedPrototypes.TryGetValue(type, out var newType)) - type = newType; - - if (_prototypeManager.HasIndex(type)) - continue; - - if (!reportedError.Add(type)) - continue; - - if (ev.DeletedPrototypes.Contains(type)) - { - _logLoader.Warning("Map contains an obsolete/removed prototype: {0}. This may cause unexpected errors.", type); - continue; - } - - _logLoader.Error("Missing prototype for map: {0}", type); - fail = true; - reportedError.Add(type); - } - - _logLoader.Debug($"Verified entities in {_stopwatch.Elapsed}"); - - if (fail) - { - _logLoader.Error("Found missing prototypes in map file. Missing prototypes have been dumped to logs."); - return false; - } - - return true; - } - - private bool ReadMetaSection(MapData data) - { - var meta = data.RootMappingNode.Get("meta"); - var ver = meta.Get("format").AsInt(); - if (ver < BackwardsVersion) - { - _logLoader.Error($"Cannot handle this map file version, found {ver} and require {MapFormatVersion}"); - return false; - } - - data.Version = ver; - - if (meta.TryGet("postmapinit", out var mapInitNode)) - { - data.MapIsPostInit = mapInitNode.AsBool(); - } - else - { - data.MapIsPostInit = true; - } - - return true; - } - - private void ReadTileMapSection(MapData data) - { - _stopwatch.Restart(); - - // Load tile mapping so that we can map the stored tile IDs into the ones actually used at runtime. - var tileMap = data.RootMappingNode.Get("tilemap"); - _context.TileMap = new Dictionary(tileMap.Count); - - foreach (var (key, value) in tileMap.Children) - { - var tileId = ((ValueDataNode)key).AsInt(); - var tileDefName = ((ValueDataNode)value).Value; - _context.TileMap.Add(tileId, tileDefName); - } - - _logLoader.Debug($"Read tilemap in {_stopwatch.Elapsed}"); - } - - private HashSet AllocEntities(MapData data, BeforeEntityReadEvent ev) - { - _stopwatch.Restart(); - var mapUid = _mapSystem.GetMapOrInvalid(data.TargetMap); - var pauseTime = mapUid.IsValid() ? _meta.GetPauseTime(mapUid) : TimeSpan.Zero; - _context.Set(data.UidEntityMap, new Dictionary(), data.MapIsPostInit, pauseTime, null); - HashSet deletedPrototypeUids = new(); - - if (data.Version >= 4) - { - var metaEntities = data.RootMappingNode.Get("entities"); - - foreach (var metaDef in metaEntities.Cast()) - { - string? type = null; - var deletedPrototype = false; - if (metaDef.TryGet("proto", out var typeNode) - && !string.IsNullOrWhiteSpace(typeNode.Value)) - { - - if (ev.DeletedPrototypes.Contains(typeNode.Value)) - deletedPrototype = true; - else if (ev.RenamedPrototypes.TryGetValue(typeNode.Value, out var newType)) - type = newType; - else - type = typeNode.Value; - } - - var entities = (SequenceDataNode) metaDef["entities"]; - EntityPrototype? proto = null; - - if (type != null) - _prototypeManager.TryIndex(type, out proto); - - foreach (var entityDef in entities.Cast()) - { - var uid = entityDef.Get("uid").AsInt(); - - var entity = _serverEntityManager.AllocEntity(proto); - data.Entities.Add(entity); - data.UidEntityMap.Add(uid, entity); - data.EntitiesToDeserialize.Add(entity, entityDef); - - if (deletedPrototype) - { - deletedPrototypeUids.Add(entity); - } - else if (data.Options.StoreMapUids) - { - var comp = _serverEntityManager.AddComponent(entity); - comp.Uid = uid; - } - } - } - } - else - { - var entities = data.RootMappingNode.Get("entities"); - - foreach (var entityDef in entities.Cast()) - { - EntityUid entity; - if (entityDef.TryGet("type", out var typeNode)) - { - if (ev.DeletedPrototypes.Contains(typeNode.Value)) - { - entity = _serverEntityManager.AllocEntity(null); - deletedPrototypeUids.Add(entity); - } - else if (ev.RenamedPrototypes.TryGetValue(typeNode.Value, out var newType)) - { - _prototypeManager.TryIndex(newType, out var prototype); - entity = _serverEntityManager.AllocEntity(prototype); - } - else - { - _prototypeManager.TryIndex(typeNode.Value, out var prototype); - entity = _serverEntityManager.AllocEntity(prototype); - } - } - else - { - entity = _serverEntityManager.AllocEntity(null); - } - - var uid = entityDef.Get("uid").AsInt(); - data.Entities.Add(entity); - data.UidEntityMap.Add(uid, entity); - data.EntitiesToDeserialize.Add(entity, entityDef); - - if (data.Options.StoreMapUids) - { - var comp = _serverEntityManager.AddComponent(entity); - comp.Uid = uid; - } - } - } - - _logLoader.Debug($"Allocated {data.Entities.Count} entities in {_stopwatch.Elapsed}"); - return deletedPrototypeUids; - } - - private void LoadEntities(MapData mapData) - { - _stopwatch.Restart(); - var metaQuery = GetEntityQuery(); - - foreach (var (entity, data) in mapData.EntitiesToDeserialize) - { - LoadEntity(entity, data, metaQuery.GetComponent(entity)); - } - - _logLoader.Debug($"Loaded {mapData.Entities.Count} entities in {_stopwatch.Elapsed}"); - } - - private void LoadEntity(EntityUid uid, MappingDataNode data, MetaDataComponent meta) - { - _context.CurrentReadingEntityComponents.Clear(); - _context.CurrentlyIgnoredComponents.Clear(); - - if (data.TryGet("components", out SequenceDataNode? componentList)) - { - var prototype = meta.EntityPrototype; - _context.CurrentReadingEntityComponents.EnsureCapacity(componentList.Count); - foreach (var compData in componentList.Cast()) - { - var datanode = compData.Copy(); - datanode.Remove("type"); - var value = ((ValueDataNode)compData["type"]).Value; - if (!_factory.TryGetRegistration(value, out var reg)) - { - if (!_factory.IsIgnored(value)) - _logLoader.Error($"Encountered unregistered component ({value}) while loading entity {ToPrettyString(uid)}"); - continue; - } - - var compType = reg.Type; - if (prototype?.Components != null && prototype.Components.TryGetValue(value, out var protData)) - { - datanode = - _serManager.PushCompositionWithGenericNode( - compType, - new[] { protData.Mapping }, datanode, _context); - } - - _context.CurrentComponent = value; - _context.CurrentReadingEntityComponents[value] = (IComponent) _serManager.Read(compType, datanode, _context)!; - _context.CurrentComponent = null; - } - } - - if (data.TryGet("missingComponents", out SequenceDataNode? missingComponentList)) - _context.CurrentlyIgnoredComponents = missingComponentList.Cast().Select(x => x.Value).ToHashSet(); - - _serverEntityManager.FinishEntityLoad(uid, meta.EntityPrototype, _context); - if (_context.CurrentlyIgnoredComponents.Count > 0) - meta.LastComponentRemoved = _timing.CurTick; - } - - private void SaveGridTileMap(MapData mapData) - { - DebugTools.Assert(_context.TileMap != null); - - foreach (var entity in mapData.EntitiesToDeserialize.Keys) - { - if (HasComp(entity)) - { - EnsureComp(entity).TileMap = _context.TileMap; - } - } - } - - private void BuildEntityHierarchy(MapData mapData) - { - _stopwatch.Restart(); - var hierarchy = mapData.Hierarchy; - var xformQuery = GetEntityQuery(); - - foreach (var ent in mapData.Entities) - { - if (xformQuery.TryGetComponent(ent, out var xform)) - { - hierarchy[ent] = xform.ParentUid; - } - else - { - hierarchy[ent] = EntityUid.Invalid; - } - } - - // mapData.Entities = new List(mapData.Entities.Count); - var added = new HashSet(mapData.Entities.Count); - mapData.Entities.Clear(); - - while (hierarchy.Count > 0) - { - var enumerator = hierarchy.GetEnumerator(); - enumerator.MoveNext(); - var (current, parent) = enumerator.Current; - BuildTopology(hierarchy, added, mapData.Entities, current, parent); - enumerator.Dispose(); - } - - _logLoader.Debug($"Built entity hierarchy for {mapData.Entities.Count} entities in {_stopwatch.Elapsed}"); - } - - private void BuildTopology(Dictionary hierarchy, HashSet added, List Entities, EntityUid current, EntityUid parent) - { - // If we've already added it then skip. - if (!added.Add(current)) - return; - - // Ensure parent is done first. - if (hierarchy.TryGetValue(parent, out var parentValue)) - { - BuildTopology(hierarchy, added, Entities, parent, parentValue); - } - - DebugTools.Assert(current.IsValid()); - // DebugTools.Assert(!Entities.Contains(current)); - Entities.Add(current); - hierarchy.Remove(current); - } - - private void SwapRootNode(MapData data) - { - _stopwatch.Restart(); - - // There's 4 scenarios - // 1. We're loading a map file onto an existing map. Dump the map file's map and use the existing one - // 2. We're loading a map file onto an existing map. Use the map file's map and swap entities to it. - // 3. We're loading a map file onto a new map. Use CreateMap (for now) and swap out the uid to the correct one - // 4. We're loading a non-map file; in this case it depends whether the map exists or not, then proceed with the above. - - var rootNode = data.Entities[0]; - var xformQuery = GetEntityQuery(); - // We just need to cache the old mapuid and point to the new mapuid. - - if (TryComp(rootNode, out MapComponent? mapComp)) - { - // If map exists swap out - if (_mapSystem.TryGetMap(data.TargetMap, out var existing)) - { - data.Options.DoMapInit |= _mapSystem.IsInitialized(data.TargetMap); - data.MapIsPaused = _mapSystem.IsPaused(existing.Value); - // Map exists but we also have a map file with stuff on it soooo swap out the old map. - if (data.Options.LoadMap) - { - _logLoader.Info($"Loading map file with a root node onto an existing map!"); - - // Smelly - if (HasComp(rootNode)) - { - data.Options.Offset = Vector2.Zero; - data.Options.Rotation = Angle.Zero; - } - - Del(existing); - EnsureComp(rootNode); - - mapComp.MapId = data.TargetMap; - DebugTools.Assert(mapComp.LifeStage < ComponentLifeStage.Initializing); - } - // Otherwise just ignore the map in the file. - else - { - var oldRootUid = data.Entities[0]; - data.Entities[0] = existing.Value; - - foreach (var ent in data.Entities) - { - if (ent == existing) - continue; - - var xform = xformQuery.GetComponent(ent); - - if (!xform.ParentUid.IsValid() || xform.ParentUid.Equals(oldRootUid)) - { - _transform.SetParent(ent, xform, existing.Value); - } - } - - Del(oldRootUid); - } - } - else - { - data.MapIsPaused = !data.MapIsPostInit; - mapComp.MapId = data.TargetMap; - DebugTools.Assert(mapComp.LifeStage < ComponentLifeStage.Initializing); - EnsureComp(rootNode); - - // Nothing should have invalid uid except for the root node. - } - } - else - { - // No map file root, in that case create a new map / get the one we're loading onto. - if (!_mapSystem.TryGetMap(data.TargetMap, out var mapNode)) - { - // Map doesn't exist so we'll start it up now so we can re-attach the preinit entities to it for later. - mapNode = _mapSystem.CreateMap(data.TargetMap, false); - } - - data.Options.DoMapInit |= _mapSystem.IsInitialized(data.TargetMap); - data.MapIsPaused = _mapSystem.IsPaused(mapNode.Value); - - // If anything has an invalid parent (e.g. it's some form of root node) then parent it to the map. - foreach (var ent in data.Entities) - { - // If it's the map itself don't reparent. - if (ent.Equals(mapNode)) - continue; - - var xform = xformQuery.GetComponent(ent); - - if (!xform.ParentUid.IsValid()) - { - _transform.SetParent(ent, xform, mapNode.Value); - } - } - } - - _logLoader.Debug($"Swapped out root node in {_stopwatch.Elapsed}"); - } - - private void ReadGrids(MapData data) - { - // TODO: Kill this when we get map format v3 and remove grid-specific yml area. - - // MapGrids already contain their assigned GridId from their ctor, and the MapComponents just got deserialized. - // Now we need to actually bind the MapGrids to their components so that you can resolve GridId -> EntityUid - // After doing this, it should be 100% safe to use the MapManager API like normal. - - if (data.Version != BackwardsVersion) - return; - - var yamlGrids = data.RootMappingNode.Get("grids"); - - // There were no new grids, nothing to do here. - if (yamlGrids.Count == 0) - return; - - // get ents that the grids will bind to - var gridComps = new Entity[yamlGrids.Count]; - var gridQuery = _serverEntityManager.GetEntityQuery(); - - // linear search for new grid comps - foreach (var uid in data.EntitiesToDeserialize.Keys) - { - if (!gridQuery.TryGetComponent(uid, out var gridComp)) - continue; - - // These should actually be new, pre-init - DebugTools.Assert(gridComp.LifeStage == ComponentLifeStage.Added); - - gridComps[gridComp.GridIndex] = new Entity(uid, gridComp); - } - - for (var index = 0; index < yamlGrids.Count; index++) - { - // Here is where the implicit index pairing magic happens from the yaml. - var yamlGrid = (MappingDataNode)yamlGrids[index]; - - // designed to throw if something is broken, every grid must map to an ent - var gridComp = gridComps[index]; - - // TODO Once maps have been updated (save+load), remove GridComponent.GridIndex altogether and replace it with: - // var savedUid = ((ValueDataNode)yamlGrid["uid"]).Value; - // var gridUid = UidEntityMap[int.Parse(savedUid)]; - // var gridComp = gridQuery.GetComponent(gridUid); - - MappingDataNode yamlGridInfo = (MappingDataNode)yamlGrid["settings"]; - SequenceDataNode yamlGridChunks = (SequenceDataNode)yamlGrid["chunks"]; - - AllocateMapGrid(gridComp, yamlGridInfo); - var gridUid = gridComp.Owner; - - foreach (var chunkNode in yamlGridChunks.Cast()) - { - var (chunkOffsetX, chunkOffsetY) = _serManager.Read(chunkNode["ind"]); - _serManager.Read(chunkNode, _context, instanceProvider: () => _mapSystem.GetOrAddChunk(gridUid, gridComp, chunkOffsetX, chunkOffsetY), notNullableOverride: true); - } - } - } - - private static void AllocateMapGrid(MapGridComponent gridComp, MappingDataNode yamlGridInfo) - { - // sane defaults - ushort csz = 16; - ushort tsz = 1; - - foreach (var kvInfo in yamlGridInfo) - { - var key = ((ValueDataNode)kvInfo.Key).Value; - var val = ((ValueDataNode)kvInfo.Value).Value; - if (key == "chunksize") - csz = ushort.Parse(val); - else if (key == "tilesize") - tsz = ushort.Parse(val); - else if (key == "snapsize") - continue; // obsolete - } - - gridComp.ChunkSize = csz; - gridComp.TileSize = tsz; - } - - private void StartupEntities(MapData data) - { - _stopwatch.Restart(); - var metaQuery = GetEntityQuery(); - var rootEntity = data.Entities[0]; - var mapQuery = GetEntityQuery(); - var xformQuery = GetEntityQuery(); - - // If the root node is a map that's already existing don't bother with it. - // If we're loading a grid then the map is already started up elsewhere in which case this - // just loads the grid outside of the loop which is also fine. - if (MetaData(rootEntity).EntityLifeStage < EntityLifeStage.Initialized) - { - StartupEntity(rootEntity, metaQuery.GetComponent(rootEntity), data); - - if (xformQuery.TryGetComponent(rootEntity, out var xform) && IsRoot(xform, mapQuery) && !HasComp(rootEntity)) - { - _transform.SetLocalPosition(xform, Vector2.Transform(xform.LocalPosition, data.Options.TransformMatrix)); - xform.LocalRotation += data.Options.Rotation; - } - } - - for (var i = 1; i < data.Entities.Count; i++) - { - var entity = data.Entities[i]; - - if (xformQuery.TryGetComponent(entity, out var xform) && IsRoot(xform, mapQuery)) - { - // Don't want to trigger events - xform._localPosition = Vector2.Transform(xform.LocalPosition, data.Options.TransformMatrix); - if (!xform.NoLocalRotation) - xform._localRotation += data.Options.Rotation; - - DebugTools.Assert(!xform.NoLocalRotation || xform.LocalRotation == 0); - } - - StartupEntity(entity, metaQuery.GetComponent(entity), data); - } - - _logLoader.Debug($"Started up {data.Entities.Count} entities in {_stopwatch.Elapsed}"); - } - - private bool IsRoot(TransformComponent xform, EntityQuery mapQuery) - { - return !xform.ParentUid.IsValid() || mapQuery.HasComponent(xform.ParentUid); - } - - private void StartupEntity(EntityUid uid, MetaDataComponent metadata, MapData data) - { - ResetNetTicks(uid, metadata, data.EntitiesToDeserialize[uid]); - - var isPaused = data is { MapIsPaused: true, MapIsPostInit: false }; - _meta.SetEntityPaused(uid, isPaused, metadata); - - // TODO: Apply map transforms if root node. - _serverEntityManager.FinishEntityInitialization(uid, metadata); - _serverEntityManager.FinishEntityStartup(uid); - - if (data.MapIsPostInit) - { - EntityManager.SetLifeStage(metadata, EntityLifeStage.MapInitialized); - } - else if (data.Options.DoMapInit) - { - _serverEntityManager.RunMapInit(uid, metadata); - } - } - - private void ResetNetTicks(EntityUid entity, MetaDataComponent metadata, MappingDataNode data) - { - if (!data.TryGet("components", out SequenceDataNode? componentList)) - { - return; - } - - if (metadata.EntityPrototype is not {} prototype) - { - return; - } - - foreach (var component in metadata.NetComponents.Values) - { - var compName = _factory.GetComponentName(component.GetType()); - - if (componentList.Cast().Any(p => ((ValueDataNode)p["type"]).Value == compName)) - { - if (prototype.Components.ContainsKey(compName)) - { - // This component is modified by the map so we have to send state. - // Though it's still in the prototype itself so creation doesn't need to be sent. - component.ClearCreationTick(); - } - - continue; - } - - // This component is not modified by the map file, - // so the client will have the same data after instantiating it from prototype ID. - component.ClearTicks(); - } - } - - #endregion - - #region Saving - - public MappingDataNode GetSaveData(EntityUid uid) - { - var ev = new BeforeSaveEvent(uid, Transform(uid).MapUid); - RaiseLocalEvent(ev); - - var data = new MappingDataNode(); - WriteMetaSection(data, uid); - - var entityUidMap = new Dictionary(); - var uidEntityMap = new Dictionary(); - var entities = new List(); - - _stopwatch.Restart(); - PopulateEntityList(uid, entities, uidEntityMap, entityUidMap); - WriteTileMapSection(data, entities); - - _logLoader.Debug($"Populated entity list in {_stopwatch.Elapsed}"); - var metadata = Comp(uid); - var pauseTime = _meta.GetPauseTime(uid, metadata); - - // TODO replace MapPreInit with the map's entity lifestage - // Yes, post-init maps do not have EntityLifeStage >= EntityLifeStage.MapInitialized - bool postInit; - if (TryComp(uid, out MapComponent? mapComp)) - postInit = mapComp.MapInitialized; - else - postInit = metadata.EntityLifeStage >= EntityLifeStage.MapInitialized; - - var rootXform = _serverEntityManager.GetComponent(uid); - _context.Set(uidEntityMap, entityUidMap, postInit, pauseTime, rootXform.ParentUid); - - _stopwatch.Restart(); - WriteEntitySection(data, uidEntityMap, entityUidMap); - _logLoader.Debug($"Wrote entity section for {entities.Count} entities in {_stopwatch.Elapsed}"); - _context.Clear(); - - return data; - } - - private void WriteMetaSection(MappingDataNode rootNode, EntityUid uid) - { - var meta = new MappingDataNode(); - rootNode.Add("meta", meta); - meta.Add("format", MapFormatVersion.ToString(CultureInfo.InvariantCulture)); - - var xform = Transform(uid); - var isPostInit = _mapManager.IsMapInitialized(xform.MapID); - - meta.Add("postmapinit", isPostInit ? "true" : "false"); - } - - private void WriteTileMapSection(MappingDataNode rootNode, List entities) - { - // Although we could use tiledefmanager it might write tiledata we don't need so we'll compress it - var gridQuery = GetEntityQuery(); - var tileDefs = new HashSet(); - - Dictionary? origTileMap = null; - foreach (var ent in entities) - { - if (!gridQuery.TryGetComponent(ent, out var grid)) - continue; - - var tileEnumerator = _mapSystem.GetAllTilesEnumerator(ent, grid, ignoreEmpty: false); - while (tileEnumerator.MoveNext(out var tileRef)) - { - tileDefs.Add(tileRef.Value.Tile.TypeId); - } - - if (TryComp(ent, out MapSaveTileMapComponent? saveTileMap)) - origTileMap ??= saveTileMap.TileMap; - } - - Dictionary tileIdMap; - if (origTileMap != null) - { - tileIdMap = new Dictionary(); - - // We are re-saving a map, so we have an original tile map we can preserve. - foreach (var (origId, prototypeId) in origTileMap) - { - // Skip removed tile definitions. - if (!_tileDefManager.TryGetDefinition(prototypeId, out var definition)) - continue; - - tileIdMap.Add(definition.TileId, origId); - } - - // Assign new IDs for all new tile types. - var nextId = 0; - foreach (var tileId in tileDefs) - { - if (tileIdMap.ContainsKey(tileId)) - continue; - - // New tile, assign new ID that isn't taken by original tile map. - while (origTileMap.ContainsKey(nextId)) - { - nextId += 1; - } - - tileIdMap.Add(tileId, nextId); - nextId += 1; - } - } - else - { - // Make no-op tile ID map. - tileIdMap = tileDefs.ToDictionary(x => x, x => x); - } - - DebugTools.Assert( - tileIdMap.Count == tileIdMap.Values.Distinct().Count(), - "Tile ID map has double mapped values??"); - - _context.TileWriteMap = tileIdMap; - - var tileMap = new MappingDataNode(); - rootNode.Add("tilemap", tileMap); - - foreach (var (nativeId, mapId) in tileIdMap.OrderBy(x => x.Key)) - { - tileMap.Add( - mapId.ToString(CultureInfo.InvariantCulture), - _tileDefManager[nativeId].ID); - } - } - - private void PopulateEntityList(EntityUid uid, List entities, Dictionary uidEntityMap, Dictionary entityUidMap) - { - var withoutUid = new HashSet(); - var saveCompQuery = GetEntityQuery(); - var transformCompQuery = GetEntityQuery(); - var metaCompQuery = GetEntityQuery(); - - RecursivePopulate(uid, entities, uidEntityMap, withoutUid, metaCompQuery, transformCompQuery, saveCompQuery); - - var uidCounter = 1; - foreach (var entity in withoutUid) - { - while (uidEntityMap.ContainsKey(uidCounter)) - { - // Find next available UID. - uidCounter += 1; - } - - uidEntityMap.Add(uidCounter, entity); - uidCounter += 1; - } - - // Build a reverse lookup - entityUidMap.EnsureCapacity(uidEntityMap.Count); - foreach(var (saveId, mapId) in uidEntityMap) - { - entityUidMap.Add(mapId, saveId); - } - } - - private bool IsSaveable(EntityUid uid) - { - // Don't serialize things parented to un savable things. - // For example clothes inside a person. - while (uid.IsValid()) - { - var meta = MetaData(uid); - - if (meta.EntityDeleted || meta.EntityPrototype?.MapSavable == false) break; - - uid = Transform(uid).ParentUid; - } - - // If we manage to get up to the map (root node) then it's saveable. - return !uid.IsValid(); - } - - private void RecursivePopulate(EntityUid uid, - List entities, - Dictionary uidEntityMap, - HashSet withoutUid, - EntityQuery metaQuery, - EntityQuery transformQuery, - EntityQuery saveCompQuery) - { - if (!IsSaveable(uid)) - return; - - entities.Add(uid); - - // TODO: Given there's some structure to this now we can probably omit the parent / child a bit. - if (!saveCompQuery.TryGetComponent(uid, out var mapSaveComp) - || mapSaveComp.Uid == 0 - || !uidEntityMap.TryAdd(mapSaveComp.Uid, uid)) - { - // If the id was already saved before, or has no save component we need to find a new id for this entity - withoutUid.Add(uid); - } - - var xform = transformQuery.GetComponent(uid); - foreach (var child in xform._children) - { - RecursivePopulate(child, entities, uidEntityMap, withoutUid, metaQuery, transformQuery, saveCompQuery); - } - } - - private void WriteEntitySection(MappingDataNode rootNode, Dictionary uidEntityMap, Dictionary entityUidMap) - { - var metaQuery = GetEntityQuery(); - var metaName = _factory.GetComponentName(typeof(MetaDataComponent)); - var xformName = _factory.GetComponentName(typeof(TransformComponent)); - - // As metadata isn't on components we'll special-case it. - var prototypeCompCache = new Dictionary>(); - - var emptyMetaNode = _serManager.WriteValueAs(typeof(MetaDataComponent), new MetaDataComponent(), alwaysWrite: true, context: _context); - - _context.CurrentComponent = _factory.GetComponentName(typeof(TransformComponent)); - var emptyXformNode = _serManager.WriteValueAs(typeof(TransformComponent), new TransformComponent(), alwaysWrite: true, context: _context); - _context.CurrentComponent = null; - - var prototypes = new Dictionary>(); - - foreach (var (entityUid, saveId) in entityUidMap) - { - var meta = metaQuery.GetComponent(entityUid); - - if (!_context.MapInitialized && meta.EntityLifeStage >= EntityLifeStage.MapInitialized) - _logWriter.Error($"Encountered a post-init entity in a pre-init map. Entity: {ToPrettyString(entityUid)}"); - - var id = meta.EntityPrototype?.ID; - id ??= string.Empty; - var uids = prototypes.GetOrNew(id); - uids.Add(saveId); - } - - var protos = prototypes.Keys.ToList(); - protos.Sort(); - var entityPrototypes = new SequenceDataNode(); - rootNode.Add("entities", entityPrototypes); - - foreach (var proto in protos) - { - var saveIds = prototypes[proto]; - saveIds.Sort(); - var entities = new SequenceDataNode(); - - var node = new MappingDataNode() - { - { "proto", proto }, - { "entities", entities}, - }; - - entityPrototypes.Add(node); - - foreach (var saveId in saveIds) - { - var entityUid = uidEntityMap[saveId]; - - _context.CurrentWritingEntity = entityUid; - var mapping = new MappingDataNode - { - {"uid", saveId.ToString(CultureInfo.InvariantCulture)} - }; - - var md = metaQuery.GetComponent(entityUid); - - Dictionary? cache = null; - - if (md.EntityPrototype is {} prototype) - { - if (!prototypeCompCache.TryGetValue(prototype.ID, out cache)) - { - prototypeCompCache[prototype.ID] = cache = new Dictionary(prototype.Components.Count); - _context.WritingReadingPrototypes = true; - - foreach (var (compType, comp) in prototype.Components) - { - _context.CurrentComponent = compType; - cache.Add(compType, _serManager.WriteValueAs(comp.Component.GetType(), comp.Component, alwaysWrite: true, context: _context)); - } - - _context.CurrentComponent = null; - _context.WritingReadingPrototypes = false; - cache.TryAdd(metaName, emptyMetaNode); - cache.TryAdd(xformName, emptyXformNode); - } - } - - var components = new SequenceDataNode(); - - var xform = Transform(entityUid); - if (xform.NoLocalRotation && xform.LocalRotation != 0) - { - Log.Error($"Encountered a no-rotation entity with non-zero local rotation: {ToPrettyString(entityUid)}"); - xform._localRotation = 0; - } - - foreach (var component in EntityManager.GetComponents(entityUid)) - { - var compType = component.GetType(); - var registration = _factory.GetRegistration(compType); - if (registration.Unsaved) - continue; - - var compName = registration.Name; - _context.CurrentComponent = compName; - MappingDataNode? compMapping; - MappingDataNode? protMapping = null; - if (cache != null && cache.TryGetValue(compName, out protMapping)) - { - // 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 = _serManager.WriteValueAs(compType, component, alwaysWrite: true, - context: _context); - - // 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(protMapping); - if(compMapping == null) - continue; - } - else - { - compMapping = _serManager.WriteValueAs(compType, component, alwaysWrite: false, - context: _context); - } - - // 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 || protMapping == null) - { - compMapping.InsertAt(0, "type", new ValueDataNode(compName)); - // Something actually got written! - components.Add(compMapping); - } - } - - if (components.Count != 0) - { - mapping.Add("components", components); - } - - if (md.EntityPrototype == null) - { - // No prototype - we are done. - entities.Add(mapping); - continue; - } - - // an entity may have less components than the original prototype, so we need to check if any are missing. - var missingComponents = new SequenceDataNode(); - foreach (var (name, comp) in md.EntityPrototype.Components) - { - // try comp instead of has-comp as it checks whether the component is supposed to have been - // deleted. - if (_serverEntityManager.TryGetComponent(entityUid, comp.Component.GetType(), out _)) - continue; - - missingComponents.Add(new ValueDataNode(name)); - } - - if (missingComponents.Count != 0) - { - mapping.Add("missingComponents", missingComponents); - } - - entities.Add(mapping); - } - } - } - - #endregion - - /// - /// Does basic pre-deserialization checks on map file load. - /// For example, let's not try to use maps with multiple grids as blueprints, shall we? - /// - private sealed class MapData - { - public MappingDataNode RootMappingNode { get; } - - public readonly MapId TargetMap; - public bool MapIsPostInit; - public bool MapIsPaused; - public readonly MapLoadOptions Options; - public int Version; - - // Loading data - public readonly List Entities = new(); - public readonly Dictionary UidEntityMap = new(); - public readonly Dictionary EntitiesToDeserialize = new(); - - public readonly Dictionary Hierarchy = new(); - - public MapData(MapId mapId, TextReader reader, MapLoadOptions options) - { - var documents = DataNodeParser.ParseYamlStream(reader).ToArray(); - - if (documents.Length < 1) - { - throw new InvalidDataException("Stream has no YAML documents."); - } - - // Kinda wanted to just make this print a warning and pick [0] but screw that. - // What is this, a hug box? - if (documents.Length > 1) - { - throw new InvalidDataException("Stream too many YAML documents. Map files store exactly one."); - } - - RootMappingNode = (MappingDataNode) documents[0].Root!; - Options = options; - TargetMap = mapId; - } - } -} diff --git a/Robust.Server/GameObjects/EntitySystems/MapSystem.cs b/Robust.Server/GameObjects/EntitySystems/MapSystem.cs index 17c0427709b..69feeef996e 100644 --- a/Robust.Server/GameObjects/EntitySystems/MapSystem.cs +++ b/Robust.Server/GameObjects/EntitySystems/MapSystem.cs @@ -21,7 +21,7 @@ public sealed class MapSystem : SharedMapSystem protected override MapId GetNextMapId() { var id = new MapId(++LastMapId); - while (MapManager.MapExists(id)) + while (MapExists(id) || UsedIds.Contains(id)) { id = new MapId(++LastMapId); } diff --git a/Robust.Server/GameObjects/IServerEntityManagerInternal.cs b/Robust.Server/GameObjects/IServerEntityManagerInternal.cs deleted file mode 100644 index 1717eb13050..00000000000 --- a/Robust.Server/GameObjects/IServerEntityManagerInternal.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Robust.Shared.GameObjects; -using Robust.Shared.Prototypes; - -namespace Robust.Server.GameObjects -{ - internal interface IServerEntityManagerInternal : IServerEntityManager - { - // These methods are used by the map loader to do multi-stage entity construction during map load. - // I would recommend you refer to the MapLoader for usage. - - EntityUid AllocEntity(EntityPrototype? prototype); - - void FinishEntityLoad(EntityUid entity, IEntityLoadContext? context = null); - - void FinishEntityLoad(EntityUid entity, EntityPrototype? prototype, IEntityLoadContext? context = null); - - void FinishEntityInitialization(EntityUid entity, MetaDataComponent? meta = null); - - void FinishEntityStartup(EntityUid entity); - } -} diff --git a/Robust.Server/GameObjects/MapSaveIdComponent.cs b/Robust.Server/GameObjects/MapSaveIdComponent.cs deleted file mode 100644 index 790f9047294..00000000000 --- a/Robust.Server/GameObjects/MapSaveIdComponent.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Robust.Shared.GameObjects; - -namespace Robust.Server.GameObjects -{ - /// - /// Metadata component used to keep consistent UIDs inside map files cross saving. - /// - /// - /// This component stores the previous map UID of entities from map load. - /// This can then be used to re-serialize the entity with the same UID for the merge driver to recognize. - /// - [RegisterComponent, UnsavedComponent] - public sealed partial class MapSaveIdComponent : Component - { - public int Uid { get; set; } - } -} diff --git a/Robust.Server/GameObjects/ServerEntityManager.cs b/Robust.Server/GameObjects/ServerEntityManager.cs index ead2ee7e148..98a1553408c 100644 --- a/Robust.Server/GameObjects/ServerEntityManager.cs +++ b/Robust.Server/GameObjects/ServerEntityManager.cs @@ -27,7 +27,7 @@ namespace Robust.Server.GameObjects /// Manager for entities -- controls things like template loading and instantiation /// [UsedImplicitly] // DI Container - public sealed class ServerEntityManager : EntityManager, IServerEntityManagerInternal + public sealed class ServerEntityManager : EntityManager, IServerEntityManager { private static readonly Gauge EntitiesCount = Metrics.CreateGauge( "robust_entities_count", @@ -61,32 +61,6 @@ public override void Startup() _pvs = System(); } - EntityUid IServerEntityManagerInternal.AllocEntity(EntityPrototype? prototype) - { - return AllocEntity(prototype, out _); - } - - void IServerEntityManagerInternal.FinishEntityLoad(EntityUid entity, IEntityLoadContext? context) - { - LoadEntity(entity, context); - } - - void IServerEntityManagerInternal.FinishEntityLoad(EntityUid entity, EntityPrototype? prototype, IEntityLoadContext? context) - { - LoadEntity(entity, context, prototype); - } - - void IServerEntityManagerInternal.FinishEntityInitialization(EntityUid entity, MetaDataComponent? meta) - { - InitializeEntity(entity, meta); - } - - [Obsolete("Use StartEntity")] - void IServerEntityManagerInternal.FinishEntityStartup(EntityUid entity) - { - StartEntity(entity); - } - internal override EntityUid CreateEntity(string? prototypeName, out MetaDataComponent metadata, IEntityLoadContext? context = null) { if (prototypeName == null) diff --git a/Robust.Server/GameStates/PvsOverrideSystem.cs b/Robust.Server/GameStates/PvsOverrideSystem.cs index bef471af085..bc7aefbd8d6 100644 --- a/Robust.Server/GameStates/PvsOverrideSystem.cs +++ b/Robust.Server/GameStates/PvsOverrideSystem.cs @@ -28,7 +28,8 @@ public override void Initialize() base.Initialize(); EntityManager.EntityDeleted += OnDeleted; _player.PlayerStatusChanged += OnPlayerStatusChanged; - SubscribeLocalEvent(OnMapChanged); + SubscribeLocalEvent(OnMapRemoved); + SubscribeLocalEvent(OnMapCreated); SubscribeLocalEvent(OnGridCreated); SubscribeLocalEvent(OnGridRemoved); @@ -259,14 +260,6 @@ public void ClearOverride(NetEntity entity) #region Map/Grid Events - private void OnMapChanged(MapChangedEvent ev) - { - if (ev.Created) - OnMapCreated(ev); - else - OnMapDestroyed(ev); - } - private void OnGridRemoved(GridRemovalEvent ev) { RemoveForceSend(ev.EntityUid); @@ -279,12 +272,12 @@ private void OnGridCreated(GridInitializeEvent ev) AddForceSend(ev.EntityUid); } - private void OnMapDestroyed(MapChangedEvent ev) + private void OnMapRemoved(MapRemovedEvent ev) { RemoveForceSend(ev.Uid); } - private void OnMapCreated(MapChangedEvent ev) + private void OnMapCreated(MapCreatedEvent ev) { // TODO PVS remove this requirement. // I think this just required refactoring client game state logic so it doesn't sending maps/grids to nullspace. diff --git a/Robust.Server/GameStates/PvsSystem.Chunks.cs b/Robust.Server/GameStates/PvsSystem.Chunks.cs index d8be2fdbd23..f6324368427 100644 --- a/Robust.Server/GameStates/PvsSystem.Chunks.cs +++ b/Robust.Server/GameStates/PvsSystem.Chunks.cs @@ -303,11 +303,8 @@ private void OnGridRemoved(GridRemovalEvent ev) RemoveRoot(ev.EntityUid); } - private void OnMapChanged(MapChangedEvent ev) + private void OnMapChanged(MapRemovedEvent ev) { - if (!ev.Destroyed) - return; - RemoveRoot(ev.Uid); } diff --git a/Robust.Server/GameStates/PvsSystem.cs b/Robust.Server/GameStates/PvsSystem.cs index bb63968cd1b..d5477684493 100644 --- a/Robust.Server/GameStates/PvsSystem.cs +++ b/Robust.Server/GameStates/PvsSystem.cs @@ -127,7 +127,7 @@ public override void Initialize() _metaQuery = GetEntityQuery(); _xformQuery = GetEntityQuery(); - SubscribeLocalEvent(OnMapChanged); + SubscribeLocalEvent(OnMapChanged); SubscribeLocalEvent(OnGridRemoved); SubscribeLocalEvent(OnTransformStartup); diff --git a/Robust.Server/Maps/LoadedMapComponent.cs b/Robust.Server/Maps/LoadedMapComponent.cs deleted file mode 100644 index e6e1539410a..00000000000 --- a/Robust.Server/Maps/LoadedMapComponent.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Robust.Shared.GameObjects; - -namespace Robust.Server.Maps; - -/// -/// Added to Maps that were loaded by MapLoaderSystem. If not present then this map was created externally. -/// -[RegisterComponent] -public sealed partial class LoadedMapComponent : Component -{ - -} diff --git a/Robust.Server/Maps/MapLoadOptions.cs b/Robust.Server/Maps/MapLoadOptions.cs deleted file mode 100644 index 59769198004..00000000000 --- a/Robust.Server/Maps/MapLoadOptions.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Numerics; -using JetBrains.Annotations; -using Robust.Shared.Maths; - -namespace Robust.Server.Maps -{ - [PublicAPI] - public sealed class MapLoadOptions - { - /// - /// If true, UID components will be created for loaded entities - /// to maintain consistency upon subsequent savings. - /// - public bool StoreMapUids { get; set; } - - /// - /// Offset to apply to the loaded objects. - /// - public Vector2 Offset - { - get => _offset; - set - { - TransformMatrix = Matrix3Helpers.CreateTransform(value, Rotation); - _offset = value; - } - } - - private Vector2 _offset = Vector2.Zero; - - /// - /// Rotation to apply to the loaded objects as a collective, around 0, 0. - /// - /// Setting this overrides - public Angle Rotation - { - get => _rotation; - set - { - TransformMatrix = Matrix3Helpers.CreateTransform(Offset, value); - _rotation = value; - } - } - - private Angle _rotation = Angle.Zero; - - public Matrix3x2 TransformMatrix { get; set; } = Matrix3x2.Identity; - - /// - /// If there is a map entity serialized should we also load it. - /// - /// - /// This should be set to false if you want to load a map file onto an existing map and do not wish to overwrite the existing entity. - /// - public bool LoadMap { get; set; } = true; - - public bool DoMapInit = false; - } -} diff --git a/Robust.Server/Maps/YamlGridSerializer.cs b/Robust.Server/Maps/YamlGridSerializer.cs deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/Robust.Server/ServerIoC.cs b/Robust.Server/ServerIoC.cs index 7ca0261c6e9..ec224673628 100644 --- a/Robust.Server/ServerIoC.cs +++ b/Robust.Server/ServerIoC.cs @@ -67,7 +67,6 @@ internal static void RegisterIoC(IDependencyCollection deps) deps.Register(); deps.Register(); deps.Register(); - deps.Register(); deps.Register(); deps.Register(); deps.Register(); diff --git a/Robust.Shared/ComponentTrees/ComponentTreeSystem.cs b/Robust.Shared/ComponentTrees/ComponentTreeSystem.cs index 45a418d71a6..182d8dde9a9 100644 --- a/Robust.Shared/ComponentTrees/ComponentTreeSystem.cs +++ b/Robust.Shared/ComponentTrees/ComponentTreeSystem.cs @@ -59,7 +59,7 @@ public override void Initialize() UpdatesAfter.Add(typeof(SharedTransformSystem)); UpdatesAfter.Add(typeof(SharedPhysicsSystem)); - SubscribeLocalEvent(MapManagerOnMapCreated); + SubscribeLocalEvent(MapManagerOnMapCreated); SubscribeLocalEvent(MapManagerOnGridCreated); SubscribeLocalEvent(OnCompStartup); @@ -143,11 +143,8 @@ protected virtual void OnTerminating(EntityUid uid, TTreeComp component, ref Ent RemComp(uid, component); } - private void MapManagerOnMapCreated(MapChangedEvent e) + private void MapManagerOnMapCreated(MapCreatedEvent e) { - if (e.Destroyed || e.Map == MapId.Nullspace) - return; - EnsureComp(e.Uid); } diff --git a/Robust.Shared/Console/Commands/MapCommands.cs b/Robust.Shared/Console/Commands/MapCommands.cs index c6608e53551..9ffd00ee82f 100644 --- a/Robust.Shared/Console/Commands/MapCommands.cs +++ b/Robust.Shared/Console/Commands/MapCommands.cs @@ -138,7 +138,7 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) { var msg = new StringBuilder(); - foreach (var mapId in _map.GetAllMapIds().OrderBy(id => id.Value)) + foreach (var mapId in _mapSystem.GetAllMapIds().OrderBy(id => id.Value)) { if (!_mapSystem.TryGetMap(mapId, out var mapUid)) continue; diff --git a/Robust.Shared/EntitySerialization/Components/LoadedMapComponent.cs b/Robust.Shared/EntitySerialization/Components/LoadedMapComponent.cs new file mode 100644 index 00000000000..602e313609e --- /dev/null +++ b/Robust.Shared/EntitySerialization/Components/LoadedMapComponent.cs @@ -0,0 +1,12 @@ +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; + +namespace Robust.Shared.EntitySerialization.Components; + +/// +/// Added to Maps that were loaded by . If not present then this map was created externally. +/// +[RegisterComponent, UnsavedComponent] +public sealed partial class LoadedMapComponent : Component +{ +} diff --git a/Robust.Server/GameObjects/MapSaveTileMapComponent.cs b/Robust.Shared/EntitySerialization/Components/MapSaveTileMapComponent.cs similarity index 89% rename from Robust.Server/GameObjects/MapSaveTileMapComponent.cs rename to Robust.Shared/EntitySerialization/Components/MapSaveTileMapComponent.cs index bdd4cde887c..96f10d90f4b 100644 --- a/Robust.Server/GameObjects/MapSaveTileMapComponent.cs +++ b/Robust.Shared/EntitySerialization/Components/MapSaveTileMapComponent.cs @@ -1,7 +1,8 @@ using System.Collections.Generic; +using Robust.Shared.EntitySerialization.Systems; using Robust.Shared.GameObjects; -namespace Robust.Server.GameObjects; +namespace Robust.Shared.EntitySerialization.Components; /// /// Used by to track the original tile map from when a map was loaded. diff --git a/Robust.Shared/EntitySerialization/Components/YamlUidComponent.cs b/Robust.Shared/EntitySerialization/Components/YamlUidComponent.cs new file mode 100644 index 00000000000..3ff978b2aa5 --- /dev/null +++ b/Robust.Shared/EntitySerialization/Components/YamlUidComponent.cs @@ -0,0 +1,18 @@ +using Robust.Shared.GameObjects; + +namespace Robust.Shared.EntitySerialization.Components; + +/// +/// This component is optionally added to entities that get loaded from yaml files. It stores the UID that the entity +/// had within the yaml file. This is used when saving the entity back to a yaml file so that it re-uses the same UID. +/// +/// +/// This is primarily intended to reduce the diff sizes when modifying yaml maps. Note that there is no guarantee that +/// the given uid will be used when writing the entity. E.g., if more than one entity have this component with the +/// same uid, only one of those entities will be saved with the requested id. +/// +[RegisterComponent, UnsavedComponent] +public sealed partial class YamlUidComponent : Component +{ + public int Uid { get; set; } +} diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs new file mode 100644 index 00000000000..73f14b1a2dc --- /dev/null +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -0,0 +1,995 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using JetBrains.Annotations; +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; + +/// +/// This class provides methods for deserializing entities from yaml. It provides some more control over +/// serialization than the methods provided by . +/// +internal sealed class EntityDeserializer : ISerializationContext, IEntityLoadContext, + ITypeSerializer +{ + private const int BackwardsVersion = 3; + + public SerializationManager.SerializerProvider SerializerProvider { get; } = new(); + + [Dependency] public readonly EntityManager EntMan = default!; + [Dependency] public readonly IGameTiming Timing = default!; + [Dependency] private readonly ISerializationManager _seriMan = default!; + [Dependency] private readonly IComponentFactory _factory = default!; + [Dependency] private readonly IPrototypeManager _proto = default!; + [Dependency] private readonly SharedMapSystem _map = default!; + [Dependency] private readonly ILogManager _logMan = default!; + + private readonly ISawmill _log; + private Stopwatch _stopwatch = new(); + + public readonly DeserializationOptions Options; + + /// + /// Serialized entity data that is going to be read. + /// + public readonly MappingDataNode Data; + + /// + /// Subset of the file's relevant to each entity. + /// + public readonly Dictionary Entities = new(); + + /// + /// Entity data grouped by their entity prototype id. Any entities without a prototype or with an invalid or + /// deleted prototypes use an empty string. + /// + public readonly Dictionary> Prototypes = new(); + + public readonly record struct EntData(int YamlId, MappingDataNode Node, bool PostInit, bool Paused, bool ToDelete); + + public readonly LoadResult Result = new(); + public readonly Dictionary TileMap = new(); + public readonly Dictionary UidMap = new(); + public readonly Dictionary RenamedPrototypes; + public readonly HashSet DeletedPrototypes; + + /// + /// Entities that need to be flagged as map-initialized. This will not actually run map-init logic, this is for + /// loading entities that have already been map-initialized and just need to be flagged as such. + /// + public readonly HashSet PostMapInit = new(); + public readonly HashSet Paused = new(); + public readonly HashSet ToDelete = new(); + public readonly List SortedEntities = new(); + + public readonly Dictionary CurrentReadingEntityComponents = new(); + public EntData? CurrentReadingEntity; + public HashSet CurrentlyIgnoredComponents = new(); + public string? CurrentComponent; + private readonly EntityQuery _mapQuery; + private readonly EntityQuery _gridQuery; + private readonly EntityQuery _xformQuery; + private readonly EntityQuery _metaQuery; + + public EntityDeserializer( + IDependencyCollection deps, + MappingDataNode data, + DeserializationOptions options, + Dictionary? renamedPrototypes = null, + HashSet? deletedPrototypes = null) + { + deps.InjectDependencies(this); + _log = _logMan.GetSawmill("entity_deserializer"); + _log.Level = LogLevel.Info; + SerializerProvider.RegisterSerializer(this); + Data = data; + Options = options; + RenamedPrototypes = renamedPrototypes ?? new(); + DeletedPrototypes = deletedPrototypes ?? new(); + + _mapQuery = EntMan.GetEntityQuery(); + _gridQuery = EntMan.GetEntityQuery(); + _xformQuery = EntMan.GetEntityQuery(); + _metaQuery = EntMan.GetEntityQuery(); + } + + /// + /// Are we currently iterating prototypes or entities for writing. + /// This is used to suppress some serialization errors/warnings. + /// + public bool WritingReadingPrototypes { get; private set; } + + /// + /// This processes some of the data in , including extracting the metdata, tile-map, + /// validate that all referenced entity prototypes exists, grouping entity data by their prototypes. + /// + /// Returns false if the entity data cannot be processed + public bool ProcessData() + { + ReadMetadata(); + if (Result.Version < BackwardsVersion) + { + _log.Error( + $"Cannot handle this map file version, found v{Result.Version} and require at least v{BackwardsVersion}"); + return false; + } + + if (!ValidatePrototypes()) + return false; + + ReadEntities(); + ReadTileMap(); + return true; + } + + /// + /// Allocate entities, load the per-entity serialized data, and populate the various entity collections. + /// + public void CreateEntities() + { + // Alloc entities, and populate the yaml uid -> EntityUid maps + AllocateEntities(); + + // Load the prototype data onto entities, e.g. transform parents, etc. + LoadEntities(); + + // Read the list of maps, grids, and orphan entities + ReadMapsAndGrids(); + + // grids prior to engine v175 might've been serialized with empty chunks which now throw debug asserts. + RemoveEmptyChunks(); + + // Assign MapSaveTileMapComponent to all read grids. This is used to avoid large file diffs if the tile map changes. + StoreGridTileMap(); + + if (Options.AssignMapids) + AssignMapIds(); + + CheckCategory(); + } + + /// + /// Finish entity startup & initialization, and delete any invalid entities + /// + public void StartEntities() + { + AdoptGrids(); + ValidateMapIds(); + PauseMaps(); + BuildEntityHierarchy(); + ProcessNullspaceEntities(); + StartEntitiesInternal(); + SetMapInitLifestage(); + SetPaused(); + MapInitializeEntities(); + ProcessDeletions(); + } + + private void ReadMetadata() + { + var meta = Data.Get("meta"); + Result.Version = meta.Get("format").AsInt(); + + if (meta.TryGet("engineVersion", out var engVer)) + Result.EngineVersion = engVer.Value; + + if (meta.TryGet("forkId", out var forkId)) + Result.ForkId = forkId.Value; + + if (meta.TryGet("forkVersion", out var forkVer)) + Result.ForkVersion = forkVer.Value; + + if (meta.TryGet("time", out var timeNode) && DateTime.TryParse(timeNode.Value, out var time)) + Result.Time = time; + + if (meta.TryGet("category", out var catNode) && + Enum.TryParse(catNode.Value, out var res)) + { + Result.Category = res; + } + } + + /// + /// Verify that the entity prototypes referenced in the file are all valid. + /// + private bool ValidatePrototypes() + { + _stopwatch.Restart(); + var fail = false; + var key = Result.Version >= 4 ? "proto" : "type"; + var entities = Data.Get("entities"); + + foreach (var metaDef in entities.Cast()) + { + if (!metaDef.TryGet(key, out var typeNode)) + continue; + + var type = typeNode.Value; + if (string.IsNullOrWhiteSpace(type)) + continue; + + if (RenamedPrototypes.TryGetValue(type, out var newType)) + type = newType; + + if (DeletedPrototypes.Contains(type)) + { + _log.Warning("Map contains an obsolete/removed prototype: {0}. This may cause unexpected errors.", type); + continue; + } + + if (_proto.HasIndex(type)) + continue; + + _log.Error("Missing prototype for map: {0}", type); + fail = true; + } + + _log.Debug($"Verified entities in {_stopwatch.Elapsed}"); + + if (!fail) + return true; + + _log.Error("Found missing prototypes in map file. Missing prototypes have been dumped to logs."); + return false; + } + + /// + /// Read entity section and populate groups. This does not actually create entities, it just + /// groups them by their prototype. + /// + private void ReadEntities() + { + if (Result.Version == 3) + { + ReadEntitiesV3(); + return; + } + + if (Result.Version < 7) + { + // Older versions do not have per-entity mapinit and paused information. + // But otherwise mostly identical + ReadEntitiesFallback(); + return; + } + + // entities are grouped by prototype. + var prototypeGroups = Data.Get("entities"); + foreach (var protoGroup in prototypeGroups.Cast()) + { + EntProtoId? protoId = null; + var deletedPrototype = false; + if (protoGroup.TryGet("proto", out var protoIdNode) + && !string.IsNullOrWhiteSpace(protoIdNode.Value)) + { + if (DeletedPrototypes.Contains(protoIdNode.Value)) + { + deletedPrototype = true; + if (_proto.HasIndex(protoIdNode.Value)) + protoId = protoIdNode.Value; + } + else if (RenamedPrototypes.TryGetValue(protoIdNode.Value, out var newType)) + protoId = newType; + else + protoId = protoIdNode.Value; + } + + var entities = (SequenceDataNode) protoGroup["entities"]; + _proto.TryIndex(protoId, out var proto); + + var protoData = Prototypes.GetOrNew(proto?.ID ?? string.Empty); + foreach (var entityNode in entities.Cast()) + { + var yamlId = entityNode.Get("uid").AsInt(); + var postInit = entityNode.TryGet("mapInit", out var initNode) && initNode.AsBool(); + + // If the paused field does not exist, the default value depends on whether or not the entity has been + // map-initialized. + var paused = entityNode.TryGet("paused", out var pausedNode) + ? pausedNode.AsBool() + : !postInit; + + protoData.Add(new EntData(yamlId, entityNode, postInit, paused, deletedPrototype)); + } + } + } + + private void ReadEntitiesV3() + { + var metadata = Data.Get("meta"); + var preInit = metadata.TryGet("postmapinit", out var mapInitNode) && !mapInitNode.AsBool(); + + var entities = Data.Get("entities"); + foreach (var entityNode in entities.Cast()) + { + var yamlId = entityNode.Get("uid").AsInt(); + EntProtoId? protoId = null; + var toDelete = false; + if (entityNode.TryGet("type", out var protoIdNode)) + { + if (DeletedPrototypes.Contains(protoIdNode.Value)) + { + toDelete = true; + if (_proto.HasIndex(protoIdNode.Value)) + protoId = protoIdNode.Value; + } + else if (RenamedPrototypes.TryGetValue(protoIdNode.Value, out var newType)) + protoId = newType; + else + protoId = protoIdNode.Value; + } + + _proto.TryIndex(protoId, out var proto); + var protoData = Prototypes.GetOrNew(proto?.ID ?? string.Empty); + var entData = new EntData(yamlId, entityNode, PostInit: !preInit, Paused: preInit, toDelete); + protoData.Add(entData); + } + } + + private void ReadEntitiesFallback() + { + var metadata = Data.Get("meta"); + var preInit = metadata.TryGet("postmapinit", out var mapInitNode) && !mapInitNode.AsBool(); + + // entities are grouped by prototype. + var prototypeGroups = Data.Get("entities"); + foreach (var protoGroup in prototypeGroups.Cast()) + { + EntProtoId? protoId = null; + var deletedPrototype = false; + if (protoGroup.TryGet("proto", out var protoIdNode) + && !string.IsNullOrWhiteSpace(protoIdNode.Value)) + { + if (DeletedPrototypes.Contains(protoIdNode.Value)) + { + deletedPrototype = true; + if (_proto.HasIndex(protoIdNode.Value)) + protoId = protoIdNode.Value; + } + else if (RenamedPrototypes.TryGetValue(protoIdNode.Value, out var newType)) + protoId = newType; + else + protoId = protoIdNode.Value; + } + + var entities = (SequenceDataNode) protoGroup["entities"]; + _proto.TryIndex(protoId, out var proto); + + var protoData = Prototypes.GetOrNew(proto?.ID ?? string.Empty); + foreach (var entityNode in entities.Cast()) + { + var yamlId = entityNode.Get("uid").AsInt(); + protoData.Add(new EntData(yamlId, entityNode, PostInit: !preInit, Paused: preInit, ToDelete: deletedPrototype)); + } + } + } + + + private void ReadTileMap() + { + // Load tile mapping so that we can map the stored tile IDs into the ones actually used at runtime. + _stopwatch.Restart(); + var tileMap = Data.Get("tilemap"); + var migrations = new Dictionary(); + foreach (var proto in _proto.EnumeratePrototypes()) + { + migrations.Add(proto.ID, proto.Target); + } + + foreach (var (key, value) in tileMap.Children) + { + var yamlTileId = ((ValueDataNode) key).AsInt(); + var tileName = ((ValueDataNode) value).Value; + if (migrations.TryGetValue(tileName, out var @new)) + tileName = @new; + + TileMap.Add(yamlTileId, tileName); + } + + _log.Debug($"Read tilemap in {_stopwatch.Elapsed}"); + } + + private void AllocateEntities() + { + _stopwatch.Restart(); + + foreach (var (protoId, ents) in Prototypes) + { + var proto = protoId == string.Empty + ? null + : _proto.Index(protoId); + + foreach (var ent in ents) + { + var entity = EntMan.AllocEntity(proto); + Result.Entities.Add(entity); + UidMap.Add(ent.YamlId, entity); + Entities.Add(entity, ent); + + if (ent.PostInit) + PostMapInit.Add(entity); + + if (ent.Paused) + Paused.Add(entity); + + if (ent.ToDelete) + ToDelete.Add(entity); + + if (Options.StoreYamlUids) + EntMan.AddComponent(entity).Uid = ent.YamlId; + } + } + + _log.Debug($"Allocated {Entities.Count} entities in {_stopwatch.Elapsed}"); + } + + private void LoadEntities() + { + _stopwatch.Restart(); + foreach (var (entity, data) in Entities) + { + try + { + CurrentReadingEntity = data; + LoadEntity(entity, _metaQuery.Comp(entity), data.Node); + } + catch (Exception e) + { +#if !EXCEPTION_TOLERANCE + throw; +#endif + ToDelete.Add(entity); + _log.Error($"Encountered error while loading entity. Yaml uid: {data.YamlId}. Loaded loaded entity: {EntMan.ToPrettyString(entity)}. Error:\n{e}."); + } + } + + CurrentReadingEntity = null; + _log.Debug($"Loaded {Entities.Count} entities in {_stopwatch.Elapsed}"); + } + + private void LoadEntity(EntityUid uid, MetaDataComponent meta, MappingDataNode entData) + { + CurrentReadingEntityComponents.Clear(); + CurrentlyIgnoredComponents.Clear(); + + if (entData.TryGet("components", out SequenceDataNode? componentList)) + { + var prototype = meta.EntityPrototype; + CurrentReadingEntityComponents.EnsureCapacity(componentList.Count); + foreach (var compData in componentList.Cast()) + { + var datanode = compData.Copy(); + datanode.Remove("type"); + var value = ((ValueDataNode)compData["type"]).Value; + if (!_factory.TryGetRegistration(value, out var reg)) + { + if (!_factory.IsIgnored(value)) + _log.Error($"Encountered unregistered component ({value}) while loading entity {EntMan.ToPrettyString(uid)}"); + continue; + } + + var compType = reg.Type; + if (prototype?.Components != null && prototype.Components.TryGetValue(value, out var protoData)) + { + datanode = _seriMan.PushCompositionWithGenericNode( + compType, + [protoData.Mapping], + datanode, + this); + } + + CurrentComponent = value; + CurrentReadingEntityComponents[value] = (IComponent) _seriMan.Read(compType, datanode, this)!; + CurrentComponent = null; + } + } + + if (entData.TryGet("missingComponents", out SequenceDataNode? missingComponentList)) + CurrentlyIgnoredComponents = missingComponentList.Cast().Select(x => x.Value).ToHashSet(); + + EntityPrototype.LoadEntity((uid, meta), _factory, EntMan, _seriMan, this); + + if (CurrentlyIgnoredComponents.Count > 0) + meta.LastComponentRemoved = Timing.CurTick; + } + + private void ReadMapsAndGrids() + { + if (Result.Version < 7) + { + ReadMapsAndGridsFallback(); + return; + } + + var maps = Data.Get("maps"); + foreach (var node in maps) + { + var yamlId = ((ValueDataNode) node).AsInt(); + var uid = UidMap[yamlId]; + if (_mapQuery.TryComp(uid, out var map)) + { + Result.Maps.Add((uid, map)); + EntMan.EnsureComponent(uid); + } + else + _log.Error($"Missing map entity: {EntMan.ToPrettyString(uid)}"); + } + + var grids = Data.Get("grids"); + foreach (var node in grids) + { + var yamlId = ((ValueDataNode) node).AsInt(); + var uid = UidMap[yamlId]; + if (_gridQuery.TryComp(uid, out var grid)) + Result.Grids.Add((uid, grid)); + else + _log.Error($"Missing grid entity: {EntMan.ToPrettyString(uid)}"); + } + + var orphans = Data.Get("orphans"); + foreach (var node in orphans) + { + var yamlId = ((ValueDataNode) node).AsInt(); + var uid = UidMap[yamlId]; + + if (EntMan.HasComponent(uid) || _xformQuery.Comp(uid).ParentUid.IsValid()) + _log.Error($"Entity {EntMan.ToPrettyString(uid)} was incorrectly labelled as an orphan?"); + else + Result.Orphans.Add(uid); + } + } + + public void AssignMapIds() + { + foreach (var map in Result.Maps) + { + _map.AssignMapId(map); + } + } + + private void ReadMapsAndGridsFallback() + { + foreach (var uid in Result.Entities) + { + if (_gridQuery.TryComp(uid, out var grid)) + { + Result.Grids.Add((uid, grid)); + if (_xformQuery.Comp(uid).ParentUid == EntityUid.Invalid && !_mapQuery.HasComp(uid)) + Result.Orphans.Add(uid); + } + + if (_mapQuery.TryComp(uid, out var map)) + { + Result.Maps.Add((uid, map)); + EntMan.EnsureComponent(uid); + } + } + } + + private void RemoveEmptyChunks() + { + var gridQuery = EntMan.GetEntityQuery(); + foreach (var uid in Entities.Keys) + { + if (!gridQuery.TryGetComponent(uid, out var gridComp)) + continue; + + foreach (var (index, chunk) in gridComp.Chunks) + { + if (chunk.FilledTiles > 0) + continue; + + _log.Warning( + $"Encountered empty chunk while deserializing map. Grid: {EntMan.ToPrettyString(uid)}. Chunk index: {index}"); + gridComp.Chunks.Remove(index); + } + } + } + + private void StoreGridTileMap() + { + /* + if (TileMap.Count == 0) + return; + */ + + foreach (var entity in Result.Grids) + { + EntMan.EnsureComponent(entity).TileMap = TileMap.ShallowClone(); + } + } + + private void BuildEntityHierarchy() + { + _stopwatch.Restart(); + var processed = new HashSet(Result.Entities.Count); + + foreach (var ent in Result.Entities) + { + BuildEntityHierarchy(ent, processed); + } + + _log.Debug($"Built entity hierarchy for {Result.Entities.Count} entities in {_stopwatch.Elapsed}"); + } + + /// + /// Validate that the category read from the metadata section is correct + /// + private void CheckCategory() + { + if (Result.Version < 7) + { + InferCategory(); + return; + } + + switch (Result.Category) + { + case FileCategory.Map: + if (Result.Maps.Count == 1) + return; + _log.Error($"Expected file to contain a single map, but instead found: {Result.Maps.Count}"); + break; + + case FileCategory.Grid: + if (Result.Maps.Count == 0 && Result.Grids.Count == 1) + return; + _log.Error($"Expected file to contain a single grid, but instead found: {Result.Grids.Count}"); + break; + + case FileCategory.Entity: + if (Result.Maps.Count == 0 && Result.Grids.Count == 0 && Result.Orphans.Count == 1) + return; + _log.Error($"Expected file to contain a orphaned entity, but instead found: {Result.Orphans.Count}"); + break; + + default: + return; + } + + Result.Category = FileCategory.Unknown; + } + + private void InferCategory() + { + if (Result.Category != FileCategory.Unknown) + return; + + if (Result.Maps.Count == 1) + Result.Category = FileCategory.Map; + else if (Result.Maps.Count == 0 && Result.Grids.Count == 1) + Result.Category = FileCategory.Grid; + } + + /// + /// In case there are any "orphaned" grids, we want to ensure that they all have a map before we initialize them, + /// as grids in null-space are not currently supported. + /// + private void AdoptGrids() + { + foreach (var grid in Result.Grids) + { + if (EntMan.HasComponent(grid.Owner)) + continue; + + var xform = _xformQuery.Comp(grid.Owner); + if (xform.ParentUid.IsValid()) + continue; + + DebugTools.Assert(Result.Orphans.Contains(grid.Owner)); + if (Options.LogOrphanedGrids) + _log.Error($"Encountered an orphaned grid. Automatically creating a map for the grid."); + var map = _map.CreateUninitializedMap(); + _map.AssignMapId(map); + + Result.Entities.Add(map); + Result.Maps.Add(map); + Result.Orphans.Remove(grid.Owner); + xform._parent = map.Owner; + DebugTools.Assert(!xform._mapIdInitialized); + } + } + + /// + /// Verify that all map entities have been assigned a map id. + /// + private void ValidateMapIds() + { + foreach (var map in Result.Maps) + { + if (map.Comp.MapId == MapId.Nullspace + || !_map.TryGetMap(map.Comp.MapId, out var e) + || e != map.Owner) + { + throw new Exception($"Map entity {EntMan.ToPrettyString(map)} has not been assigned a map id"); + } + } + } + + private void PauseMaps() + { + if (!Options.PauseMaps) + return; + + foreach (var ent in Result.Maps) + { + _map.SetPaused(ent!, true); + } + } + + private void BuildEntityHierarchy(EntityUid uid, HashSet processed) + { + // If we've already added it then skip. + if (!processed.Add(uid)) + return; + + if (!_xformQuery.TryComp(uid, out var xform)) + return; + + // Ensure parent is done first. + var parent = xform.ParentUid; + if (parent != EntityUid.Invalid) + BuildEntityHierarchy(parent, processed); + + // If entities were moved around or merged onto an existing map, it is possible that the entities passed + // to this method were not originally being deserialized. + if (!Result.Entities.Contains(uid)) + return; + + SortedEntities.Add(uid); + if (parent == EntityUid.Invalid) + Result.RootNodes.Add(uid); + } + + private void ProcessNullspaceEntities() + { + foreach (var uid in Result.RootNodes) + { + if (EntMan.HasComponent(uid)) + { + DebugTools.Assert(Result.Maps.Any(x => x.Owner == uid)); + continue; + } + + if (Result.Orphans.Contains(uid)) + continue; + + Result.NullspaceEntities.Add(uid); + + // Null-space grids are not yet supported. + // So it shouldn't have been possible to save a grid without it being flagged as an orphan. + DebugTools.Assert(!EntMan.HasComponent(uid)); + } + } + + private void StartEntitiesInternal() + { + _stopwatch.Restart(); + var metaQuery = EntMan.GetEntityQuery(); + foreach (var uid in SortedEntities) + { + StartupEntity(uid, metaQuery.GetComponent(uid)); + } + _log.Debug($"Started up {Result.Entities.Count} entities in {_stopwatch.Elapsed}"); + } + + private void StartupEntity(EntityUid uid, MetaDataComponent metadata) + { + ResetNetTicks(uid, metadata); + EntMan.InitializeEntity(uid, metadata); + EntMan.StartEntity(uid); + } + + private void ResetNetTicks(EntityUid uid, MetaDataComponent metadata) + { + if (!Entities.TryGetValue(uid, out var entData)) + { + // AdoptGrids() can create new maps that have no associated yaml data. + return; + } + + if (metadata.EntityPrototype is not { } prototype) + return; + + if (!entData.Node.TryGet("components", out SequenceDataNode? componentList)) + return; + + foreach (var component in metadata.NetComponents.Values) + { + var compName = _factory.GetComponentName(component.GetType()); + + if (componentList.Cast().Any(p => ((ValueDataNode) p["type"]).Value == compName)) + { + if (prototype.Components.ContainsKey(compName)) + { + // This component is modified by the map so we have to send state. + // Though it's still in the prototype itself so creation doesn't need to be sent. + component.ClearCreationTick(); + } + + continue; + } + + // This component is not modified by the map file, + // so the client will have the same data after instantiating it from prototype ID. + component.ClearTicks(); + } + } + + private void SetMapInitLifestage() + { + if (PostMapInit.Count == 0) + return; + + _stopwatch.Restart(); + + foreach (var uid in PostMapInit) + { + if (!_metaQuery.TryComp(uid, out var meta)) + continue; + + DebugTools.Assert(meta.EntityLifeStage == EntityLifeStage.Initialized); + meta.EntityLifeStage = EntityLifeStage.MapInitialized; + } + + _log.Debug($"Finished flagging mapinit in {_stopwatch.Elapsed}"); + } + + private void SetPaused() + { + if (Paused.Count == 0) + return; + + _stopwatch.Restart(); + + var time = Timing.CurTime; + var ev = new EntityPausedEvent(); + + foreach (var uid in Paused) + { + if (!_metaQuery.TryComp(uid, out var meta)) + continue; + + meta.PauseTime = time; + + // TODO ENTITY SERIALIZATION + // TODO PowerNet / NodeNet Serialization + // Make this **not** raise an event. + // Ideally an event shouldn't be required. However some stinky systems rely on it to actually pause entities. + // E.g., power nets don't get serialized properly, and store entity-paused information in a separate object, + // so they needs to receive the event to make sure that the entity is **actually** paused. + // hours of debugging + // AAAAAAAAaaaa + // Whenever node nets become nullspace ents, this can probably just be purged. + EntMan.EventBus.RaiseLocalEvent(uid, ref ev); + } + + _log.Debug($"Finished setting PauseTime in {_stopwatch.Elapsed}"); + } + + + private void MapInitializeEntities() + { + if (!Options.InitializeMaps) + { + foreach (var ent in Result.Maps) + { + if (_metaQuery.Comp(ent.Owner).EntityLifeStage < EntityLifeStage.MapInitialized) + _map.SetPaused(ent!, true); + } + + return; + } + + foreach (var ent in Result.Maps) + { + if (!ent.Comp.MapInitialized) + _map.InitializeMap(ent!, unpause: !Options.PauseMaps); + } + } + + private void ProcessDeletions() + { + foreach (var uid in ToDelete) + { + EntMan.DeleteEntity(uid); + Result.Entities.Remove(uid); + } + } + + // Create custom object serializers that will correctly allow data to be overriden by the map file. + bool IEntityLoadContext.TryGetComponent(string componentName, [NotNullWhen(true)] out IComponent? component) + { + return CurrentReadingEntityComponents.TryGetValue(componentName, out component); + } + + public IEnumerable GetExtraComponentTypes() + { + return CurrentReadingEntityComponents.Keys; + } + + public bool ShouldSkipComponent(string compName) + { + return CurrentlyIgnoredComponents.Contains(compName); + } + + #region ITypeSerializer + + ValidationNode ITypeValidator.Validate( + ISerializationManager serializationManager, + ValueDataNode node, + IDependencyCollection dependencies, + ISerializationContext? context) + { + if (node.Value is "null" or "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) + { + return value.IsValid() + ? new ValueDataNode(value.Id.ToString(CultureInfo.InvariantCulture)) + : new ValueDataNode("invalid"); + } + + EntityUid ITypeReader.Read( + ISerializationManager serializationManager, + ValueDataNode node, + IDependencyCollection dependencies, + SerializationHookContext hookCtx, + ISerializationContext? context, + ISerializationManager.InstantiationDelegate? _) + { + if (node.Value == "invalid" && CurrentComponent == "Transform") + return EntityUid.Invalid; + + if (int.TryParse(node.Value, out var val) && UidMap.TryGetValue(val, out var entity)) + return entity; + + _log.Error($"Invalid yaml entity id: '{val}'"); + return EntityUid.Invalid; + } + + [MustUseReturnValue] + public EntityUid Copy( + ISerializationManager serializationManager, + EntityUid source, + EntityUid target, + bool skipHook, + ISerializationContext? context = null) + { + return new(source.Id); + } + + #endregion +} diff --git a/Robust.Shared/EntitySerialization/EntitySerializer.cs b/Robust.Shared/EntitySerialization/EntitySerializer.cs new file mode 100644 index 00000000000..995b842ee38 --- /dev/null +++ b/Robust.Shared/EntitySerialization/EntitySerializer.cs @@ -0,0 +1,872 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using JetBrains.Annotations; +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.Reflection; +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; + +/// +/// This class provides methods for serializing entities into yaml. It provides some more control over +/// serialization than the methods provided by . +/// +/// +/// There are several methods (e.g., that serialize entities into a +/// per-entity stored in the dictionary, which is indexed by the +/// entity's assigned yaml id (see . The generated data can then be written to a larger yaml +/// document using the various "Write" methods. (e.g., ). After a one has finished using +/// the generated data, the serializer needs to be reset () using it again to serialize other entities. +/// +public sealed class EntitySerializer : ISerializationContext, ITypeSerializer +{ + public const int MapFormatVersion = 7; + + 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!; + + private readonly ISawmill _log; + public readonly Dictionary YamlUidMap = new(); + public readonly HashSet YamlIds = new(); + + + public string? CurrentComponent { get; private set; } + public Entity? CurrentEntity { get; private set; } + public int CurrentEntityYamlUid { get; private set; } + + /// + /// Tile ID -> yaml tile ID mapping. + /// + private readonly Dictionary _tileMap = new(); + private readonly HashSet _yamlTileIds = new(); + + /// + public bool WritingReadingPrototypes { get; private set; } + + /// + /// 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. + /// + public EntityUid Truncate { get; private set; } + + public readonly SerializationOptions Options; + + /// + /// Cached prototype data. This is used to avoid writing redundant data that is already specified in an entity's + /// prototype. + /// + public readonly Dictionary> PrototypeCache = new(); + + /// + /// The serialized entity data. + /// + public readonly Dictionary EntityData = new(); + + /// + /// indices grouped by their entity prototype ids. + /// + public readonly Dictionary> Prototypes = new(); + + /// + /// Yaml ids of all serialized map entities. + /// + public readonly List Maps = new(); + + /// + /// Yaml ids of all serialized grid entities. + /// + public readonly List Grids = new(); + + /// + /// 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. + /// + public readonly List 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 _autoInclude = new(); + private readonly EntityQuery _yamlQuery; + private readonly EntityQuery _gridQuery; + private readonly EntityQuery _mapQuery; + + /// + /// C# event for checking whether an entity is serializable. Can be used by content to prevent specific entities + /// from getting serialized. + /// + public event IsSerializableDelegate? OnIsSerializeable; + public delegate void IsSerializableDelegate(Entity ent, ref bool serializable); + + public EntitySerializer(IDependencyCollection _dependency, SerializationOptions options) + { + _dependency.InjectDependencies(this); + + _log = _logMan.GetSawmill("entity_serializer"); + SerializerProvider.RegisterSerializer(this); + + _metaName = _factory.GetComponentName(typeof(MetaDataComponent)); + _xformName = _factory.GetComponentName(typeof(TransformComponent)); + _emptyMetaNode = _serialization.WriteValueAs(typeof(MetaDataComponent), new MetaDataComponent(), alwaysWrite: true, context: this); + + CurrentComponent = _xformName; + _emptyXformNode = _serialization.WriteValueAs(typeof(TransformComponent), new TransformComponent(), alwaysWrite: true, context: this); + CurrentComponent = null; + + _yamlQuery = EntMan.GetEntityQuery(); + _gridQuery = EntMan.GetEntityQuery(); + _mapQuery = EntMan.GetEntityQuery(); + Options = options; + } + + public bool IsSerializable(Entity 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 + + /// + /// Serialize a single entity. This does not automatically include + /// children, though depending on the setting of it may + /// auto-include additional entities aside from the one provided. + /// + 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(); + } + + /// + /// Serialize a set of entities. This does not automatically include children or parents, though depending on the + /// setting of it may auto-include additional entities + /// aside from the one provided. + /// + public void SerializeEntities(HashSet entities) + { + foreach (var uid in entities) + { + if (!IsSerializable(uid)) + throw new Exception($"{EntMan.ToPrettyString(uid)} is not serializable"); + } + + ReserveYamlIds(entities); + SerializeEntitiesInternal(entities); + } + + /// + /// Serializes an entity and all of its serializable children. Note that this will not automatically serialize the + /// entity's parents. + /// + public void SerializeEntityRecursive(EntityUid root) + { + if (!IsSerializable(root)) + throw new Exception($"{EntMan.ToPrettyString(root)} is not serializable"); + + Truncate = EntMan.GetComponent(root).ParentUid; + InitializeTileMap(root); + HashSet entities = new(); + RecursivelyIncludeChildren(root, entities); + ReserveYamlIds(entities); + SerializeEntitiesInternal(entities); + Truncate = EntityUid.Invalid; + } + + #endregion + + /// + /// Initialize the that is used to serialize grid chunks using + /// . 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. + /// + 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() + foreach (var (origId, prototypeId) in savedMap) + { + if (_tileDef.TryGetDefinition(prototypeId, out var definition)) + _tileMap.TryAdd(definition.TileId, origId); + } + } + + private bool FindSavedTileMap(EntityUid root, [NotNullWhen(true)] out Dictionary? map) + { + // Try and fetch the mapping directly + if (EntMan.TryGetComponent(root, out MapSaveTileMapComponent? comp)) + { + map = comp.TileMap; + return true; + } + + // If the root is a map, iterate over all of its children and grab the first grid with a mapping + var xform = EntMan.GetComponent(root); + foreach (var child in xform._children) + { + if (!EntMan.TryGetComponent(child, out MapSaveTileMapComponent? cComp)) + continue; + map = cComp.TileMap; + return true; + } + + map = null; + return false; + } + + #region AutoInclude + + private void ProcessAutoInclude() + { + DebugTools.AssertEqual(_autoInclude.ToHashSet().Count, _autoInclude.Count); + + var ents = new HashSet(); + + switch (Options.MissingEntityBehaviour) + { + case MissingEntityBehaviour.AutoInclude: + // Include the entity and any of its direct parents + foreach (var uid in _autoInclude) + { + RecursivelyIncludeParents(uid, ents); + } + break; + case MissingEntityBehaviour.IncludeNullspace: + case MissingEntityBehaviour.AutoIncludeChildren: + // Find the root transform of all the included entities + var roots = new HashSet(); + 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 ents) + { + if (!IsSerializable(uid)) + return; + + ents.Add(uid); + var xform = EntMan.GetComponent(uid); + foreach (var child in xform._children) + { + RecursivelyIncludeChildren(child, ents); + } + } + + private void GetRootNode(EntityUid uid, HashSet ents) + { + if (!IsSerializable(uid)) + throw new NotSupportedException($"Attempted to auto-include an unserializable entity: {EntMan.ToPrettyString(uid)}"); + + var xform = EntMan.GetComponent(uid); + while (xform.ParentUid.IsValid() && xform.ParentUid != Truncate) + { + uid = xform.ParentUid; + xform = EntMan.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 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 = EntMan.GetComponent(uid).ParentUid; + } + } + + #endregion + + private void SerializeEntitiesInternal(HashSet entities) + { + foreach (var uid in entities) + { + DebugTools.AssertNull(CurrentEntity); + SerializeEntityInternal(uid); + } + + DebugTools.AssertNull(CurrentEntity); + if (_autoInclude.Count != 0) + ProcessAutoInclude(); + } + + /// + /// Serialize a single entity, and store the results in . + /// + private void SerializeEntityInternal(EntityUid uid) + { + var saveId = GetYamlUid(uid); + DebugTools.Assert(!EntityData.ContainsKey(saveId)); + if (EntityData.ContainsKey(saveId)) + return; + + var meta = EntMan.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); + + if (_mapQuery.HasComp(uid)) + Maps.Add(saveId); + + if (_gridQuery.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(); + var xform = EntMan.GetComponent(uid); + if (xform.NoLocalRotation && xform.LocalRotation != 0) + { + _log.Error($"Encountered a no-rotation entity with non-zero local rotation: {EntMan.ToPrettyString(uid)}"); + xform._localRotation = 0; + } + + 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(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(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) + { + compMapping.InsertAt(0, "type", new ValueDataNode(reg.Name)); + components.Add(compMapping); + } + } + + CurrentComponent = null; + if (components.Count != 0) + entData.Add("components", components); + + // TODO ENTITY SERIALIZATION + // Consider adding a Action? 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 less 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; + } + + private Dictionary? 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(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() + { + return new MappingDataNode + { + {"meta", WriteMetadata()}, + {"maps", WriteIds(Maps)}, + {"grids", WriteIds(Grids)}, + {"orphans", WriteIds(Orphans)}, + {"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 ids) + { + var result = new SequenceDataNode(); + foreach (var id in ids) + { + result.Add(new ValueDataNode(id.ToString(CultureInfo.InvariantCulture))); + } + return result; + } + + /// + /// Serialize the to yaml. This data is required to deserialize any serialized grid chunks using . + /// + public MappingDataNode WriteTileMap() + { + var map = new MappingDataNode(); + foreach (var (tileId, yamlTileId) in _tileMap.OrderBy(x => x.Key)) + { + map.Add(yamlTileId.ToString(CultureInfo.InvariantCulture), _tileDef[tileId].ID); + } + return map; + } + + public SequenceDataNode WriteEntitySection() + { + if (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; + } + + /// + /// Get the category that the serialized data belongs to. If one was specified in the + /// it will use that after validating it, otherwise it will attempt to infer a + /// category. + /// + 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) + return FileCategory.Map; + if (Grids.Count == 1) + return FileCategory.Grid; + if (Orphans.Count == 1) + return FileCategory.Entity; + return FileCategory.Unknown; + } + } + + #endregion + + #region YamlIds + + /// + /// Get (or allocate) the integer id that will be used in the serialized file to refer to the given entity. + /// + public int GetYamlUid(EntityUid uid) + { + return !YamlUidMap.TryGetValue(uid, out var id) ? AllocateYamlUid(uid) : id; + } + + private int AllocateYamlUid(EntityUid uid) + { + DebugTools.Assert(!YamlUidMap.ContainsKey(uid)); + while (!YamlIds.Add(_nextYamlUid)) + { + _nextYamlUid++; + } + + YamlUidMap.Add(uid, _nextYamlUid); + return _nextYamlUid++; + } + + /// + /// Get (or allocate) the integer id that will be used in the serialized file to refer to the given grid tile id. + /// + 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++; + } + + /// + /// This method ensures that the given entities have a yaml ids assigned. If the entities have a + /// , they will attempt to use that id, which exists to prevent large map file diffs + /// due to changing yaml ids. + /// + public void ReserveYamlIds(HashSet entities) + { + List 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)) + YamlUidMap.Add(uid, comp.Uid); + else + needIds.Add(uid); + } + + foreach (var uid in needIds) + { + AllocateYamlUid(uid); + } + } + + /// + /// This method ensures that the given entity has a yaml id assigned to it. If the entity has a + /// , it will attempt to use that id, which exists to prevent large map file diffs due + /// to changing yaml ids. + /// + public void ReserveYamlId(EntityUid uid) + { + if (YamlUidMap.ContainsKey(uid)) + return; + + if (_yamlQuery.TryGetComponent(uid, out var comp) && comp.Uid > 0 && YamlIds.Add(comp.Uid)) + YamlUidMap.Add(uid, comp.Uid); + else + AllocateYamlUid(uid); + } + + #endregion + + #region ITypeSerializer + + ValidationNode ITypeValidator.Validate( + ISerializationManager serializationManager, + ValueDataNode node, + IDependencyCollection dependencies, + ISerializationContext? context) + { + if (node.Value is "null" or "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)) + { + DebugTools.Assert(value != Truncate); + return new ValueDataNode(yamlId.ToString(CultureInfo.InvariantCulture)); + } + + if (CurrentComponent == _xformName) + { + if (value == EntityUid.Invalid) + return new ValueDataNode("invalid"); + + DebugTools.Assert(!Orphans.Contains(CurrentEntityYamlUid)); + Orphans.Add(CurrentEntityYamlUid); + + if (Options.ErrorOnOrphan && CurrentEntity != null && value != Truncate) + _log.Error($"Serializing entity {EntMan.ToPrettyString(CurrentEntity)} without including its parent {EntMan.ToPrettyString(value)}"); + + return new ValueDataNode("invalid"); + } + + if (Truncate != EntityUid.Invalid && value == Truncate) + { + _log.Error( + $"{EntMan.ToPrettyString(CurrentEntity)} is attempting to serialize references to a truncated parent entity {EntMan.ToPrettyString(Truncate)}."); + } + + if (value == EntityUid.Invalid) + { + if (Options.MissingEntityBehaviour != MissingEntityBehaviour.Ignore) + _log.Error($"Encountered an invalid entityUid reference."); + + return new ValueDataNode("invalid"); + } + + 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 new ValueDataNode("invalid"); + case MissingEntityBehaviour.Ignore: + return new ValueDataNode("invalid"); + case MissingEntityBehaviour.IncludeNullspace: + if (!EntMan.TryGetComponent(value, out TransformComponent? xform) + || xform.ParentUid != EntityUid.Invalid + || EntMan.HasComponent(value) + || EntMan.HasComponent(value)) + { + goto case MissingEntityBehaviour.Error; + } + goto case MissingEntityBehaviour.AutoIncludeChildren; + case MissingEntityBehaviour.AutoInclude: + case MissingEntityBehaviour.AutoIncludeChildren: + if (Options.WarnOnAutoInclude) + _log.Warning($"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.Read( + ISerializationManager serializationManager, + ValueDataNode node, + IDependencyCollection dependencies, + SerializationHookContext hookCtx, + ISerializationContext? context, + ISerializationManager.InstantiationDelegate? _) + { + return node.Value == "invalid" ? EntityUid.Invalid : EntityUid.Parse(node.Value); + } + + [MustUseReturnValue] + public EntityUid Copy( + ISerializationManager serializationManager, + EntityUid source, + EntityUid target, + bool skipHook, + ISerializationContext? context = null) + { + return new(source.Id); + } + + #endregion +} diff --git a/Robust.Shared/EntitySerialization/LoadResult.cs b/Robust.Shared/EntitySerialization/LoadResult.cs new file mode 100644 index 00000000000..1996b05c1c4 --- /dev/null +++ b/Robust.Shared/EntitySerialization/LoadResult.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.GameObjects; +using Robust.Shared.Map.Components; + +namespace Robust.Shared.EntitySerialization; + +/// +/// Class containing information about entities that were loaded from a yaml file. +/// +public sealed class LoadResult +{ + /// + /// The file format version. + /// + public int Version; + + /// + /// The category of the file that was loaded in. + /// + public FileCategory Category = FileCategory.Unknown; + + /// + /// The engine version that was used to write the file. See . + /// + public string? EngineVersion; + + /// + /// The fork that was used to write the file. See . + /// + public string? ForkId; + + /// + /// The fork version that was used to write the file. See . + /// + public string? ForkVersion; + + /// + /// The when the file was created. + /// + public DateTime? Time; + + /// + /// Set of all entities that were created while the file was being loaded. + /// + public readonly HashSet Entities = new(); + + /// + /// Set of entities that are not parented to other entities. This will be a combination of , + /// , and . + /// + public readonly List RootNodes = new(); + + public readonly HashSet> Maps = new(); + + public readonly HashSet> Grids = new(); + + /// + /// Deserialized entities that need to be assigned a new parent. These differ from "true" null-space entities. + /// E,g, saving a grid without saving the map would make the grid an "orphan". + /// + public readonly HashSet Orphans = new(); + + /// + /// List of null-space entities. This contains all entities without a parent that don't have a + /// , and were not listed as orphans + /// + public readonly List NullspaceEntities = new(); + + +} diff --git a/Robust.Server/Maps/MapChunkSerializer.cs b/Robust.Shared/EntitySerialization/MapChunkSerializer.cs similarity index 81% rename from Robust.Server/Maps/MapChunkSerializer.cs rename to Robust.Shared/EntitySerialization/MapChunkSerializer.cs index cabf4435c23..abb956a92d9 100644 --- a/Robust.Server/Maps/MapChunkSerializer.cs +++ b/Robust.Shared/EntitySerialization/MapChunkSerializer.cs @@ -14,19 +14,25 @@ using Robust.Shared.Serialization.TypeSerializers.Interfaces; using Robust.Shared.Utility; -namespace Robust.Server.Maps; +namespace Robust.Shared.EntitySerialization; [TypeSerializer] internal sealed class MapChunkSerializer : ITypeSerializer, ITypeCopyCreator { - public ValidationNode Validate(ISerializationManager serializationManager, MappingDataNode node, - IDependencyCollection dependencies, ISerializationContext? context = null) + public ValidationNode Validate( + ISerializationManager serializationManager, + MappingDataNode node, + IDependencyCollection dependencies, + ISerializationContext? context = null) { throw new NotImplementedException(); } public MapChunk Read(ISerializationManager serializationManager, MappingDataNode node, - IDependencyCollection dependencies, SerializationHookContext hookCtx, ISerializationContext? context = null, ISerializationManager.InstantiationDelegate? instantiationDelegate = null) + IDependencyCollection dependencies, + SerializationHookContext hookCtx, + ISerializationContext? context = null, + ISerializationManager.InstantiationDelegate? instantiationDelegate = null) { var ind = (Vector2i) serializationManager.Read(typeof(Vector2i), node["ind"], hookCtx, context)!; var tileNode = (ValueDataNode)node["tiles"]; @@ -50,10 +56,8 @@ public MapChunk Read(ISerializationManager serializationManager, MappingDataNode IReadOnlyDictionary? tileMap = null; - if (context is MapSerializationContext serContext) - { + if (context is EntityDeserializer serContext) tileMap = serContext.TileMap; - } if (tileMap == null) { @@ -104,16 +108,12 @@ public DataNode Write(ISerializationManager serializationManager, MapChunk value root.Add("version", new ValueDataNode("6")); - Dictionary? tileWriteMap = null; - if (context is MapSerializationContext mapContext) - tileWriteMap = mapContext.TileWriteMap; - - gridNode.Value = SerializeTiles(value, tileWriteMap); + gridNode.Value = SerializeTiles(value, context as EntitySerializer); return root; } - private static string SerializeTiles(MapChunk chunk, Dictionary? tileWriteMap) + private static string SerializeTiles(MapChunk chunk, EntitySerializer? serializer) { // number of bytes written per tile, because sizeof(Tile) is useless. const int structSize = 6; @@ -130,8 +130,8 @@ private static string SerializeTiles(MapChunk chunk, Dictionary? tileW { var tile = chunk.GetTile(x, y); var typeId = tile.TypeId; - if (tileWriteMap != null) - typeId = tileWriteMap[typeId]; + if (serializer != null) + typeId = serializer.GetYamlTileId(typeId); writer.Write(typeId); writer.Write((byte)tile.Flags); @@ -143,8 +143,12 @@ private static string SerializeTiles(MapChunk chunk, Dictionary? tileW return Convert.ToBase64String(barr); } - public MapChunk CreateCopy(ISerializationManager serializationManager, MapChunk source, - IDependencyCollection dependencies, SerializationHookContext hookCtx, ISerializationContext? context = null) + public MapChunk CreateCopy( + ISerializationManager serializationManager, + MapChunk source, + IDependencyCollection dependencies, + SerializationHookContext hookCtx, + ISerializationContext? context = null) { var mapManager = dependencies.Resolve(); mapManager.SuppressOnTileChanged = true; diff --git a/Robust.Shared/EntitySerialization/Options.cs b/Robust.Shared/EntitySerialization/Options.cs new file mode 100644 index 00000000000..2c588439646 --- /dev/null +++ b/Robust.Shared/EntitySerialization/Options.cs @@ -0,0 +1,121 @@ +using System.Numerics; +using JetBrains.Annotations; +using Robust.Shared.EntitySerialization.Components; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Maths; + +namespace Robust.Shared.EntitySerialization; + +public record struct SerializationOptions +{ + public static readonly SerializationOptions Default = new(); + + /// + /// What to do when serializing the EntityUid of an entity that is not one of entities currently being serialized. + /// I.e., What should happen when serializing a map that has entities with components that store references to a + /// null-space entity? Note that this does not affect the treatment of , + /// which will never auto-include parents. + /// + public MissingEntityBehaviour MissingEntityBehaviour = MissingEntityBehaviour.IncludeNullspace; + + /// + /// Whether or not to log an error when serializing an entity without its parent. + /// + public bool ErrorOnOrphan = true; + + /// + /// Whether or not to log a warning when auto-including entities while serializing. See . + /// + public bool WarnOnAutoInclude = true; + + /// + /// If true, the serializer will log an error if it encounters a post map-init entity. + /// + public bool ExpectPreInit; + + public FileCategory Category; + + public SerializationOptions() + { + } +} + +public record struct DeserializationOptions() +{ + public static readonly DeserializationOptions Default = new(); + + /// + /// If true, each loaded entity will get a that stores the uid that the entity + /// had in the yaml file. This is used to maintain consistent entity labelling on subsequent saves. + /// + public bool StoreYamlUids = false; + + /// + /// If true, all maps that get created while loading this file will get map-initialized. + /// + public bool InitializeMaps = false; + + /// + /// If true, all maps that get created while loading this file will get paused. + /// Note that this will not automatically unpause maps that were saved while paused. + /// Pre-mapinit maps are assumed to be paused. + /// + public bool PauseMaps = false; + + /// + /// Whether or not to log an error when starting up a grid entity that has no map. + /// This usually indicates that someone is attempting to load an incorrect file type (e.g. loading a grid as a map). + /// + public bool LogOrphanedGrids = true; + + /// + /// Whether or not to automatically assign map ids to any deserialized map entities. + /// If false, maps need to be manually given ids before entities are initialized. + /// + public bool AssignMapids = true; +} + +/// +/// Superset of that contain information relevant to loading +/// maps & grids, potentially onto other existing maps. +/// +public struct MapLoadOptions() +{ + public static readonly MapLoadOptions Default = new(); + + /// + /// If specified, all orphaned entities and the children of all loaded maps will be re-parented onto this map. + /// I.e., this will merge map contents onto an existing map. This will also cause any maps that get loaded to + /// delete themselves after their children have been moved. + /// + public MapId? MergeMap = null; + + /// + /// Offset to apply to the position of any loaded entities that are directly parented to a map. + /// + public Vector2 Offset; + + /// + /// Rotation to apply to the position & local rotation of any loaded entities that are directly parented to a map. + /// + public Angle Rotation; + + /// + /// Options to use when deserializing entities. + /// + public DeserializationOptions DeserializationOptions = DeserializationOptions.Default; + + /// + /// When loading a single map, this will attempt to force the map to use the given map id. Generally, it is better + /// to allow the map system to auto-allocate a map id, to avoid accidentally re-using an old id. + /// + public MapId? ForceMapId; + + /// + /// The expected type for the file currently being read. + /// Will log errors if the category doesn't match the expected one (e.g., trying to load a "map" from a file + /// that doesn't contain any map entities). + /// + public FileCategory? ExpectedCategory; +} diff --git a/Robust.Shared/EntitySerialization/SerializationEnums.cs b/Robust.Shared/EntitySerialization/SerializationEnums.cs new file mode 100644 index 00000000000..2d225e24441 --- /dev/null +++ b/Robust.Shared/EntitySerialization/SerializationEnums.cs @@ -0,0 +1,82 @@ +using Robust.Shared.GameObjects; +using Robust.Shared.Upload; + +namespace Robust.Shared.EntitySerialization; + +/// +/// This enum is used to indicate the type of entity data that was written to a file. The actual format of the file does +/// not change, but it helps avoid mistakes like accidentally using a map file when trying to load a single grid. +/// +public enum FileCategory : byte +{ + Unknown, + + /// + /// File should contain a single root entity, its children, and maybe some null-space entities. + /// + Entity, + + /// + /// File should contain a single grid, its children, and maybe some null-space entities. + /// + Grid, + + /// + /// File should contain a single map, its children, and maybe some null-space entities. + /// + Map, + + /// + /// File is a full game save, and will in general contain one or more maps likely a few null-space entities. + /// + /// + /// The file might also contain additional yaml entries for things like prototypes uploaded via + /// , and might contain references to additional resources that need to be + /// loaded (e.g., files uploaded using ). + /// + Save, +} + +public enum MissingEntityBehaviour +{ + /// + /// Log an error and replace the reference with + /// + Error, + + /// + /// Ignore the reference, replace it with + /// + Ignore, + + /// + /// Automatically include & serialize any referenced null-space entities and their children. + /// I.e., entities that are not attached to any parent and are not maps. Any non-nullspace entities will result in + /// an error. + /// + /// + /// This is primarily intended to make it easy to auto-include information carrying null-space entities. E.g., the + /// "minds" of players, or entities that represent power or gas networks on a grid. Note that a full game save + /// should still try to explicitly include all relevant entities, as this could still easily fail to auto-include + /// relevant entities if they are not explicitly referenced in a data-field by some other entity. + /// + IncludeNullspace, + + /// + /// Automatically include & serialize any referenced entity. Note that this means that the missing entity's + /// parents will also be included, however this will not include other children. E.g., if serializing a grid that + /// references an entity on the map, this will also cause the map to get serialized, but will not necessarily + /// serialize everything on the map. + /// + /// + /// Note that this might unexpectedly change the . I.e., trying to serialize a grid might + /// accidentally lead to serializing a (partial?) map file. + /// + AutoInclude, + + /// + /// Variant of that will also automatically include the children of any entities that + /// that are automatically included. + /// + AutoIncludeChildren, +} diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs new file mode 100644 index 00000000000..52453f8d0f1 --- /dev/null +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs @@ -0,0 +1,505 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Numerics; +using Robust.Shared.ContentPack; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Map.Events; +using Robust.Shared.Maths; +using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Utility; +using Vector2 = System.Numerics.Vector2; + +namespace Robust.Shared.EntitySerialization.Systems; + +public sealed partial class MapLoaderSystem +{ + /// + /// Tries to load entities from a yaml file. If you are specifically trying to load a map or grid, you should + /// probably be using or instead + /// + /// The file to load. + /// Data class containing information about the loaded entities + /// Optional Options for configuring loading behaviour. + public bool TryLoadEntities(ResPath file, [NotNullWhen(true)] out LoadResult? result, MapLoadOptions? options = null) + { + result = null; + + if (!TryReadFile(file, out var data)) + return false; + + _stopwatch.Restart(); + var ev = new BeforeEntityReadEvent(); + RaiseLocalEvent(ev); + + var opts = options ?? MapLoadOptions.Default; + + // If we are forcing a map id, we cannot auto-assign ids. + opts.DeserializationOptions.AssignMapids = opts.ForceMapId == null; + + if (opts.MergeMap is { } targetId && !_mapSystem.MapExists(targetId)) + throw new Exception($"Target map {targetId} does not exist"); + + if (opts.MergeMap != null && opts.ForceMapId != null) + throw new Exception($"Invalid combination of MapLoadOptions"); + + if (_mapSystem.MapExists(opts.ForceMapId)) + throw new Exception($"Target map already exists"); + + // Using a local deserializer instead of a cached value, both to ensure that we don't accidentally carry over + // data from a previous serializations, and because some entities cause other maps/grids to be loaded during + // during mapinit. + var deserializer = new EntityDeserializer( + _dependency, + data, + opts.DeserializationOptions, + ev.RenamedPrototypes, + ev.DeletedPrototypes); + + if (!deserializer.ProcessData()) + { + Log.Debug($"Failed to process entity data in {file}"); + return false; + } + + try + { + deserializer.CreateEntities(); + } + catch (Exception e) + { + Log.Error($"Caught exception while creating entities: {e}"); + Delete(deserializer.Result); + throw; + } + + if (opts.ExpectedCategory is { } exp && exp != deserializer.Result.Category) + { + // Did someone try to load a map file as a grid or vice versa? + Log.Error($"File does not contain the expected data. Expected {exp} but got {deserializer.Result.Category}"); + Delete(deserializer.Result); + return false; + } + + // Reparent entities if loading entities onto an existing map. + var reparented = new HashSet(); + MergeMaps(deserializer, opts, reparented); + + if (!SetMapId(deserializer, opts)) + return false; + + // Apply any offsets & rotations specified by the load options + ApplyTransform(deserializer, opts); + + try + { + deserializer.StartEntities(); + } + catch (Exception e) + { + Log.Error($"Caught exception while starting entities: {e}"); + Delete(deserializer.Result); + throw; + } + + if (opts.MergeMap is {} map) + MapinitalizeReparented(reparented, map); + + result = deserializer.Result; + Log.Debug($"Loaded map in {_stopwatch.Elapsed}"); + return true; + } + + private bool SetMapId(EntityDeserializer deserializer, MapLoadOptions opts) + { + if (opts.ForceMapId is not {} id) + return true; + + if (deserializer.Result.Maps.Count != 1) + { + Log.Error( + $"The {nameof(MapLoadOptions.ForceMapId)} option is only supported when loading a file containing a single map."); + Delete(deserializer.Result); + return false; + } + + var map = deserializer.Result.Maps.Single(); + _mapSystem.AssignMapId(map, id); + return true; + } + + /// + /// Tries to load a file and return a list of grids and maps + /// + public bool TryLoadEntities( + ResPath file, + [NotNullWhen(true)] out HashSet>? maps, + [NotNullWhen(true)] out HashSet>? grids, + MapLoadOptions? options = null) + { + grids = null; + maps = null; + if (!TryLoadEntities(file, out var data, options)) + return false; + + maps = data.Maps; + grids = data.Grids; + return true; + } + + /// + /// Tries to load a grid entity from a file and parent it to the given map. + /// If the file does not contain exactly one grid, this will return false and delete loaded entities. + /// + public bool TryLoadGrid( + MapId map, + ResPath path, + [NotNullWhen(true)] out Entity? grid, + DeserializationOptions? options = null, + Vector2 offset = default, + Angle rot = default) + { + var opts = new MapLoadOptions + { + MergeMap = map, + Offset = offset, + Rotation = rot, + DeserializationOptions = options ?? DeserializationOptions.Default, + ExpectedCategory = FileCategory.Grid + }; + + grid = null; + if (!TryLoadEntities(path, out var result, opts)) + return false; + + if (result.Grids.Count == 1) + { + grid = result.Grids.Single(); + return true; + } + + Delete(result); + return false; + } + + /// + /// Attempts to load a file containing a single map, and merge its children onto another map. After which the + /// loaded map gets deleted. + /// + public bool TryMergeMap( + MapId mapId, + ResPath path, + [NotNullWhen(true)] out Entity? map, + [NotNullWhen(true)] out HashSet>? grids, + DeserializationOptions? options = null, + Vector2 offset = default, + Angle rot = default) + { + map = null; + grids = null; + + var opts = new MapLoadOptions + { + Offset = offset, + Rotation = rot, + DeserializationOptions = options ?? DeserializationOptions.Default, + ExpectedCategory = FileCategory.Map + }; + + if (!_mapSystem.MapExists(mapId)) + throw new Exception($"Target map {mapId} does not exist"); + + opts.MergeMap = mapId; + if (!TryLoadEntities(path, out var result, opts)) + return false; + + if (!_mapSystem.TryGetMap(mapId, out var uid) || !TryComp(uid, out MapComponent? comp)) + return false; + + map = new(uid.Value, comp); + grids = result.Grids; + return true; + } + + /// + /// Attempts to load a file containing a single map, assign it the given map id. + /// + /// + /// If possible, it is better to use which automatically assigns a . + /// + /// + /// Note that this will not automatically initialize the map, unless specified via the . + /// + public bool TryLoadMapWithId( + MapId mapId, + ResPath path, + [NotNullWhen(true)] out Entity? map, + [NotNullWhen(true)] out HashSet>? grids, + DeserializationOptions? options = null, + Vector2 offset = default, + Angle rot = default) + { + map = null; + grids = null; + + var opts = new MapLoadOptions + { + Offset = offset, + Rotation = rot, + DeserializationOptions = options ?? DeserializationOptions.Default, + ExpectedCategory = FileCategory.Map + }; + + if (_mapSystem.MapExists(mapId)) + throw new Exception($"Target map already exists"); + + opts.ForceMapId = mapId; + if (!TryLoadEntities(path, out var result, opts)) + return false; + + if (!_mapSystem.TryGetMap(mapId, out var uid) || !TryComp(uid, out MapComponent? comp)) + return false; + + map = new(uid.Value, comp); + grids = result.Grids; + return true; + } + + /// + /// Attempts to load a file containing a single map. + /// If the file does not contain exactly one map, this will return false and delete all loaded entities. + /// + /// + /// Note that this will not automatically initialize the map, unless specified via the . + /// + public bool TryLoadMap( + ResPath path, + [NotNullWhen(true)] out Entity? map, + [NotNullWhen(true)] out HashSet>? grids, + DeserializationOptions? options = null, + Vector2 offset = default, + Angle rot = default) + { + var opts = new MapLoadOptions + { + Offset = offset, + Rotation = rot, + DeserializationOptions = options ?? DeserializationOptions.Default, + ExpectedCategory = FileCategory.Map + }; + + map = null; + grids = null; + if (!TryLoadEntities(path, out var result, opts)) + return false; + + if (result.Maps.Count == 1) + { + map = result.Maps.First(); + grids = result.Grids; + return true; + } + + Delete(result); + return false; + } + + /// + /// Helper method for deleting all loaded entities. + /// + public void Delete(LoadResult result) + { + foreach (var uid in result.Maps) + { + Del(uid); + } + + foreach (var uid in result.Orphans) + { + Del(uid); + } + + foreach (var uid in result.Entities) + { + Del(uid); + } + } + + private bool TryGetReader(ResPath resPath, [NotNullWhen(true)] out TextReader? reader) + { + if (_resourceManager.UserData.Exists(resPath)) + { + // Log warning if file exists in both user and content data. + if (_resourceManager.ContentFileExists(resPath)) + Log.Warning("Reading map user data instead of content"); + + reader = _resourceManager.UserData.OpenText(resPath); + return true; + } + + if (_resourceManager.TryContentFileRead(resPath, out var contentReader)) + { + reader = new StreamReader(contentReader); + return true; + } + + Log.Error($"File not found: {resPath}"); + reader = null; + return false; + } + + private bool TryReadFile(ResPath file, [NotNullWhen(true)] out MappingDataNode? data) + { + var resPath = file.ToRootedPath(); + data = null; + + if (!TryGetReader(resPath, out var reader)) + return false; + + Log.Info($"Loading file: {resPath}"); + _stopwatch.Restart(); + + using var textReader = reader; + var documents = DataNodeParser.ParseYamlStream(reader).ToArray(); + Log.Debug($"Loaded yml stream in {_stopwatch.Elapsed}"); + + // Yes, logging errors in a "try" method is kinda shit, but it was throwing exceptions when I found it and it does + // make sense to at least provide some kind of feedback for why it failed. + switch (documents.Length) + { + case < 1: + Log.Error("Stream has no YAML documents."); + return false; + case > 1: + Log.Error("Stream too many YAML documents. Map files store exactly one."); + return false; + default: + data = (MappingDataNode) documents[0].Root; + return true; + } + } + + private void ApplyTransform(EntityDeserializer deserializer, MapLoadOptions opts) + { + if (opts.Rotation == Angle.Zero && opts.Offset == Vector2.Zero) + return; + + // If merging onto a single map, the transformation was already applied by SwapRootNode() + if (opts.MergeMap != null) + return; + + var matrix = Matrix3Helpers.CreateTransform(opts.Offset, opts.Rotation); + + // We want to apply the transforms to all children of any loaded maps. However, we can't just iterate over the + // children of loaded maps, as transform component has not yet been initialized. I.e. xform.Children is empty. + foreach (var uid in deserializer.Result.Entities) + { + var xform = Transform(uid); + + if (!_mapQuery.HasComp(xform.ParentUid)) + continue; + + // The original comment around this bit of logic was just: + // > Smelly + // I don't know what sloth meant by that, but I guess applying transforms to grid-maps is a no-no? + // Or more generally, loading a mapgrid onto another (potentially non-mapgrid) map is just generally kind of weird. + if (_gridQuery.HasComponent(xform.ParentUid)) + continue; + + var rot = xform.LocalRotation + opts.Rotation; + var pos = Vector2.Transform(xform.LocalPosition, matrix); + _xform.SetLocalPositionRotation(uid, pos, rot, xform); + DebugTools.Assert(!xform.NoLocalRotation || xform.LocalRotation == 0); + } + } + + private void MapinitalizeReparented(HashSet reparented, MapId targetId) + { + // fuck me I hate this map merging bullshit. + // loading a map "onto" another map shouldn't need to be supported by the generic map loading methods. + // If something needs to do that, it should implement it itself. + // AFAIK this only exists for the loadgamemap command? + + if (!_mapSystem.TryGetMap(targetId, out var targetUid)) + throw new Exception($"Target map {targetId} does not exist"); + + if (_mapSystem.IsInitialized(targetUid.Value)) + { + foreach (var uid in reparented) + { + _mapSystem.RecursiveMapInit(uid); + } + } + + var paused = _mapSystem.IsPaused(targetUid.Value); + foreach (var uid in reparented) + { + _mapSystem.RecursiveSetPaused(uid, paused); + } + } + + private void MergeMaps(EntityDeserializer deserializer, MapLoadOptions opts, HashSet reparented) + { + if (opts.MergeMap is not {} targetId) + return; + + if (!_mapSystem.TryGetMap(targetId, out var targetUid)) + throw new Exception($"Target map {targetId} does not exist"); + + deserializer.Result.Category = FileCategory.Unknown; + var rotation = opts.Rotation; + var matrix = Matrix3Helpers.CreateTransform(opts.Offset, rotation); + var target = new Entity(targetUid.Value, Transform(targetUid.Value)); + + foreach (var uid in deserializer.Result.Orphans) + { + Reparent(reparented, uid, target, matrix, rotation); + } + + deserializer.Result.Orphans.Clear(); + + foreach (var uid in deserializer.Result.Entities) + { + var xform = Transform(uid); + if (!_mapQuery.HasComp(xform.ParentUid)) + continue; + + // The original comment around this bit of logic was just: + // > Smelly + // I don't know what sloth meant by that, but I guess loading a grid-map onto another grid-map for whatever + // reason must be done without offsets? + // Or more generally, loading a mapgrid onto another (potentially non-mapgrid) map is just generally kind of weird. + if (_gridQuery.HasComponent(xform.ParentUid)) + { + Reparent(reparented, uid, target, Matrix3x2.Identity, Angle.Zero); + } + else + { + Reparent(reparented, uid, target, matrix, rotation); + } + } + + deserializer.ToDelete.UnionWith(deserializer.Result.Maps.Select(x => x.Owner)); + deserializer.Result.Maps.Clear(); + } + + private void Reparent( + HashSet reparented, + EntityUid uid, + Entity target, + in Matrix3x2 matrix, + Angle rotation) + { + reparented.Add(uid); + var xform = Transform(uid); + var angle = xform.LocalRotation + rotation; + var pos = Vector2.Transform(xform.LocalPosition, matrix); + var coords = new EntityCoordinates(target.Owner, pos); + _xform.SetCoordinates((uid, xform, MetaData(uid)), coords, rotation: angle, newParent: target.Comp); + } +} diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs new file mode 100644 index 00000000000..895d028af04 --- /dev/null +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs @@ -0,0 +1,131 @@ +using System; +using Robust.Shared.ContentPack; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Map.Events; +using Robust.Shared.Serialization; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Utility; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace Robust.Shared.EntitySerialization.Systems; + +public sealed partial class MapLoaderSystem +{ + /// + public event EntitySerializer.IsSerializableDelegate? OnIsSerializable; + + /// + /// Recursively serialize the given entity and its children. + /// + public (MappingDataNode Node, FileCategory Category) SerializeEntityRecursive(EntityUid uid, SerializationOptions? options = null) + { + _stopwatch.Restart(); + var opts = options ?? SerializationOptions.Default with + { + ExpectPreInit = (LifeStage(uid) < EntityLifeStage.MapInitialized) + }; + + var serializer = new EntitySerializer(_dependency, opts); + serializer.OnIsSerializeable += OnIsSerializable; + + serializer.SerializeEntityRecursive(uid); + + var data = serializer.Write(); + var cat = serializer.GetCategory(); + + Log.Debug($"Serialized {serializer.EntityData.Count} entities in {_stopwatch.Elapsed}"); + return (data, cat); + } + + /// + /// This method will serialize an entity and all of its children, and will write the result to a yaml file. + /// If you are specifically trying to save a single grid or map, you should probably use SaveGrid or SaveMap instead. + /// + public FileCategory SaveEntity(EntityUid uid, ResPath path, SerializationOptions? options = null) + { + if (!Exists(uid)) + throw new Exception($"{uid} does not exist."); + + var ev = new BeforeSaveEvent(uid, Transform(uid).MapUid); + RaiseLocalEvent(ev); + + Log.Debug($"Saving entity {ToPrettyString(uid)} to {path}"); + + var data = SerializeEntityRecursive(uid, options); + + var ev2 = new AfterSaveEvent(uid, Transform(uid).MapUid, data.Node, data.Category); + RaiseLocalEvent(ev2); + + var document = new YamlDocument(ev2.Node.ToYaml()); + var resPath = path.ToRootedPath(); + _resourceManager.UserData.CreateDir(resPath.Directory); + + using var writer = _resourceManager.UserData.OpenWriteText(resPath); + { + var stream = new YamlStream {document}; + stream.Save(new YamlMappingFix(new Emitter(writer)), false); + } + + Log.Info($"Saved {ToPrettyString(uid)} to {path}"); + + return data.Item2; + } + + public void SaveMap(MapId id, string path, SerializationOptions? options = null) + => SaveMap(id, new ResPath(path), options); + + public void SaveMap(MapId mapId, ResPath path, SerializationOptions? options = null) + { + if (!_mapSystem.TryGetMap(mapId, out var mapUid)) + { + Log.Error($"Unable to find map {mapId}"); + return; + } + + SaveMap(mapUid.Value, path, options); + } + + public void SaveMap(EntityUid map, ResPath path, SerializationOptions? options = null) + { + if (!HasComp(map)) + { + Log.Error($"{ToPrettyString(map)} is not a map."); + return; + } + + var opts = options ?? SerializationOptions.Default; + opts.Category = FileCategory.Map; + + var cat = SaveEntity(map, path, opts); + if (cat != FileCategory.Map) + Log.Error($"Failed to save {ToPrettyString(map)} as a map. Output: {cat}"); + } + + public void SaveGrid(EntityUid grid, string path, SerializationOptions? options = null) + => SaveGrid(grid, new ResPath(path), options); + + public void SaveGrid(EntityUid grid, ResPath path, SerializationOptions? options = null) + { + if (!HasComp(grid)) + { + Log.Error($"{ToPrettyString(grid)} is not a grid."); + return; + } + + if (HasComp(grid)) + { + Log.Error($"{ToPrettyString(grid)} is a map, not (just) a grid."); + return; + } + + var opts = options ?? SerializationOptions.Default; + opts.Category = FileCategory.Grid; + + var cat = SaveEntity(grid, path, opts); + if (cat != FileCategory.Grid) + Log.Error($"Failed to save {ToPrettyString(grid)} as a grid. Output: {cat}"); + } +} diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs new file mode 100644 index 00000000000..f3281998a50 --- /dev/null +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs @@ -0,0 +1,35 @@ +using Robust.Shared.ContentPack; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Map.Components; +using Robust.Shared.Timing; + +namespace Robust.Shared.EntitySerialization.Systems; + +/// +/// This class provides methods for saving and loading maps and grids. +/// +/// +/// The save & load methods are basically wrappers around and +/// , which can be used for more control over serialization. +/// +public sealed partial class MapLoaderSystem : EntitySystem +{ + [Dependency] private readonly IResourceManager _resourceManager = default!; + [Dependency] private readonly SharedMapSystem _mapSystem = default!; + [Dependency] private readonly SharedTransformSystem _xform = default!; + [Dependency] private readonly IDependencyCollection _dependency = default!; + + private Stopwatch _stopwatch = new(); + + private EntityQuery _mapQuery; + private EntityQuery _gridQuery; + + public override void Initialize() + { + base.Initialize(); + _gridQuery = GetEntityQuery(); + _mapQuery = GetEntityQuery(); + _gridQuery = GetEntityQuery(); + } +} diff --git a/Robust.Shared/GameObjects/EntityManager.cs b/Robust.Shared/GameObjects/EntityManager.cs index 6aa976d10b9..680f1832518 100644 --- a/Robust.Shared/GameObjects/EntityManager.cs +++ b/Robust.Shared/GameObjects/EntityManager.cs @@ -304,18 +304,23 @@ public virtual void FrameUpdate(float frameTime) #region Entity Management - /// + [Obsolete] public EntityUid CreateEntityUninitialized(string? prototypeName, EntityUid euid, ComponentRegistry? overrides = null) { return CreateEntity(prototypeName, out _, overrides); } /// - public virtual EntityUid CreateEntityUninitialized(string? prototypeName, ComponentRegistry? overrides = null) + public EntityUid CreateEntityUninitialized(string? prototypeName, ComponentRegistry? overrides = null) { return CreateEntity(prototypeName, out _, overrides); } + public EntityUid CreateEntityUninitialized(string? prototypeName, out MetaDataComponent meta, ComponentRegistry? overrides = null) + { + return CreateEntity(prototypeName, out meta, overrides); + } + /// public virtual EntityUid CreateEntityUninitialized(string? prototypeName, EntityCoordinates coordinates, ComponentRegistry? overrides = null, Angle rotation = default) { @@ -783,7 +788,7 @@ private void FlushEntitiesInternal() /// /// Allocates an entity and stores it but does not load components or do initialization. /// - private protected EntityUid AllocEntity( + protected internal EntityUid AllocEntity( EntityPrototype? prototype, out MetaDataComponent metadata) { @@ -793,6 +798,9 @@ private protected EntityUid AllocEntity( return entity; } + /// + internal EntityUid AllocEntity(EntityPrototype? prototype) => AllocEntity(prototype, out _); + /// /// Allocates an entity and stores it but does not load components or do initialization. /// @@ -872,21 +880,9 @@ private protected EntityUid CreateEntity(EntityPrototype prototype, out MetaData } } - private protected void LoadEntity(EntityUid entity, IEntityLoadContext? context) - { - EntityPrototype.LoadEntity((entity, MetaQuery.GetComponent(entity)), ComponentFactory, this, _serManager, context); - } - - private protected void LoadEntity(EntityUid entity, IEntityLoadContext? context, EntityPrototype? prototype) - { - var meta = MetaQuery.GetComponent(entity); - DebugTools.Assert(meta.EntityPrototype == prototype); - EntityPrototype.LoadEntity((entity, meta), ComponentFactory, this, _serManager, context); - } - public void InitializeAndStartEntity(EntityUid entity, MapId? mapId = null) { - var doMapInit = _mapManager.IsMapInitialized(mapId ?? TransformQuery.GetComponent(entity).MapID); + var doMapInit = _mapSystem.IsInitialized(mapId ?? TransformQuery.GetComponent(entity).MapID); InitializeAndStartEntity(entity, doMapInit); } diff --git a/Robust.Shared/GameObjects/Systems/EntityLookupSystem.cs b/Robust.Shared/GameObjects/Systems/EntityLookupSystem.cs index 9d49df23b96..59255216929 100644 --- a/Robust.Shared/GameObjects/Systems/EntityLookupSystem.cs +++ b/Robust.Shared/GameObjects/Systems/EntityLookupSystem.cs @@ -125,7 +125,7 @@ public override void Initialize() SubscribeLocalEvent(OnBroadphaseAdd); SubscribeLocalEvent(OnBroadphaseInit); SubscribeLocalEvent(OnGridAdd); - SubscribeLocalEvent(OnMapChange); + SubscribeLocalEvent(OnMapChange); _transform.OnBeforeMoveEvent += OnMove; EntityManager.EntityInitialized += OnEntityInit; @@ -194,9 +194,9 @@ private void RemoveChildrenFromTerminatingBroadphase(TransformComponent xform, } } - private void OnMapChange(MapChangedEvent ev) + private void OnMapChange(MapCreatedEvent ev) { - if (ev.Created && ev.Map != MapId.Nullspace) + if (ev.MapId != MapId.Nullspace) { EnsureComp(ev.Uid); } diff --git a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Map.cs b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Map.cs index ac7fb9f088a..9eb67c7c1c1 100644 --- a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Map.cs +++ b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Map.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Robust.Shared.GameStates; using Robust.Shared.Map; @@ -70,14 +71,15 @@ private void OnMapHandleState(EntityUid uid, MapComponent component, ref Compone if (component.MapId == MapId.Nullspace) { if (state.MapId == MapId.Nullspace) - throw new Exception($"Received invalid map state? {ToPrettyString(uid)}"); + throw new Exception($"Received invalid map state for {ToPrettyString(uid)}"); - component.MapId = state.MapId; - Maps.Add(component.MapId, uid); + AssignMapId((uid, component), state.MapId); RecursiveMapIdUpdate(uid, uid, component.MapId); } - DebugTools.AssertEqual(component.MapId, state.MapId); + if (component.MapId != state.MapId) + throw new Exception($"Received invalid map state for {ToPrettyString(uid)}"); + component.LightingEnabled = state.LightingEnabled; component.MapInitialized = state.Initialized; @@ -119,26 +121,75 @@ private void OnComponentAdd(EntityUid uid, MapComponent component, ComponentAdd EnsureComp(uid); } - private void OnCompInit(EntityUid uid, MapComponent component, ComponentInit args) + internal void AssignMapId(Entity map, MapId? id = null) { - if (component.MapId == MapId.Nullspace) - component.MapId = GetNextMapId(); - - DebugTools.AssertEqual(component.MapId.IsClientSide, IsClientSide(uid)); - if (!Maps.TryAdd(component.MapId, uid)) + if (map.Comp.MapId != MapId.Nullspace) { - if (Maps[component.MapId] != uid) - throw new Exception($"Attempted to initialize a map {ToPrettyString(uid)} with a duplicate map id {component.MapId}"); + if (id != null && map.Comp.MapId != id) + { + QueueDel(map.Owner); + throw new Exception($"Map entity {ToPrettyString(map.Owner)} has already been assigned an id"); + } + + if (!Maps.TryGetValue(map.Comp.MapId, out var existing) || existing != map.Owner) + { + QueueDel(map.Owner); + throw new Exception($"Map entity {ToPrettyString(map.Owner)} was improperly assigned a map id?"); + } + + DebugTools.Assert(UsedIds.Contains(map.Comp.MapId)); + return; } - var msg = new MapChangedEvent(uid, component.MapId, true); - RaiseLocalEvent(uid, msg, true); + map.Comp.MapId = id ?? GetNextMapId(); + + if (IsClientSide(map) != map.Comp.MapId.IsClientSide) + throw new Exception($"Attempting to assign a client-side map id to a networked entity or vice-versa"); + + if (!UsedIds.Add(map.Comp.MapId)) + Log.Warning($"Re-using a previously used map id ({map.Comp.MapId}) for map entity {ToPrettyString(map)}"); + + if (Maps.TryAdd(map.Comp.MapId, map.Owner)) + return; + + if (Maps[map.Comp.MapId] == map.Owner) + return; + + QueueDel(map); + throw new Exception( + $"Attempted to assign an existing mapId {map.Comp} to a map entity {ToPrettyString(map.Owner)}"); + } + + private void OnCompInit(Entity map, ref ComponentInit args) + { + AssignMapId(map); + +#pragma warning disable CS0618 // Type or member is obsolete + var msg = new MapChangedEvent(map, map.Comp.MapId, true); +#pragma warning restore CS0618 // Type or member is obsolete + RaiseLocalEvent(map, msg, true); + var ev = new MapCreatedEvent(map, map.Comp.MapId); + RaiseLocalEvent(map, ev, true); + } + + private void OnMapInit(EntityUid uid, MapComponent component, MapInitEvent args) + { + DebugTools.Assert(!component.MapInitialized); + component.MapInitialized = true; + Dirty(uid, component); } private void OnCompStartup(EntityUid uid, MapComponent component, ComponentStartup args) { - if (component.MapPaused) - RecursiveSetPaused(uid, true); + // un-initialized maps are always paused. + component.MapPaused |= !component.MapInitialized; + + if (!component.MapPaused) + return; + + // Recursively pause all entities on the map + component.MapPaused = false; + SetPaused(uid, true); } private void OnMapRemoved(EntityUid uid, MapComponent component, ComponentShutdown args) @@ -146,8 +197,13 @@ private void OnMapRemoved(EntityUid uid, MapComponent component, ComponentShutdo DebugTools.Assert(component.MapId != MapId.Nullspace); Maps.Remove(component.MapId); +#pragma warning disable CS0618 // Type or member is obsolete var msg = new MapChangedEvent(uid, component.MapId, false); +#pragma warning restore CS0618 // Type or member is obsolete RaiseLocalEvent(uid, msg, true); + + var ev = new MapRemovedEvent(uid, component.MapId); + RaiseLocalEvent(uid, ev, true); } /// @@ -181,19 +237,17 @@ public EntityUid CreateMap(MapId mapId, bool runMapInit = true) if (_netManager.IsClient && _netManager.IsConnected && !mapId.IsClientSide) throw new ArgumentException($"Attempted to create a client-side map entity with a non client-side map ID?"); - var uid = EntityManager.CreateEntityUninitialized(null); - var map = _factory.GetComponent(); - map.MapId = mapId; - AddComp(uid, map); + if (UsedIds.Contains(mapId)) + Log.Warning($"Re-using MapId: {mapId}"); - // Give the entity a name, mainly for debugging. Content can always override this with a localized name. - var meta = MetaData(uid); - _meta.SetEntityName(uid, $"Map Entity", meta); + var (uid, map, meta) = CreateUninitializedMap(); + DebugTools.AssertEqual(map.MapId, MapId.Nullspace); + AssignMapId((uid, map), mapId); // Initialize components. this should add the map id to the collections. - EntityManager.InitializeComponents(uid, meta); - EntityManager.StartComponents(uid); - DebugTools.Assert(Maps[mapId] == uid); + EntityManager.InitializeEntity(uid, meta); + EntityManager.StartEntity(uid); + DebugTools.AssertEqual(Maps[mapId], uid); if (runMapInit) InitializeMap((uid, map)); @@ -202,4 +256,22 @@ public EntityUid CreateMap(MapId mapId, bool runMapInit = true) return uid; } + + public Entity CreateUninitializedMap() + { + var uid = EntityManager.CreateEntityUninitialized(null, out var meta); + _meta.SetEntityName(uid, $"Map Entity", meta); + return (uid, AddComp(uid), meta); + } + + public void DeleteMap(MapId mapId) + { + if (TryGetMap(mapId, out var uid)) + Del(uid); + } + + public IEnumerable GetAllMapIds() + { + return Maps.Keys; + } } diff --git a/Robust.Shared/GameObjects/Systems/SharedMapSystem.MapInit.cs b/Robust.Shared/GameObjects/Systems/SharedMapSystem.MapInit.cs index e7b326e039c..a5d903b9125 100644 --- a/Robust.Shared/GameObjects/Systems/SharedMapSystem.MapInit.cs +++ b/Robust.Shared/GameObjects/Systems/SharedMapSystem.MapInit.cs @@ -34,13 +34,6 @@ public bool IsInitialized(Entity map) return map.Comp.MapInitialized; } - private void OnMapInit(EntityUid uid, MapComponent component, MapInitEvent args) - { - DebugTools.Assert(!component.MapInitialized); - component.MapInitialized = true; - EntityManager.Dirty(uid, component); - } - public void InitializeMap(MapId mapId, bool unpause = true) { if(!Maps.TryGetValue(mapId, out var uid)) @@ -63,7 +56,7 @@ public void InitializeMap(Entity map, bool unpause = true) SetPaused(map, false); } - private void RecursiveMapInit(EntityUid entity) + internal void RecursiveMapInit(EntityUid entity) { var toInitialize = new List {entity}; for (var i = 0; i < toInitialize.Count; i++) diff --git a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Pause.cs b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Pause.cs index cd62f2c2dfe..53ca0ce95d5 100644 --- a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Pause.cs +++ b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Pause.cs @@ -22,7 +22,7 @@ public bool IsPaused(Entity map) if (!_mapQuery.Resolve(map, ref map.Comp)) return false; - return map.Comp.MapPaused; + return map.Comp.MapPaused || !map.Comp.MapInitialized; } public void SetPaused(MapId mapId, bool paused) @@ -49,7 +49,7 @@ public void SetPaused(Entity map, bool paused) RecursiveSetPaused(map, paused); } - private void RecursiveSetPaused(EntityUid entity, bool paused) + internal void RecursiveSetPaused(EntityUid entity, bool paused) { _meta.SetEntityPaused(entity, paused); foreach (var child in Transform(entity)._children) diff --git a/Robust.Shared/GameObjects/Systems/SharedMapSystem.cs b/Robust.Shared/GameObjects/Systems/SharedMapSystem.cs index f87a98e33cb..dfdcfcde832 100644 --- a/Robust.Shared/GameObjects/Systems/SharedMapSystem.cs +++ b/Robust.Shared/GameObjects/Systems/SharedMapSystem.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Robust.Shared.GameStates; using Robust.Shared.IoC; @@ -32,6 +33,13 @@ public abstract partial class SharedMapSystem : EntitySystem internal Dictionary Maps { get; } = new(); + /// + /// This hashset is used to try prevent MapId re-use. This is mainly for auto-assigned map ids. + /// Loading a map with a specific id (e.g., the various mapping commands) may still result in an id being + /// reused. + /// + protected HashSet UsedIds = new(); + public override void Initialize() { base.Initialize(); @@ -53,6 +61,7 @@ public override void Initialize() /// /// Arguments for when a map is created or deleted. /// + [Obsolete("Use map creation or deletion events")] public sealed class MapChangedEvent : EntityEventArgs { public EntityUid Uid; @@ -83,6 +92,16 @@ public MapChangedEvent(EntityUid uid, MapId map, bool created) public bool Destroyed => !Created; } + /// + /// Event raised whenever a map is created. + /// + public readonly record struct MapCreatedEvent(EntityUid Uid, MapId MapId); + + /// + /// Event raised whenever a map is removed. + /// + public readonly record struct MapRemovedEvent(EntityUid Uid, MapId MapId); + #pragma warning disable CS0618 public sealed class GridStartupEvent : EntityEventArgs { diff --git a/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs b/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs index 1323c693edd..88ba50de28a 100644 --- a/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs +++ b/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs @@ -5,7 +5,7 @@ using Robust.Shared.Physics; using Robust.Shared.Utility; using System; -using System.Diagnostics; +using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using Robust.Shared.Map.Components; @@ -213,7 +213,15 @@ public bool IsParentOf(TransformComponent parent, EntityUid child) } else if (_mapQuery.TryComp(uid, out var mapComp)) { - DebugTools.AssertNotEqual(mapComp.MapId, MapId.Nullspace); + if (mapComp.MapId == MapId.Nullspace) + { +#if !EXCEPTION_TOLERANCE + throw new Exception("Transform is initialising before map ids have been assigned?"); +#endif + Log.Error($"Transform is initialising before map ids have been assigned?"); + _map.AssignMapId((uid, mapComp)); + } + xform.MapUid = uid; xform.MapID = mapComp.MapId; } diff --git a/Robust.Shared/GameObjects/Systems/SharedTransformSystem.cs b/Robust.Shared/GameObjects/Systems/SharedTransformSystem.cs index 76f2068861c..14484863dcb 100644 --- a/Robust.Shared/GameObjects/Systems/SharedTransformSystem.cs +++ b/Robust.Shared/GameObjects/Systems/SharedTransformSystem.cs @@ -201,7 +201,7 @@ public EntityCoordinates GetMoverCoordinates(EntityCoordinates coordinates) if (xform.GridUid == xform.ParentUid) return (xform.Coordinates, GetWorldRotation(xform, XformQuery)); - DebugTools.Assert(!_mapManager.IsGrid(uid) && !_mapManager.IsMap(uid)); + DebugTools.Assert(!HasComp(uid) && !HasComp(uid)); var (pos, worldRot) = GetWorldPositionRotation(xform, XformQuery); diff --git a/Robust.Shared/Map/Components/MapComponent.cs b/Robust.Shared/Map/Components/MapComponent.cs index d771a8c4754..3633ad41cbd 100644 --- a/Robust.Shared/Map/Components/MapComponent.cs +++ b/Robust.Shared/Map/Components/MapComponent.cs @@ -15,7 +15,7 @@ public sealed partial class MapComponent : Component [DataField] public bool LightingEnabled { get; set; } = true; - [ViewVariables(VVAccess.ReadOnly)] + [ViewVariables(VVAccess.ReadOnly), Access(typeof(SharedMapSystem), Other = AccessPermissions.ReadExecute)] public MapId MapId { get; internal set; } = MapId.Nullspace; [DataField, Access(typeof(SharedMapSystem), typeof(MapManager))] diff --git a/Robust.Shared/Map/Events/MapSerializationEvents.cs b/Robust.Shared/Map/Events/MapSerializationEvents.cs index 770ebadfb51..a8c0f38a841 100644 --- a/Robust.Shared/Map/Events/MapSerializationEvents.cs +++ b/Robust.Shared/Map/Events/MapSerializationEvents.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using Robust.Shared.EntitySerialization; using Robust.Shared.GameObjects; +using Robust.Shared.Serialization.Markdown.Mapping; namespace Robust.Shared.Map.Events; @@ -42,3 +44,8 @@ public sealed class BeforeSaveEvent(EntityUid entity, EntityUid? map) /// public EntityUid? Map = map; } + +/// +/// This event is broadcast just after an entity gets serialized, but before it gets written to a yaml file. +/// +public readonly record struct AfterSaveEvent(EntityUid Uid, EntityUid? Map, MappingDataNode Node, FileCategory Category); diff --git a/Robust.Shared/Map/IMapManager.cs b/Robust.Shared/Map/IMapManager.cs index db37657a152..c64ef2af5e1 100644 --- a/Robust.Shared/Map/IMapManager.cs +++ b/Robust.Shared/Map/IMapManager.cs @@ -47,21 +47,25 @@ public interface IMapManager /// /// The map ID to check existence of. /// True if the map exists, false otherwise. + [Obsolete("Use MapSystem")] bool MapExists([NotNullWhen(true)] MapId? mapId); /// /// Returns the map entity ID for a given map, or an invalid entity Id if the map does not exist. /// - [Obsolete("Use TryGetMap")] + [Obsolete("Use MapSystem")] EntityUid GetMapEntityId(MapId mapId); /// /// Replaces GetMapEntity()'s throw-on-failure semantics. /// + [Obsolete("Use MapSystem")] EntityUid GetMapEntityIdOrThrow(MapId mapId); + [Obsolete("Use MapSystem")] IEnumerable GetAllMapIds(); + [Obsolete("Use MapSystem")] void DeleteMap(MapId mapId); // ReSharper disable once MethodOverloadWithOptionalParameter @@ -205,20 +209,22 @@ public IEnumerable FindGridsIntersecting(MapId mapId, Box2Rota [Obsolete("Just delete the grid entity")] void DeleteGrid(EntityUid euid); + [Obsolete("Use HasComp")] bool IsGrid(EntityUid uid); + + [Obsolete("Use HasComp")] bool IsMap(EntityUid uid); // // Pausing functions // + [Obsolete("Use MapSystem")] void SetMapPaused(MapId mapId, bool paused); + [Obsolete("Use MapSystem")] void DoMapInitialize(MapId mapId); - [Obsolete("Use CreateMap's runMapInit argument")] - void AddUninitializedMap(MapId mapId); - [Obsolete("Use MapSystem")] bool IsMapPaused(MapId mapId); diff --git a/Robust.Shared/Map/ITileDefinitionManager.cs b/Robust.Shared/Map/ITileDefinitionManager.cs index fd1f13226dd..c409202567e 100644 --- a/Robust.Shared/Map/ITileDefinitionManager.cs +++ b/Robust.Shared/Map/ITileDefinitionManager.cs @@ -67,15 +67,5 @@ public interface ITileDefinitionManager : IEnumerable /// /// THe definition to register. void Register(ITileDefinition tileDef); - - /// - /// Register a tile alias with this manager. - /// The tile need not exist yet - the alias's creation will be deferred until it exists. - /// Tile aliases do not have IDs of their own and do not show up in enumeration. - /// Their main utility is for easier map migration. - /// - /// The source tile (i.e. name of the alias). - /// The destination tile (i.e. the actual concrete tile). - void AssignAlias(string src, string dst); } } diff --git a/Robust.Shared/Map/MapId.cs b/Robust.Shared/Map/MapId.cs index 1a696e50143..b76b89987bd 100644 --- a/Robust.Shared/Map/MapId.cs +++ b/Robust.Shared/Map/MapId.cs @@ -51,7 +51,7 @@ public static explicit operator int(MapId self) public override string ToString() { - return Value.ToString(); + return IsClientSide ? $"c{-Value}" : Value.ToString(); } public bool IsClientSide => Value < 0; diff --git a/Robust.Shared/Map/MapManager.GridCollection.cs b/Robust.Shared/Map/MapManager.GridCollection.cs index 50aba226ea2..8f036224981 100644 --- a/Robust.Shared/Map/MapManager.GridCollection.cs +++ b/Robust.Shared/Map/MapManager.GridCollection.cs @@ -125,6 +125,9 @@ protected Entity CreateGrid(EntityUid map, ushort chunkSize, E EntityManager.System().SetEntityName(gridEnt, $"grid", meta); EntityManager.InitializeComponents(gridEnt, meta); EntityManager.StartComponents(gridEnt); + // Note that this does not actually map-initialize the gird entity, even if the map its being spawn on has already been initialized. + // I don't know whether that is intentional or not. + return (gridEnt, grid); } } diff --git a/Robust.Shared/Map/MapManager.MapCollection.cs b/Robust.Shared/Map/MapManager.MapCollection.cs index 4e308388c06..e8a5f44e181 100644 --- a/Robust.Shared/Map/MapManager.MapCollection.cs +++ b/Robust.Shared/Map/MapManager.MapCollection.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using Robust.Shared.GameObjects; using Robust.Shared.Map.Components; -using Robust.Shared.Utility; namespace Robust.Shared.Map; @@ -28,16 +27,10 @@ public MapEventArgs(MapId map) internal partial class MapManager { - private Dictionary _mapEntities => _mapSystem.Maps; - /// public virtual void DeleteMap(MapId mapId) { - if (!_mapEntities.TryGetValue(mapId, out var ent) || !ent.IsValid()) - throw new InvalidOperationException($"Attempted to delete nonexistent map '{mapId}'"); - - EntityManager.DeleteEntity(ent); - DebugTools.Assert(!_mapEntities.ContainsKey(mapId)); + _mapSystem.DeleteMap(mapId); } /// @@ -81,7 +74,7 @@ public bool TryGetMap([NotNullWhen(true)] MapId? mapId, [NotNullWhen(true)] out /// public IEnumerable GetAllMapIds() { - return _mapEntities.Keys; + return _mapSystem.GetAllMapIds(); } /// diff --git a/Robust.Shared/Map/MapManager.Pause.cs b/Robust.Shared/Map/MapManager.Pause.cs index 9cd232f4c89..d4fb8f58976 100644 --- a/Robust.Shared/Map/MapManager.Pause.cs +++ b/Robust.Shared/Map/MapManager.Pause.cs @@ -1,7 +1,5 @@ -using System; using System.Globalization; using Robust.Shared.GameObjects; -using Robust.Shared.Map.Components; namespace Robust.Shared.Map { @@ -27,14 +25,6 @@ public bool IsMapInitialized(MapId mapId) return _mapSystem.IsInitialized(mapId); } - public void AddUninitializedMap(MapId mapId) - { - var ent = GetMapEntityId(mapId); - EntityManager.GetComponent(ent).MapInitialized = false; - var meta = EntityManager.GetComponent(ent); - ((EntityManager)EntityManager).SetLifeStage(meta, EntityLifeStage.Initialized); - } - /// public bool IsMapPaused(MapId mapId) { @@ -87,7 +77,7 @@ private void InitializeMapPausing() return; } - shell.WriteLine(IsMapPaused(mapId).ToString()); + shell.WriteLine(_mapSystem.IsPaused(mapId).ToString()); }); _conhost.RegisterCommand("unpausemap", diff --git a/Robust.Shared/Map/MapManager.Queries.cs b/Robust.Shared/Map/MapManager.Queries.cs index e61e572747c..189482c3f25 100644 --- a/Robust.Shared/Map/MapManager.Queries.cs +++ b/Robust.Shared/Map/MapManager.Queries.cs @@ -46,54 +46,54 @@ private bool IsIntersecting( public void FindGridsIntersecting(MapId mapId, IPhysShape shape, Transform transform, ref List> grids, bool approx = IMapManager.Approximate, bool includeMap = IMapManager.IncludeMap) { - if (_mapEntities.TryGetValue(mapId, out var mapEnt)) - FindGridsIntersecting(mapEnt, shape, transform, ref grids, approx, includeMap); + if (_mapSystem.TryGetMap(mapId, out var mapEnt)) + FindGridsIntersecting(mapEnt.Value, shape, transform, ref grids, approx, includeMap); } public void FindGridsIntersecting(MapId mapId, IPhysShape shape, Transform transform, GridCallback callback, bool approx = IMapManager.Approximate, bool includeMap = IMapManager.IncludeMap) { - if (_mapEntities.TryGetValue(mapId, out var mapEnt)) - FindGridsIntersecting(mapEnt, shape, transform, callback, includeMap, approx); + if (_mapSystem.TryGetMap(mapId, out var mapEnt)) + FindGridsIntersecting(mapEnt.Value, shape, transform, callback, includeMap, approx); } public void FindGridsIntersecting(MapId mapId, Box2 worldAABB, GridCallback callback, bool approx = IMapManager.Approximate, bool includeMap = IMapManager.IncludeMap) { - if (_mapEntities.TryGetValue(mapId, out var mapEnt)) - FindGridsIntersecting(mapEnt, worldAABB, callback, approx, includeMap); + if (_mapSystem.TryGetMap(mapId, out var mapEnt)) + FindGridsIntersecting(mapEnt.Value, worldAABB, callback, approx, includeMap); } public void FindGridsIntersecting(MapId mapId, Box2 worldAABB, ref TState state, GridCallback callback, bool approx = IMapManager.Approximate, bool includeMap = IMapManager.IncludeMap) { - if (_mapEntities.TryGetValue(mapId, out var map)) - FindGridsIntersecting(map, worldAABB, ref state, callback, approx, includeMap); + if (_mapSystem.TryGetMap(mapId, out var map)) + FindGridsIntersecting(map.Value, worldAABB, ref state, callback, approx, includeMap); } public void FindGridsIntersecting(MapId mapId, Box2 worldAABB, ref List> grids, bool approx = IMapManager.Approximate, bool includeMap = IMapManager.IncludeMap) { - if (_mapEntities.TryGetValue(mapId, out var map)) - FindGridsIntersecting(map, worldAABB, ref grids, approx, includeMap); + if (_mapSystem.TryGetMap(mapId, out var map)) + FindGridsIntersecting(map.Value, worldAABB, ref grids, approx, includeMap); } public void FindGridsIntersecting(MapId mapId, Box2Rotated worldBounds, GridCallback callback, bool approx = IMapManager.Approximate, bool includeMap = IMapManager.IncludeMap) { - if (_mapEntities.TryGetValue(mapId, out var mapEnt)) - FindGridsIntersecting(mapEnt, worldBounds, callback, approx, includeMap); + if (_mapSystem.TryGetMap(mapId, out var mapEnt)) + FindGridsIntersecting(mapEnt.Value, worldBounds, callback, approx, includeMap); } public void FindGridsIntersecting(MapId mapId, Box2Rotated worldBounds, ref TState state, GridCallback callback, bool approx = IMapManager.Approximate, bool includeMap = IMapManager.IncludeMap) { - if (_mapEntities.TryGetValue(mapId, out var mapEnt)) - FindGridsIntersecting(mapEnt, worldBounds, ref state, callback, approx, includeMap); + if (_mapSystem.TryGetMap(mapId, out var mapEnt)) + FindGridsIntersecting(mapEnt.Value, worldBounds, ref state, callback, approx, includeMap); } public void FindGridsIntersecting(MapId mapId, Box2Rotated worldBounds, ref List> grids, bool approx = IMapManager.Approximate, bool includeMap = IMapManager.IncludeMap) { - if (_mapEntities.TryGetValue(mapId, out var mapEnt)) - FindGridsIntersecting(mapEnt, worldBounds, ref grids, approx, includeMap); + if (_mapSystem.TryGetMap(mapId, out var mapEnt)) + FindGridsIntersecting(mapEnt.Value, worldBounds, ref grids, approx, includeMap); } #endregion @@ -324,8 +324,8 @@ public bool TryFindGridAt( /// public bool TryFindGridAt(MapId mapId, Vector2 worldPos, out EntityUid uid, [NotNullWhen(true)] out MapGridComponent? grid) { - if (_mapEntities.TryGetValue(mapId, out var map)) - return TryFindGridAt(map, worldPos, out uid, out grid); + if (_mapSystem.TryGetMap(mapId, out var map)) + return TryFindGridAt(map.Value, worldPos, out uid, out grid); uid = default; grid = null; diff --git a/Robust.Shared/Map/MapManager.cs b/Robust.Shared/Map/MapManager.cs index 6dd0abbc167..d58ab3175ce 100644 --- a/Robust.Shared/Map/MapManager.cs +++ b/Robust.Shared/Map/MapManager.cs @@ -11,10 +11,10 @@ namespace Robust.Shared.Map; /// [Virtual] -internal partial class MapManager : IMapManagerInternal, IEntityEventSubscriber, IPostInjectInit +internal partial class MapManager : IMapManagerInternal, IEntityEventSubscriber { - [field: Dependency] public IGameTiming GameTiming { get; } = default!; - [field: Dependency] public IEntityManager EntityManager { get; } = default!; + [Dependency] public readonly IGameTiming GameTiming = default!; + [Dependency] public readonly IEntityManager EntityManager = default!; [Dependency] private readonly IManifoldManager _manifolds = default!; [Dependency] private readonly ILogManager _logManager = default!; [Dependency] private readonly IConsoleHost _conhost = default!; @@ -34,6 +34,7 @@ public void Initialize() _gridTreeQuery = EntityManager.GetEntityQuery(); _gridQuery = EntityManager.GetEntityQuery(); InitializeMapPausing(); + _sawmill = _logManager.GetSawmill("system.map"); } /// @@ -74,9 +75,4 @@ public void Restart() EntityManager.DeleteEntity(uid); } } - - void IPostInjectInit.PostInject() - { - _sawmill = _logManager.GetSawmill("system.map"); - } } diff --git a/Robust.Shared/Map/MapSerializationContext.cs b/Robust.Shared/Map/MapSerializationContext.cs deleted file mode 100644 index 72d106d4597..00000000000 --- a/Robust.Shared/Map/MapSerializationContext.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using JetBrains.Annotations; -using Robust.Shared.GameObjects; -using Robust.Shared.IoC; -using Robust.Shared.Log; -using Robust.Shared.Serialization; -using Robust.Shared.Serialization.Manager; -using Robust.Shared.Serialization.Markdown; -using Robust.Shared.Serialization.Markdown.Validation; -using Robust.Shared.Serialization.Markdown.Value; -using Robust.Shared.Serialization.TypeSerializers.Interfaces; -using Robust.Shared.Timing; - -namespace Robust.Shared.Map; - -internal sealed class MapSerializationContext : ISerializationContext, IEntityLoadContext, - ITypeSerializer -{ - public SerializationManager.SerializerProvider SerializerProvider { get; } = new(); - - // Run-specific data - public Dictionary? TileMap; - public readonly Dictionary CurrentReadingEntityComponents = new(); - public HashSet CurrentlyIgnoredComponents = new(); - public string? CurrentComponent; - public EntityUid? CurrentWritingEntity; - public IEntityManager EntityManager; - public IGameTiming Timing; - - private Dictionary _uidEntityMap = new(); - private Dictionary _entityUidMap = new(); - - // Native tile ID -> map tile ID map for writing maps. - public Dictionary TileWriteMap = []; - - /// - /// Are we currently iterating prototypes or entities for writing. - /// - public bool WritingReadingPrototypes { get; set; } - - /// - /// Whether the map has been MapInitialized or not. - /// - public bool MapInitialized; - - /// - /// How long the target map has been paused. Used for time offsets. - /// - public TimeSpan PauseTime; - - /// - /// The parent of the entity being saved, This entity is not itself getting saved. - /// - private EntityUid? _parentUid; - - public MapSerializationContext(IEntityManager entityManager, IGameTiming timing) - { - EntityManager = entityManager; - Timing = timing; - SerializerProvider.RegisterSerializer(this); - } - - public void Set( - Dictionary uidEntityMap, - Dictionary entityUidMap, - bool mapPreInit, - TimeSpan pauseTime, - EntityUid? parentUid) - { - _uidEntityMap = uidEntityMap; - _entityUidMap = entityUidMap; - MapInitialized = mapPreInit; - PauseTime = pauseTime; - if (parentUid != null && parentUid.Value.IsValid()) - _parentUid = parentUid; - } - - public void Clear() - { - CurrentReadingEntityComponents.Clear(); - CurrentlyIgnoredComponents.Clear(); - CurrentComponent = null; - CurrentWritingEntity = null; - PauseTime = TimeSpan.Zero; - } - - // Create custom object serializers that will correctly allow data to be overriden by the map file. - bool IEntityLoadContext.TryGetComponent(string componentName, [NotNullWhen(true)] out IComponent? component) - { - return CurrentReadingEntityComponents.TryGetValue(componentName, out component); - } - - public IEnumerable GetExtraComponentTypes() - { - return CurrentReadingEntityComponents!.Keys; - } - - public bool ShouldSkipComponent(string compName) - { - return CurrentlyIgnoredComponents.Contains(compName); - } - - ValidationNode ITypeValidator.Validate(ISerializationManager serializationManager, - ValueDataNode node, IDependencyCollection dependencies, ISerializationContext? context) - { - if (node.Value == "null") - { - return new ValidatedValueNode(node); - } - - if (!int.TryParse(node.Value, out var val) || !_uidEntityMap.ContainsKey(val)) - { - return new ErrorNode(node, "Invalid EntityUid", true); - } - - return new ValidatedValueNode(node); - } - - public DataNode Write(ISerializationManager serializationManager, EntityUid value, - IDependencyCollection dependencies, bool alwaysWrite = false, - ISerializationContext? context = null) - { - if (!_entityUidMap.TryGetValue(value, out var entityUidMapped)) - { - if (CurrentComponent == "Transform") - { - if (!value.IsValid() || value == _parentUid) - return new ValueDataNode("invalid"); - } - - dependencies - .Resolve() - .GetSawmill("map") - .Error("Encountered an invalid entityUid '{0}' while serializing a map.", value); - - return new ValueDataNode("invalid"); - } - - return new ValueDataNode(entityUidMapped.ToString(CultureInfo.InvariantCulture)); - } - - EntityUid ITypeReader.Read(ISerializationManager serializationManager, - ValueDataNode node, - IDependencyCollection dependencies, - SerializationHookContext hookCtx, - ISerializationContext? context, ISerializationManager.InstantiationDelegate? _) - { - if (node.Value == "invalid" && CurrentComponent == "Transform") - return EntityUid.Invalid; - - if (int.TryParse(node.Value, out var val) && _uidEntityMap.TryGetValue(val, out var entity)) - return entity; - - dependencies - .Resolve() - .GetSawmill("map") - .Error("Error in map file: found local entity UID '{0}' which does not exist.", val); - - return EntityUid.Invalid; - - } - - [MustUseReturnValue] - public EntityUid Copy(ISerializationManager serializationManager, EntityUid source, EntityUid target, - bool skipHook, - ISerializationContext? context = null) - { - return new((int)source); - } -} diff --git a/Robust.Shared/Map/TileDefinitionManager.cs b/Robust.Shared/Map/TileDefinitionManager.cs index a4694510d5e..7e8d676d42e 100644 --- a/Robust.Shared/Map/TileDefinitionManager.cs +++ b/Robust.Shared/Map/TileDefinitionManager.cs @@ -27,10 +27,6 @@ public TileDefinitionManager() public virtual void Initialize() { - foreach (var prototype in _prototypeManager.EnumeratePrototypes()) - { - AssignAlias(prototype.ID, prototype.Target); - } } public virtual void Register(ITileDefinition tileDef) @@ -45,46 +41,8 @@ public virtual void Register(ITileDefinition tileDef) tileDef.AssignTileId(id); TileDefs.Add(tileDef); _tileNames[name] = tileDef; - - AliasingHandleDeferred(name); } - private void AliasingHandleDeferred(string name) - { - // Aliases may have been held back due to tiles not being registered yet, handle this. - if (_awaitingAliases.ContainsKey(name)) - { - var list = _awaitingAliases[name]; - _awaitingAliases.Remove(name); - foreach (var alias in list) - { - AssignAlias(alias, name); - } - } - } - - - public virtual void AssignAlias(string src, string dst) - { - if (_tileNames.ContainsKey(src)) - { - throw new ArgumentException("Another tile definition or alias with the same name has already been registered.", nameof(src)); - } - - if (_tileNames.ContainsKey(dst)) - { - // Simple enough, source to destination. - _tileNames[src] = _tileNames[dst]; - AliasingHandleDeferred(src); - } - else - { - // Less simple - stash this alias for later so it appears when the target does. - if (!_awaitingAliases.ContainsKey(dst)) - _awaitingAliases[dst] = new(); - _awaitingAliases[dst].Add(src); - } - } public Tile GetVariantTile(string name, IRobustRandom random) { diff --git a/Robust.Shared/Physics/FixtureSerializer.cs b/Robust.Shared/Physics/FixtureSerializer.cs index a16e01465f3..7fee831b863 100644 --- a/Robust.Shared/Physics/FixtureSerializer.cs +++ b/Robust.Shared/Physics/FixtureSerializer.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Robust.Shared.EntitySerialization; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Map; @@ -78,13 +79,11 @@ public DataNode Write(ISerializationManager serializationManager, Dictionary().HasComponent(mapContext.CurrentWritingEntity)) - { + if (ctx.EntMan.HasComponent(ctx.CurrentEntity)) return seq; - } } foreach (var (id, fixture) in value) diff --git a/Robust.Shared/Prototypes/EntityPrototype.cs b/Robust.Shared/Prototypes/EntityPrototype.cs index e97d14b1071..d3e9f223887 100644 --- a/Robust.Shared/Prototypes/EntityPrototype.cs +++ b/Robust.Shared/Prototypes/EntityPrototype.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using Robust.Shared.EntitySerialization; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Localization; @@ -268,7 +269,7 @@ public static void EnsureCompExistsAndDeserialize(EntityUid entity, component = newComponent; } - if (context is not MapSerializationContext map) + if (context is not EntityDeserializer map) { serManager.CopyTo(data, ref component, context, notNullableOverride: true); return; diff --git a/Robust.Shared/Prototypes/TileAliasPrototype.cs b/Robust.Shared/Prototypes/TileAliasPrototype.cs index de75ba09f4f..54b805fa9b1 100644 --- a/Robust.Shared/Prototypes/TileAliasPrototype.cs +++ b/Robust.Shared/Prototypes/TileAliasPrototype.cs @@ -4,8 +4,7 @@ namespace Robust.Shared.Prototypes; /// -/// Prototype that represents an alias from one tile ID to another. -/// Tile alias prototypes, unlike tile prototypes, are implemented here, as they're really just fed to TileDefinitionManager. +/// Prototype that represents an alias from one tile ID to another. These are used when deserializing entities from yaml. /// [Prototype("tileAlias")] public sealed partial class TileAliasPrototype : IPrototype @@ -13,13 +12,13 @@ public sealed partial class TileAliasPrototype : IPrototype /// /// The target tile ID to alias to. /// - [DataField("target")] + [DataField] public string Target { get; private set; } = default!; /// /// The source tile ID (and the ID of this tile alias). /// [ViewVariables] - [IdDataFieldAttribute] + [IdDataField] public string ID { get; private set; } = default!; } diff --git a/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/TimeOffsetSerializer.cs b/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/TimeOffsetSerializer.cs index c7114cb8058..d697939092f 100644 --- a/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/TimeOffsetSerializer.cs +++ b/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/TimeOffsetSerializer.cs @@ -1,8 +1,8 @@ using System; using System.Globalization; +using Robust.Shared.EntitySerialization; using Robust.Shared.GameObjects; using Robust.Shared.IoC; -using Robust.Shared.Map; using Robust.Shared.Serialization.Manager; using Robust.Shared.Serialization.Markdown; using Robust.Shared.Serialization.Markdown.Validation; @@ -13,8 +13,8 @@ namespace Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; /// -/// This serializer offsets a timespan by the game's current time. If the entity is currently paused, the pause time -/// will also be accounted for, +/// This serializer offsets a timespan by the game's current time. If the entity is currently paused, the the offset will +/// instead be the time at which the entity was paused. /// /// /// Prototypes and pre map-init entities will always serialize this as zero. This is done mainly as a brute force fix @@ -29,14 +29,13 @@ public TimeSpan Read(ISerializationManager serializationManager, ValueDataNode n ISerializationContext? context = null, ISerializationManager.InstantiationDelegate? instanceProvider = null) { - if (context is not MapSerializationContext mapContext - || mapContext.WritingReadingPrototypes - || !mapContext.MapInitialized) - { + if (context is {WritingReadingPrototypes: true}) + return TimeSpan.Zero; + + if (context is not EntityDeserializer {CurrentReadingEntity.PostInit: true} ctx) return TimeSpan.Zero; - } - var timing = mapContext.Timing; + var timing = ctx.Timing; var seconds = double.Parse(node.Value, CultureInfo.InvariantCulture); return TimeSpan.FromSeconds(seconds) + timing.CurTime; } @@ -50,34 +49,29 @@ public ValidationNode Validate(ISerializationManager serializationManager, Value : new ErrorNode(node, "Failed parsing TimeSpan"); } - public DataNode Write(ISerializationManager serializationManager, TimeSpan value, IDependencyCollection dependencies, bool alwaysWrite = false, + public DataNode Write( + ISerializationManager serializationManager, + TimeSpan value, + IDependencyCollection dependencies, + bool alwaysWrite = false, ISerializationContext? context = null) { - if (context is not MapSerializationContext mapContext - || mapContext.WritingReadingPrototypes - || !mapContext.MapInitialized) + if (context is not EntitySerializer serializer + || serializer.WritingReadingPrototypes + || !serializer.EntMan.TryGetComponent(serializer.CurrentEntity, out MetaDataComponent? meta) + || meta.EntityLifeStage < EntityLifeStage.MapInitialized) { DebugTools.Assert(value == TimeSpan.Zero || context?.WritingReadingPrototypes != true, "non-zero time offsets in prototypes are not supported. If required, initialize offsets on map-init"); - return new ValueDataNode("0"); } - if (!mapContext.MapInitialized) - return new ValueDataNode("0"); - - if (mapContext.EntityManager.TryGetComponent(mapContext.CurrentWritingEntity, out MetaDataComponent? meta)) - { - // Here, PauseTime is a time -- not a duration. - if (meta.PauseTime != null) - value -= meta.PauseTime.Value; - } + // We subtract the current time, unless the entity is paused, in which case we subtract the time at which + // it was paused. + if (meta.PauseTime != null) + value -= meta.PauseTime.Value; else - { - // But here, PauseTime is a duration instead of a time - // What jolly fun. - value = value - mapContext.Timing.CurTime + mapContext.PauseTime; - } + value -= serializer.Timing.CurTime; return new ValueDataNode(value.TotalSeconds.ToString(CultureInfo.InvariantCulture)); } diff --git a/Robust.UnitTesting/RobustUnitTest.cs b/Robust.UnitTesting/RobustUnitTest.cs index 15df76fe6c3..1e0b8462d6f 100644 --- a/Robust.UnitTesting/RobustUnitTest.cs +++ b/Robust.UnitTesting/RobustUnitTest.cs @@ -13,6 +13,8 @@ using Robust.Shared.Console; using Robust.Shared.Containers; using Robust.Shared.ContentPack; +using Robust.Shared.EntitySerialization.Components; +using Robust.Shared.EntitySerialization.Systems; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Map; @@ -157,7 +159,6 @@ public void BaseSetup() systems.LoadExtraSystemType(); systems.LoadExtraSystemType(); systems.LoadExtraSystemType(); - systems.LoadExtraSystemType(); systems.LoadExtraSystemType(); systems.LoadExtraSystemType(); systems.LoadExtraSystemType(); @@ -179,12 +180,10 @@ public void BaseSetup() if (ExtraComponents != null) compFactory.RegisterTypes(ExtraComponents); - if (Project == UnitTestProject.Server) - { - compFactory.RegisterClass(); - compFactory.RegisterClass(); - } - else + compFactory.RegisterClass(); + compFactory.RegisterClass(); + + if (Project != UnitTestProject.Server) { compFactory.RegisterClass(); compFactory.RegisterClass(); diff --git a/Robust.UnitTesting/Server/GameObjects/Components/Transform_Test.cs b/Robust.UnitTesting/Server/GameObjects/Components/Transform_Test.cs index a7accf49d1c..a80ddfb00db 100644 --- a/Robust.UnitTesting/Server/GameObjects/Components/Transform_Test.cs +++ b/Robust.UnitTesting/Server/GameObjects/Components/Transform_Test.cs @@ -19,7 +19,7 @@ sealed class Transform_Test : RobustUnitTest { public override UnitTestProject Project => UnitTestProject.Server; - private IServerEntityManagerInternal EntityManager = default!; + private IEntityManager EntityManager = default!; private IMapManager MapManager = default!; private SharedTransformSystem XformSystem => EntityManager.System(); @@ -47,7 +47,7 @@ public void Setup() { IoCManager.Resolve().GenerateNetIds(); - EntityManager = IoCManager.Resolve(); + EntityManager = IoCManager.Resolve(); MapManager = IoCManager.Resolve(); IoCManager.Resolve().Initialize(); diff --git a/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs b/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs index ea5ac10ec7d..44a13a7c460 100644 --- a/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs +++ b/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs @@ -1,51 +1,58 @@ -using System; using System.Linq; +using System.Threading.Tasks; using NUnit.Framework; -using Robust.Server.GameObjects; using Robust.Shared.ContentPack; +using Robust.Shared.EntitySerialization.Systems; using Robust.Shared.GameObjects; -using Robust.Shared.IoC; -using Robust.Shared.Map; -using Robust.Shared.Physics; -using Robust.Shared.Physics.Dynamics; -using Robust.Shared.Prototypes; -using Robust.Shared.Serialization.Manager; +using Robust.Shared.Map.Components; using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.Utility; -using IgnoreUIRangeComponent = Robust.Shared.GameObjects.IgnoreUIRangeComponent; namespace Robust.UnitTesting.Server.Maps { [TestFixture] - public sealed partial class MapLoaderTest : RobustUnitTest + public sealed partial class MapLoaderTest : RobustIntegrationTest { private const string MapData = @" meta: - format: 2 - name: DemoStation - author: Space-Wizards - postmapinit: false + format: 7 + category: Grid + engineVersion: 238.0.0 + forkId: """" + forkVersion: """" + time: 12/22/2024 04:08:12 + entityCount: 3 +maps: [] grids: -- settings: - chunksize: 16 - tilesize: 1 - snapsize: 1 - chunks: [] +- 1 +orphans: +- 1 tilemap: {} entities: -- uid: 0 - components: - - parent: null - type: Transform - - index: 0 - type: MapGrid -- uid: 1 - type: MapDeserializeTest - components: - - type: MapDeserializeTest - foo: 3 - - parent: 0 - type: Transform +- proto: """" + entities: + - uid: 1 + mapInit: true + components: + - type: MetaData + - type: Transform + - type: MapGrid + chunks: {} + - type: Broadphase + - type: Physics + canCollide: False + - type: Fixtures + fixtures: {} + - type: MapSaveTileMap +- proto: MapDeserializeTest + entities: + - uid: 2 + mapInit: true + components: + - type: Transform + parent: 1 + - type: MapDeserializeTest + foo: 3 "; private const string Prototype = @" @@ -57,46 +64,35 @@ public sealed partial class MapLoaderTest : RobustUnitTest bar: 2 "; - protected override Type[]? ExtraComponents => new[] { typeof(MapDeserializeTestComponent), typeof(VisibilityComponent), typeof(IgnoreUIRangeComponent)}; - - [OneTimeSetUp] - public void Setup() - { - IoCManager.Resolve().Initialize(); - var resourceManager = IoCManager.Resolve(); - resourceManager.Initialize(null); - resourceManager.MountString("/TestMap.yml", MapData); - resourceManager.MountString("/EnginePrototypes/TestMapEntity.yml", Prototype); - - var protoMan = IoCManager.Resolve(); - protoMan.RegisterKind(typeof(EntityPrototype), typeof(EntityCategoryPrototype)); - - protoMan.LoadDirectory(new ("/EnginePrototypes")); - protoMan.LoadDirectory(new ("/Prototypes")); - protoMan.ResolveResults(); - } - [Test] - public void TestDataLoadPriority() + public async Task TestDataLoadPriority() { - // TODO: Fix after serv3 - // fix what? + var opts = new ServerIntegrationOptions() + { + ExtraPrototypes = Prototype + }; - var entMan = IoCManager.Resolve(); - entMan.System().CreateMap(out var mapId); + var server = StartServer(opts); + await server.WaitIdleAsync(); - var traversal = entMan.System(); + var resourceManager = server.ResolveDependency(); + resourceManager.MountString("/TestMap.yml", MapData); + + var traversal = server.System(); traversal.Enabled = false; - var mapLoad = IoCManager.Resolve().GetEntitySystem(); - if (!mapLoad.TryLoad(mapId, "/TestMap.yml", out var root) - || root.FirstOrDefault() is not { Valid:true } geid) + var mapLoad = server.System(); + + Entity? grid = default; + await server.WaitPost(() => { - Assert.Fail(); - return; - } + server.System().CreateMap(out var mapId); + Assert.That(mapLoad.TryLoadGrid(mapId, new ResPath("/TestMap.yml"), out grid)); + }); + + var geid = grid!.Value.Owner; - var entity = entMan.GetComponent(geid)._children.Single(); - var c = entMan.GetComponent(entity); + var entity = server.EntMan.GetComponent(geid)._children.Single(); + var c = server.EntMan.GetComponent(entity); traversal.Enabled = true; Assert.That(c.Bar, Is.EqualTo(2)); @@ -104,7 +100,7 @@ public void TestDataLoadPriority() Assert.That(c.Baz, Is.EqualTo(-1)); } - [DataDefinition] + [RegisterComponent] private sealed partial class MapDeserializeTestComponent : Component { [DataField("foo")] public int Foo { get; set; } = -1; diff --git a/Robust.UnitTesting/Shared/GameObjects/ContainerTests.cs b/Robust.UnitTesting/Shared/GameObjects/ContainerTests.cs index 2960529f50f..3a81bda415e 100644 --- a/Robust.UnitTesting/Shared/GameObjects/ContainerTests.cs +++ b/Robust.UnitTesting/Shared/GameObjects/ContainerTests.cs @@ -7,10 +7,12 @@ using Robust.Server.GameObjects; using Robust.Server.Player; using Robust.Shared.Containers; +using Robust.Shared.EntitySerialization.Systems; using Robust.Shared.GameObjects; using Robust.Shared.Map; using Robust.Shared.Network; using Robust.Shared.Timing; +using Robust.Shared.Utility; namespace Robust.UnitTesting.Shared.GameObjects { @@ -291,15 +293,16 @@ public async Task Container_DeserializeGrid_IsStillContained() await Task.WhenAll(server.WaitIdleAsync()); var sEntManager = server.ResolveDependency(); - var mapManager = server.ResolveDependency(); + var mapSys = sEntManager.System(); var sContainerSys = sEntManager.System(); var sMetadataSys = sEntManager.System(); + var path = new ResPath("container_test.yml"); await server.WaitAssertion(() => { // build the map sEntManager.System().CreateMap(out var mapIdOne); - Assert.That(mapManager.IsMapInitialized(mapIdOne), Is.True); + Assert.That(mapSys.IsInitialized(mapIdOne), Is.True); var containerEnt = sEntManager.SpawnEntity(null, new MapCoordinates(1, 1, mapIdOne)); sMetadataSys.SetEntityName(containerEnt, "ContainerEnt"); @@ -315,8 +318,8 @@ await server.WaitAssertion(() => // save the map var mapLoader = sEntManager.EntitySysManager.GetEntitySystem(); - mapLoader.SaveMap(mapIdOne, "container_test.yml"); - mapManager.DeleteMap(mapIdOne); + mapLoader.SaveMap(mapIdOne, path); + mapSys.DeleteMap(mapIdOne); }); // A few moments later... @@ -325,11 +328,10 @@ await server.WaitAssertion(() => await server.WaitAssertion(() => { var mapLoader = sEntManager.System(); - sEntManager.System().CreateMap(out var mapIdTwo); // load the map - mapLoader.Load(mapIdTwo, "container_test.yml"); - Assert.That(mapManager.IsMapInitialized(mapIdTwo), Is.True); // Map Initialize-ness is saved in the map file. + Assert.That(mapLoader.TryLoadMap(path, out var map, out _)); + Assert.That(mapSys.IsInitialized(map), Is.True); // Map Initialize-ness is saved in the map file. }); await server.WaitRunTicks(1); diff --git a/Robust.UnitTesting/Shared/GameObjects/Systems/AnchoredSystemTests.cs b/Robust.UnitTesting/Shared/GameObjects/Systems/AnchoredSystemTests.cs index 236c9469357..a80baf2ddfb 100644 --- a/Robust.UnitTesting/Shared/GameObjects/Systems/AnchoredSystemTests.cs +++ b/Robust.UnitTesting/Shared/GameObjects/Systems/AnchoredSystemTests.cs @@ -40,7 +40,6 @@ private static (ISimulation, Entity grid, MapCoordinates, Shar var mapManager = sim.Resolve(); - // Adds the map with id 1, and spawns entity 1 as the map entity. var testMapId = sim.CreateMap().MapId; var coords = new MapCoordinates(new Vector2(7, 7), testMapId); // Add grid 1, as the default grid to anchor things to. diff --git a/Robust.UnitTesting/Shared/Map/EntityCoordinates_Tests.cs b/Robust.UnitTesting/Shared/Map/EntityCoordinates_Tests.cs index f7f3c7bf3d4..4f14da17ab9 100644 --- a/Robust.UnitTesting/Shared/Map/EntityCoordinates_Tests.cs +++ b/Robust.UnitTesting/Shared/Map/EntityCoordinates_Tests.cs @@ -108,8 +108,6 @@ public void NoParent_OffsetZero() public void GetGridId_Map() { var entityManager = IoCManager.Resolve(); - var mapManager = IoCManager.Resolve(); - var mapEnt = entityManager.System().CreateMap(out var mapId); var newEnt = entityManager.CreateEntityUninitialized(null, new MapCoordinates(Vector2.Zero, mapId)); @@ -139,8 +137,6 @@ public void GetGridId_Grid() public void GetMapId_Map() { var entityManager = IoCManager.Resolve(); - var mapManager = IoCManager.Resolve(); - var mapEnt = entityManager.System().CreateMap(out var mapId); var newEnt = entityManager.CreateEntityUninitialized(null, new MapCoordinates(Vector2.Zero, mapId)); diff --git a/Robust.UnitTesting/Shared/Serialization/TypeSerializers/Custom/TimeOffsetSerializer.cs b/Robust.UnitTesting/Shared/Serialization/TypeSerializers/Custom/TimeOffsetSerializer.cs deleted file mode 100644 index 99a23fb66eb..00000000000 --- a/Robust.UnitTesting/Shared/Serialization/TypeSerializers/Custom/TimeOffsetSerializer.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.Globalization; -using System.Threading.Tasks; -using NUnit.Framework; -using Robust.Shared.GameObjects; -using Robust.Shared.Map; -using Robust.Shared.Serialization.Manager; -using Robust.Shared.Serialization.Markdown.Value; -using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; -using Robust.Shared.Timing; - -namespace Robust.UnitTesting.Shared.Serialization.TypeSerializers.Custom; - -[TestFixture] -public sealed class TimeOffsetSerializerTest : RobustIntegrationTest -{ - [Test] - public async Task SerializationTest() - { - var sim = StartServer(); - await sim.WaitIdleAsync(); - var serialization = sim.ResolveDependency(); - var timing = sim.ResolveDependency(); - var entMan = sim.ResolveDependency(); - var ctx = new MapSerializationContext(entMan, timing); - - await sim.WaitRunTicks(10); - Assert.That(timing.CurTime.TotalSeconds, Is.GreaterThan(0)); - - // "pause" a map at this time - var pauseTime = timing.CurTime; - await sim.WaitRunTicks(10); - - // Spawn a paused entity - var uid = entMan.SpawnEntity(null, MapCoordinates.Nullspace); - var metaSys = entMan.System(); - metaSys.SetEntityPaused(uid, true); - - await sim.WaitRunTicks(10); - Assert.That(metaSys.GetPauseTime(uid).TotalSeconds, Is.GreaterThan(0)); - - var curTime = timing.CurTime; - var dataTime = curTime + TimeSpan.FromSeconds(2); - ctx.PauseTime = curTime - pauseTime; - var entPauseDuration = metaSys.GetPauseTime(uid); - - Assert.That(curTime.TotalSeconds, Is.GreaterThan(0)); - Assert.That(entPauseDuration.TotalSeconds, Is.GreaterThan(0)); - Assert.That(ctx.PauseTime.TotalSeconds, Is.GreaterThan(0)); - - Assert.That(ctx.PauseTime, Is.Not.EqualTo(curTime)); - Assert.That(ctx.PauseTime, Is.Not.EqualTo(entPauseDuration)); - Assert.That(entPauseDuration, Is.Not.EqualTo(curTime)); - - // time gets properly offset when reading a post-init map - ctx.MapInitialized = true; - var node = serialization.WriteValue(dataTime, context: ctx); - var value = ((ValueDataNode) node).Value; - var expected = (dataTime - curTime + ctx.PauseTime).TotalSeconds.ToString(CultureInfo.InvariantCulture); - Assert.That(value, Is.EqualTo(expected)); - - // When writing paused entities, it will instead use the entity's pause time: - ctx.CurrentWritingEntity = uid; - node = serialization.WriteValue(dataTime, context: ctx); - value = ((ValueDataNode) node).Value; - expected = (dataTime - curTime + entPauseDuration).TotalSeconds.ToString(CultureInfo.InvariantCulture); - Assert.That(value, Is.EqualTo(expected)); - - // Uninitialized maps always serialize as zero - ctx.MapInitialized = false; - node = serialization.WriteValue(dataTime, context: ctx); - value = ((ValueDataNode) node).Value; - Assert.That(value, Is.EqualTo("0")); - - ctx.CurrentWritingEntity = null; - node = serialization.WriteValue(dataTime, context: ctx); - value = ((ValueDataNode) node).Value; - Assert.That(value, Is.EqualTo("0")); - } - - [Test] - public async Task DeserializationTest() - { - var sim = StartServer(); - await sim.WaitIdleAsync(); - - var serialization = sim.ResolveDependency(); - - await sim.WaitRunTicks(10); - - var timing = sim.ResolveDependency(); - var entMan = sim.ResolveDependency(); - var ctx = new MapSerializationContext(entMan, timing); - var curTime = timing.CurTime; - var node = new ValueDataNode("2"); - - // time gets properly offset when reading a post-init map - ctx.MapInitialized = true; - var time = serialization.Read(node, ctx); - Assert.That(time, Is.EqualTo(curTime + TimeSpan.FromSeconds(2))); - - // pre-init maps read time offsets as 0. - ctx.MapInitialized = false; - time = serialization.Read(node, ctx); - Assert.That(time, Is.EqualTo(TimeSpan.Zero)); - - // Same goes for no-context reads - time = serialization.Read(node); - Assert.That(time, Is.EqualTo(TimeSpan.Zero)); - } -} From cb170edfddcb0082613d97e7922e688078ff236d Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Mon, 23 Dec 2024 12:41:52 +1300 Subject: [PATCH 02/27] test fixes --- RELEASE-NOTES.md | 5 +- .../EntitySerialization/EntityDeserializer.cs | 117 +++++++++++++----- .../Systems/MapLoaderSystem.Load.cs | 4 +- .../GameObjects/EntitySystemManager.cs | 12 +- .../GameObjects/IEntitySystemManager.cs | 6 + 5 files changed, 106 insertions(+), 38 deletions(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index dea41456427..07586a7ce96 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -37,7 +37,7 @@ END TEMPLATE--> * `ITileDefinitionManager.AssignAlias` and general tile alias functionality has been removed. `TileAliasPrototype` still exist, but are only used during entity deserialization. * `IMapManager.AddUninitializedMap` has been removed. Use the map-init options on `CreateMap()` instead. -* Re-using a MapId will now log a warning. This may cause some integertion tests to fail if they are configured to fail +* Re-using a MapId will now log a warning. This may cause some integration tests to fail if they are configured to fail when warnings are logged. * The minimum supported map format / version has been increased from 2 to 3. * The server-side `MapLoaderSystem` and associated classes & structs has been moved to `Robust.Shared`, and has been significantly modified. @@ -50,6 +50,7 @@ END TEMPLATE--> ### New features * The current map format/version has increased from 6 to 7 and now contains more information to try support serialization of maps with null-space entities and full game saves. +* `IEntitySystemManager` now provides access to the system `IDependencyCollection`. ### Bugfixes @@ -57,7 +58,7 @@ END TEMPLATE--> ### Other -* `MapChangedEvent` has been marked as obsolet, and should be replaced with `MapCreatedEvent` and `MapRemovedEvent. +* `MapChangedEvent` has been marked as obsolete, and should be replaced with `MapCreatedEvent` and `MapRemovedEvent. ### Internal diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index 73f14b1a2dc..fe2a8242a18 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -29,7 +29,7 @@ namespace Robust.Shared.EntitySerialization; /// This class provides methods for deserializing entities from yaml. It provides some more control over /// serialization than the methods provided by . /// -internal sealed class EntityDeserializer : ISerializationContext, IEntityLoadContext, +public sealed class EntityDeserializer : ISerializationContext, IEntityLoadContext, ITypeSerializer { private const int BackwardsVersion = 3; @@ -55,10 +55,15 @@ internal sealed class EntityDeserializer : ISerializationContext, IEntityLoadCon public readonly MappingDataNode Data; /// - /// Subset of the file's relevant to each entity. + /// Subset of the file's relevant to each entity, indexed by their allocated EntityUids /// public readonly Dictionary Entities = new(); + /// + /// Variant of indexed by the entity's yaml id. + /// + public readonly Dictionary YamlEntities = new(); + /// /// Entity data grouped by their entity prototype id. Any entities without a prototype or with an invalid or /// deleted prototypes use an empty string. @@ -70,6 +75,9 @@ internal sealed class EntityDeserializer : ISerializationContext, IEntityLoadCon public readonly LoadResult Result = new(); public readonly Dictionary TileMap = new(); public readonly Dictionary UidMap = new(); + public readonly List MapYamlIds = new(); + public readonly List GridYamlIds = new(); + public readonly List OrphanYamlIds = new(); public readonly Dictionary RenamedPrototypes; public readonly HashSet DeletedPrototypes; @@ -120,11 +128,11 @@ public EntityDeserializer( public bool WritingReadingPrototypes { get; private set; } /// - /// This processes some of the data in , including extracting the metdata, tile-map, - /// validate that all referenced entity prototypes exists, grouping entity data by their prototypes. + /// This processes some of the data in , including extracting the metadata, tile-map, + /// validating that all referenced entity prototypes exists, and generating collections for accessing entity data. /// /// Returns false if the entity data cannot be processed - public bool ProcessData() + public bool TryProcessData() { ReadMetadata(); if (Result.Version < BackwardsVersion) @@ -139,6 +147,7 @@ public bool ProcessData() ReadEntities(); ReadTileMap(); + ReadMapsAndGrids(); return true; } @@ -153,8 +162,8 @@ public void CreateEntities() // Load the prototype data onto entities, e.g. transform parents, etc. LoadEntities(); - // Read the list of maps, grids, and orphan entities - ReadMapsAndGrids(); + // Get the lists of maps, grids, and orphan entities + GetMapsAndGrids(); // grids prior to engine v175 might've been serialized with empty chunks which now throw debug asserts. RemoveEmptyChunks(); @@ -162,6 +171,9 @@ public void CreateEntities() // Assign MapSaveTileMapComponent to all read grids. This is used to avoid large file diffs if the tile map changes. StoreGridTileMap(); + // Separate maps and orphaned entities out from the "true" null-space entities. + ProcessNullspaceEntities(); + if (Options.AssignMapids) AssignMapIds(); @@ -177,7 +189,6 @@ public void StartEntities() ValidateMapIds(); PauseMaps(); BuildEntityHierarchy(); - ProcessNullspaceEntities(); StartEntitiesInternal(); SetMapInitLifestage(); SetPaused(); @@ -309,7 +320,9 @@ private void ReadEntities() ? pausedNode.AsBool() : !postInit; - protoData.Add(new EntData(yamlId, entityNode, postInit, paused, deletedPrototype)); + var entData = new EntData(yamlId, entityNode, postInit, paused, deletedPrototype); + protoData.Add(entData); + YamlEntities.Add(yamlId, entData); } } } @@ -343,6 +356,7 @@ private void ReadEntitiesV3() var protoData = Prototypes.GetOrNew(proto?.ID ?? string.Empty); var entData = new EntData(yamlId, entityNode, PostInit: !preInit, Paused: preInit, toDelete); protoData.Add(entData); + YamlEntities.Add(yamlId, entData); } } @@ -351,7 +365,6 @@ private void ReadEntitiesFallback() var metadata = Data.Get("meta"); var preInit = metadata.TryGet("postmapinit", out var mapInitNode) && !mapInitNode.AsBool(); - // entities are grouped by prototype. var prototypeGroups = Data.Get("entities"); foreach (var protoGroup in prototypeGroups.Cast()) { @@ -379,7 +392,9 @@ private void ReadEntitiesFallback() foreach (var entityNode in entities.Cast()) { var yamlId = entityNode.Get("uid").AsInt(); - protoData.Add(new EntData(yamlId, entityNode, PostInit: !preInit, Paused: preInit, ToDelete: deletedPrototype)); + var entData = new EntData(yamlId, entityNode, PostInit: !preInit, Paused: preInit, ToDelete: deletedPrototype); + protoData.Add(entData); + YamlEntities.Add(yamlId, entData); } } } @@ -443,6 +458,33 @@ private void AllocateEntities() _log.Debug($"Allocated {Entities.Count} entities in {_stopwatch.Elapsed}"); } + private void ReadMapsAndGrids() + { + if (Result.Version < 7) + return; + + var maps = Data.Get("maps"); + foreach (var node in maps) + { + var yamlId = ((ValueDataNode) node).AsInt(); + MapYamlIds.Add(yamlId); + } + + var grids = Data.Get("grids"); + foreach (var node in grids) + { + var yamlId = ((ValueDataNode) node).AsInt(); + GridYamlIds.Add(yamlId); + } + + var orphans = Data.Get("orphans"); + foreach (var node in orphans) + { + var yamlId = ((ValueDataNode) node).AsInt(); + OrphanYamlIds.Add(yamlId); + } + } + private void LoadEntities() { _stopwatch.Restart(); @@ -491,11 +533,23 @@ private void LoadEntity(EntityUid uid, MetaDataComponent meta, MappingDataNode e var compType = reg.Type; if (prototype?.Components != null && prototype.Components.TryGetValue(value, out var protoData)) { - datanode = _seriMan.PushCompositionWithGenericNode( - compType, - [protoData.Mapping], - datanode, - this); + // Previously this method used generic composition pushing. I.e.: + /* + datanode = ISerializationManager.PushCompositionWithGenericNode( + compType, + [protoData.Mapping], + datanode, + this); + */ + // However, I don't think this is what we want to do here. I.e., we want to ignore things like the + // AlwaysPushInheritanceAttribute. Complex inheritance pushing should have already been done when + // creating the proto data. Now we want to directly compare the serialized entity with the prototype, + // and we don't want data in the prototype to be pushed into the entity. + // + // If we want to support this, we need to change entity serialization so that it doesn't do a simple + // diff with respect to the prototype data and instead does some kind of inheritance subtraction / removal. + + datanode = _seriMan.CombineMappings(protoData.Mapping, datanode); } CurrentComponent = value; @@ -513,18 +567,16 @@ private void LoadEntity(EntityUid uid, MetaDataComponent meta, MappingDataNode e meta.LastComponentRemoved = Timing.CurTick; } - private void ReadMapsAndGrids() + private void GetMapsAndGrids() { if (Result.Version < 7) { - ReadMapsAndGridsFallback(); + GetMapsAndGridsFallback(); return; } - var maps = Data.Get("maps"); - foreach (var node in maps) + foreach (var yamlId in MapYamlIds) { - var yamlId = ((ValueDataNode) node).AsInt(); var uid = UidMap[yamlId]; if (_mapQuery.TryComp(uid, out var map)) { @@ -535,10 +587,8 @@ private void ReadMapsAndGrids() _log.Error($"Missing map entity: {EntMan.ToPrettyString(uid)}"); } - var grids = Data.Get("grids"); - foreach (var node in grids) + foreach (var yamlId in GridYamlIds) { - var yamlId = ((ValueDataNode) node).AsInt(); var uid = UidMap[yamlId]; if (_gridQuery.TryComp(uid, out var grid)) Result.Grids.Add((uid, grid)); @@ -546,12 +596,9 @@ private void ReadMapsAndGrids() _log.Error($"Missing grid entity: {EntMan.ToPrettyString(uid)}"); } - var orphans = Data.Get("orphans"); - foreach (var node in orphans) + foreach (var yamlId in OrphanYamlIds) { - var yamlId = ((ValueDataNode) node).AsInt(); var uid = UidMap[yamlId]; - if (EntMan.HasComponent(uid) || _xformQuery.Comp(uid).ParentUid.IsValid()) _log.Error($"Entity {EntMan.ToPrettyString(uid)} was incorrectly labelled as an orphan?"); else @@ -567,7 +614,7 @@ public void AssignMapIds() } } - private void ReadMapsAndGridsFallback() + private void GetMapsAndGridsFallback() { foreach (var uid in Result.Entities) { @@ -702,6 +749,18 @@ private void AdoptGrids() var map = _map.CreateUninitializedMap(); _map.AssignMapId(map); + // We intentionally do this after maps have been given the LoadedMapComponent, so this map will not have it. + // vague justification is that this entity wasn't actually deserialized from the file, and shouldn't + // contain any non-default data. + + // But the real reason is that this is just how it used to work due to shitty code that never properly + // distinguished between grid & map files, and checks for this component after deserialization to check whether + // the file was a grid or map. + + // So we still support code that tries to load a file without knowing what's in it, but unless the options + // disable it, the default behaviour is to log an error in this situation. This is meant to try ensure that + // people use the `TryLoadGrid` method when appropriate. + Result.Entities.Add(map); Result.Maps.Add(map); Result.Orphans.Remove(grid.Owner); diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs index 52453f8d0f1..7dcf98c82bf 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs @@ -61,7 +61,7 @@ public bool TryLoadEntities(ResPath file, [NotNullWhen(true)] out LoadResult? re ev.RenamedPrototypes, ev.DeletedPrototypes); - if (!deserializer.ProcessData()) + if (!deserializer.TryProcessData()) { Log.Debug($"Failed to process entity data in {file}"); return false; @@ -353,7 +353,7 @@ private bool TryGetReader(ResPath resPath, [NotNullWhen(true)] out TextReader? r return false; } - private bool TryReadFile(ResPath file, [NotNullWhen(true)] out MappingDataNode? data) + public bool TryReadFile(ResPath file, [NotNullWhen(true)] out MappingDataNode? data) { var resPath = file.ToRootedPath(); data = null; diff --git a/Robust.Shared/GameObjects/EntitySystemManager.cs b/Robust.Shared/GameObjects/EntitySystemManager.cs index 015b90d3025..0ab9e3fe044 100644 --- a/Robust.Shared/GameObjects/EntitySystemManager.cs +++ b/Robust.Shared/GameObjects/EntitySystemManager.cs @@ -22,11 +22,11 @@ namespace Robust.Shared.GameObjects { public sealed class EntitySystemManager : IEntitySystemManager, IPostInjectInit { - [IoC.Dependency] private readonly IReflectionManager _reflectionManager = default!; - [IoC.Dependency] private readonly IEntityManager _entityManager = default!; - [IoC.Dependency] private readonly ProfManager _profManager = default!; - [IoC.Dependency] private readonly IDependencyCollection _dependencyCollection = default!; - [IoC.Dependency] private readonly ILogManager _logManager = default!; + [Dependency] private readonly IReflectionManager _reflectionManager = default!; + [Dependency] private readonly IEntityManager _entityManager = default!; + [Dependency] private readonly ProfManager _profManager = default!; + [Dependency] private readonly IDependencyCollection _dependencyCollection = default!; + [Dependency] private readonly ILogManager _logManager = default!; #if EXCEPTION_TOLERANCE [Dependency] private readonly IRuntimeLog _runtimeLog = default!; @@ -35,6 +35,8 @@ public sealed class EntitySystemManager : IEntitySystemManager, IPostInjectInit private ISawmill _sawmill = default!; internal DependencyCollection SystemDependencyCollection = default!; + public IDependencyCollection DependencyCollection => SystemDependencyCollection; + private readonly List _systemTypes = new(); private static readonly Histogram _tickUsageHistogram = Metrics.CreateHistogram("robust_entity_systems_update_usage", diff --git a/Robust.Shared/GameObjects/IEntitySystemManager.cs b/Robust.Shared/GameObjects/IEntitySystemManager.cs index d363f89f411..d4424483cbe 100644 --- a/Robust.Shared/GameObjects/IEntitySystemManager.cs +++ b/Robust.Shared/GameObjects/IEntitySystemManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using Robust.Shared.IoC; using Robust.Shared.IoC.Exceptions; namespace Robust.Shared.GameObjects @@ -131,5 +132,10 @@ void Resolve([NotNull] ref T1? instance1, [NotNull] ref T2? inst IEnumerable GetEntitySystemTypes(); bool TryGetEntitySystem(Type sysType, [NotNullWhen(true)] out object? system); object GetEntitySystem(Type sysType); + + /// + /// Dependency collection that contains all the loaded systems. + /// + public IDependencyCollection DependencyCollection { get; } } } From 530783ea1694d5786284019e9e3c35fddc03cf39 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Mon, 23 Dec 2024 16:22:18 +1300 Subject: [PATCH 03/27] ISerializationManager tweaks --- .../Manager/ISerializationManager.cs | 7 ++++ .../SerializationManager.Composition.cs | 22 ++++------- .../Markdown/Mapping/MappingDataNode.cs | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/Robust.Shared/Serialization/Manager/ISerializationManager.cs b/Robust.Shared/Serialization/Manager/ISerializationManager.cs index 22fd2c56e17..247ab142d2b 100644 --- a/Robust.Shared/Serialization/Manager/ISerializationManager.cs +++ b/Robust.Shared/Serialization/Manager/ISerializationManager.cs @@ -3,6 +3,7 @@ using JetBrains.Annotations; using Robust.Shared.Reflection; using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Mapping; using Robust.Shared.Serialization.Markdown.Validation; using Robust.Shared.Serialization.TypeSerializers.Interfaces; @@ -440,6 +441,12 @@ public TNode PushCompositionWithGenericNode(Type type, TNode[] parents, T return (TNode) PushComposition(type, parents, child, context); } + /// + /// Simple inheritance pusher clones data and overrides a parent's values with + /// the child's. + /// + MappingDataNode CombineMappings(MappingDataNode child, MappingDataNode parent); + #endregion public bool TryGetVariableType(Type type, string variableName, [NotNullWhen(true)] out Type? variableType); diff --git a/Robust.Shared/Serialization/Manager/SerializationManager.Composition.cs b/Robust.Shared/Serialization/Manager/SerializationManager.Composition.cs index bdb40e6df3a..7011d4fae3c 100644 --- a/Robust.Shared/Serialization/Manager/SerializationManager.Composition.cs +++ b/Robust.Shared/Serialization/Manager/SerializationManager.Composition.cs @@ -95,7 +95,7 @@ private PushCompositionDelegate GetOrCreatePushCompositionDelegate(Type type, Da Expression.Convert(parentParam, nodeType)), MappingDataNode => Expression.Call( instanceConst, - nameof(PushInheritanceMapping), + nameof(CombineMappings), Type.EmptyTypes, Expression.Convert(childParam, nodeType), Expression.Convert(parentParam, nodeType)), @@ -117,32 +117,26 @@ private SequenceDataNode PushInheritanceSequence(SequenceDataNode child, Sequenc //todo implement different inheritancebehaviours for yamlfield // I have NFI what this comment means. - var result = new SequenceDataNode(child.Count + parent.Count); + var result = child.Copy(); foreach (var entry in parent) { - result.Add(entry); - } - foreach (var entry in child) - { - result.Add(entry); + result.Add(entry.Copy()); } return result; } - private MappingDataNode PushInheritanceMapping(MappingDataNode child, MappingDataNode parent) + public MappingDataNode CombineMappings(MappingDataNode child, MappingDataNode parent) { //todo implement different inheritancebehaviours for yamlfield // I have NFI what this comment means. + // I still don't know what it means, but if it's talking about the always/never push inheritance attributes, + // make sure it doesn't break entity serialization. - var result = new MappingDataNode(child.Count + parent.Count); + var result = child.Copy(); foreach (var (k, v) in parent) { - result[k] = v; - } - foreach (var (k, v) in child) - { - result[k] = v; + result.TryAddCopy(k, v); } return result; diff --git a/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs b/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs index 598c3d1b046..777eeb2a96f 100644 --- a/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs +++ b/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.InteropServices; using System.Threading; using Robust.Shared.Serialization.Markdown.Value; using Robust.Shared.Utility; @@ -272,6 +273,26 @@ public override MappingDataNode Copy() return newMapping; } + /// + /// Variant of that doesn't clone the keys or values. + /// + public MappingDataNode ShallowClone() + { + var newMapping = new MappingDataNode(_children.Count) + { + Tag = Tag, + Start = Start, + End = End + }; + + foreach (var (key, val) in _list) + { + newMapping.Add(key, val); + } + + return newMapping; + } + /// /// Variant of that will recursively call except rather than only checking equality. /// @@ -396,5 +417,21 @@ public bool Remove(KeyValuePair item) public int Count => _children.Count; public bool IsReadOnly => false; + + + public bool TryAdd(DataNode key, DataNode value) + { + return _children.TryAdd(key, value); + } + + public bool TryAddCopy(DataNode key, DataNode value) + { + ref var entry = ref CollectionsMarshal.GetValueRefOrAddDefault(_children, key, out var exists); + if (exists) + return false; + + entry = value.Copy(); + return true; + } } } From 2074ac315158b3f21e27286e02ca4c923b8b4787 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Mon, 23 Dec 2024 16:32:54 +1300 Subject: [PATCH 04/27] Fix component composition --- .../EntitySerialization/EntityDeserializer.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index fe2a8242a18..2bc732e9d41 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -520,8 +520,6 @@ private void LoadEntity(EntityUid uid, MetaDataComponent meta, MappingDataNode e CurrentReadingEntityComponents.EnsureCapacity(componentList.Count); foreach (var compData in componentList.Cast()) { - var datanode = compData.Copy(); - datanode.Remove("type"); var value = ((ValueDataNode)compData["type"]).Value; if (!_factory.TryGetRegistration(value, out var reg)) { @@ -531,6 +529,7 @@ private void LoadEntity(EntityUid uid, MetaDataComponent meta, MappingDataNode e } var compType = reg.Type; + MappingDataNode datanode; if (prototype?.Components != null && prototype.Components.TryGetValue(value, out var protoData)) { // Previously this method used generic composition pushing. I.e.: @@ -549,9 +548,15 @@ private void LoadEntity(EntityUid uid, MetaDataComponent meta, MappingDataNode e // If we want to support this, we need to change entity serialization so that it doesn't do a simple // diff with respect to the prototype data and instead does some kind of inheritance subtraction / removal. - datanode = _seriMan.CombineMappings(protoData.Mapping, datanode); + datanode = _seriMan.CombineMappings(compData, protoData.Mapping); + } + else + { + datanode = compData.ShallowClone(); } + + datanode.Remove("type"); CurrentComponent = value; CurrentReadingEntityComponents[value] = (IComponent) _seriMan.Read(compType, datanode, this)!; CurrentComponent = null; From eee3756a719425cbbf59307eddca35b6fd43ecef Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Mon, 23 Dec 2024 16:41:24 +1300 Subject: [PATCH 05/27] Try fix entity deserialization component composition --- .../EntitySystems/MapLoaderSystem.cs | 29 ++++++++++++--- .../Manager/ISerializationManager.cs | 7 ++++ .../SerializationManager.Composition.cs | 22 ++++------- .../Markdown/Mapping/MappingDataNode.cs | 37 +++++++++++++++++++ 4 files changed, 76 insertions(+), 19 deletions(-) diff --git a/Robust.Server/GameObjects/EntitySystems/MapLoaderSystem.cs b/Robust.Server/GameObjects/EntitySystems/MapLoaderSystem.cs index 98644cb6128..a1fa08bc229 100644 --- a/Robust.Server/GameObjects/EntitySystems/MapLoaderSystem.cs +++ b/Robust.Server/GameObjects/EntitySystems/MapLoaderSystem.cs @@ -545,8 +545,6 @@ private void LoadEntity(EntityUid uid, MappingDataNode data, MetaDataComponent m _context.CurrentReadingEntityComponents.EnsureCapacity(componentList.Count); foreach (var compData in componentList.Cast()) { - var datanode = compData.Copy(); - datanode.Remove("type"); var value = ((ValueDataNode)compData["type"]).Value; if (!_factory.TryGetRegistration(value, out var reg)) { @@ -555,15 +553,36 @@ private void LoadEntity(EntityUid uid, MappingDataNode data, MetaDataComponent m continue; } + + MappingDataNode datanode; var compType = reg.Type; if (prototype?.Components != null && prototype.Components.TryGetValue(value, out var protData)) { - datanode = - _serManager.PushCompositionWithGenericNode( + // Previously this method used generic composition pushing. I.e.: + /* + datanode = _serManager.PushCompositionWithGenericNode( compType, - new[] { protData.Mapping }, datanode, _context); + new[] {protData.Mapping}, + datanode, + _context); + */ + // However, I don't think this is what we want to do here. I.e., we want to ignore things like the + // AlwaysPushInheritanceAttribute. Complex inheritance pushing should have already been done when + // creating the proto data. Now we just want to override the prototype information with the + // serialized data. + // + // If we do ever want to support this, we need to change entity serialization so that it doesn't do + // a simple diff with respect to the prototype data and instead does some kind of inheritance + // subtraction / removal. + + datanode = _serManager.CombineMappings(compData, protData.Mapping); + } + else + { + datanode = compData.ShallowClone(); } + datanode.Remove("type"); _context.CurrentComponent = value; _context.CurrentReadingEntityComponents[value] = (IComponent) _serManager.Read(compType, datanode, _context)!; _context.CurrentComponent = null; diff --git a/Robust.Shared/Serialization/Manager/ISerializationManager.cs b/Robust.Shared/Serialization/Manager/ISerializationManager.cs index 22fd2c56e17..247ab142d2b 100644 --- a/Robust.Shared/Serialization/Manager/ISerializationManager.cs +++ b/Robust.Shared/Serialization/Manager/ISerializationManager.cs @@ -3,6 +3,7 @@ using JetBrains.Annotations; using Robust.Shared.Reflection; using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Mapping; using Robust.Shared.Serialization.Markdown.Validation; using Robust.Shared.Serialization.TypeSerializers.Interfaces; @@ -440,6 +441,12 @@ public TNode PushCompositionWithGenericNode(Type type, TNode[] parents, T return (TNode) PushComposition(type, parents, child, context); } + /// + /// Simple inheritance pusher clones data and overrides a parent's values with + /// the child's. + /// + MappingDataNode CombineMappings(MappingDataNode child, MappingDataNode parent); + #endregion public bool TryGetVariableType(Type type, string variableName, [NotNullWhen(true)] out Type? variableType); diff --git a/Robust.Shared/Serialization/Manager/SerializationManager.Composition.cs b/Robust.Shared/Serialization/Manager/SerializationManager.Composition.cs index bdb40e6df3a..7011d4fae3c 100644 --- a/Robust.Shared/Serialization/Manager/SerializationManager.Composition.cs +++ b/Robust.Shared/Serialization/Manager/SerializationManager.Composition.cs @@ -95,7 +95,7 @@ private PushCompositionDelegate GetOrCreatePushCompositionDelegate(Type type, Da Expression.Convert(parentParam, nodeType)), MappingDataNode => Expression.Call( instanceConst, - nameof(PushInheritanceMapping), + nameof(CombineMappings), Type.EmptyTypes, Expression.Convert(childParam, nodeType), Expression.Convert(parentParam, nodeType)), @@ -117,32 +117,26 @@ private SequenceDataNode PushInheritanceSequence(SequenceDataNode child, Sequenc //todo implement different inheritancebehaviours for yamlfield // I have NFI what this comment means. - var result = new SequenceDataNode(child.Count + parent.Count); + var result = child.Copy(); foreach (var entry in parent) { - result.Add(entry); - } - foreach (var entry in child) - { - result.Add(entry); + result.Add(entry.Copy()); } return result; } - private MappingDataNode PushInheritanceMapping(MappingDataNode child, MappingDataNode parent) + public MappingDataNode CombineMappings(MappingDataNode child, MappingDataNode parent) { //todo implement different inheritancebehaviours for yamlfield // I have NFI what this comment means. + // I still don't know what it means, but if it's talking about the always/never push inheritance attributes, + // make sure it doesn't break entity serialization. - var result = new MappingDataNode(child.Count + parent.Count); + var result = child.Copy(); foreach (var (k, v) in parent) { - result[k] = v; - } - foreach (var (k, v) in child) - { - result[k] = v; + result.TryAddCopy(k, v); } return result; diff --git a/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs b/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs index 598c3d1b046..777eeb2a96f 100644 --- a/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs +++ b/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.InteropServices; using System.Threading; using Robust.Shared.Serialization.Markdown.Value; using Robust.Shared.Utility; @@ -272,6 +273,26 @@ public override MappingDataNode Copy() return newMapping; } + /// + /// Variant of that doesn't clone the keys or values. + /// + public MappingDataNode ShallowClone() + { + var newMapping = new MappingDataNode(_children.Count) + { + Tag = Tag, + Start = Start, + End = End + }; + + foreach (var (key, val) in _list) + { + newMapping.Add(key, val); + } + + return newMapping; + } + /// /// Variant of that will recursively call except rather than only checking equality. /// @@ -396,5 +417,21 @@ public bool Remove(KeyValuePair item) public int Count => _children.Count; public bool IsReadOnly => false; + + + public bool TryAdd(DataNode key, DataNode value) + { + return _children.TryAdd(key, value); + } + + public bool TryAddCopy(DataNode key, DataNode value) + { + ref var entry = ref CollectionsMarshal.GetValueRefOrAddDefault(_children, key, out var exists); + if (exists) + return false; + + entry = value.Copy(); + return true; + } } } From 9ee0293f8be764341c18b5f2611d20a7ba644bc3 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Mon, 23 Dec 2024 16:48:52 +1300 Subject: [PATCH 06/27] comments --- Robust.Shared/EntitySerialization/EntityDeserializer.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index 2bc732e9d41..d5e193ef439 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -542,11 +542,12 @@ private void LoadEntity(EntityUid uid, MetaDataComponent meta, MappingDataNode e */ // However, I don't think this is what we want to do here. I.e., we want to ignore things like the // AlwaysPushInheritanceAttribute. Complex inheritance pushing should have already been done when - // creating the proto data. Now we want to directly compare the serialized entity with the prototype, - // and we don't want data in the prototype to be pushed into the entity. + // creating the proto data. Now we just want to override the prototype information with the + // serialized data. // - // If we want to support this, we need to change entity serialization so that it doesn't do a simple - // diff with respect to the prototype data and instead does some kind of inheritance subtraction / removal. + // If we do ever want to support this, we need to change entity serialization so that it doesn't do + // a simple diff with respect to the prototype data and instead does some kind of inheritance + // subtraction / removal. datanode = _seriMan.CombineMappings(compData, protoData.Mapping); } From 043ac1a1ac110e9bc4291c1bb0e36637f87c28e0 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Mon, 23 Dec 2024 16:51:21 +1300 Subject: [PATCH 07/27] CL --- RELEASE-NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index a0daaf128e8..bde24625491 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -43,7 +43,7 @@ END TEMPLATE--> ### Bugfixes -*None yet* +* Fixed entity deserialization for components with a data fields that have a AlwaysPushInheritance Attribute ### Other From 75b6ebd9641407d78bd69f904f6e3402ce99f4de Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Mon, 23 Dec 2024 17:15:26 +1300 Subject: [PATCH 08/27] error preinit --- RELEASE-NOTES.md | 1 + Robust.Shared/GameObjects/EntitySystemManager.cs | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 07586a7ce96..35eec3e2d7d 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -64,6 +64,7 @@ END TEMPLATE--> *None yet* + ## 238.0.0 ### Breaking changes diff --git a/Robust.Shared/GameObjects/EntitySystemManager.cs b/Robust.Shared/GameObjects/EntitySystemManager.cs index 0ab9e3fe044..b5257f011f1 100644 --- a/Robust.Shared/GameObjects/EntitySystemManager.cs +++ b/Robust.Shared/GameObjects/EntitySystemManager.cs @@ -35,7 +35,17 @@ public sealed class EntitySystemManager : IEntitySystemManager, IPostInjectInit private ISawmill _sawmill = default!; internal DependencyCollection SystemDependencyCollection = default!; - public IDependencyCollection DependencyCollection => SystemDependencyCollection; + + public IDependencyCollection DependencyCollection + { + get + { + if (_initialized) + return SystemDependencyCollection; + + throw new InvalidOperationException($"{nameof(EntitySystemManager)} has been initialized."); + } + } private readonly List _systemTypes = new(); From 0de702b0a950dfcec366f05e039eb32fde296f72 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Mon, 23 Dec 2024 17:15:49 +1300 Subject: [PATCH 09/27] a --- Robust.Shared/GameObjects/EntitySystemManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Robust.Shared/GameObjects/EntitySystemManager.cs b/Robust.Shared/GameObjects/EntitySystemManager.cs index b5257f011f1..db09a486e94 100644 --- a/Robust.Shared/GameObjects/EntitySystemManager.cs +++ b/Robust.Shared/GameObjects/EntitySystemManager.cs @@ -43,7 +43,7 @@ public IDependencyCollection DependencyCollection if (_initialized) return SystemDependencyCollection; - throw new InvalidOperationException($"{nameof(EntitySystemManager)} has been initialized."); + throw new InvalidOperationException($"{nameof(EntitySystemManager)} has not been initialized."); } } From 164cb1a3bff71b3e21dd4ac1403ba24a544c2529 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Mon, 23 Dec 2024 17:33:59 +1300 Subject: [PATCH 10/27] cleanup --- Robust.Shared/EntitySerialization/EntitySerializer.cs | 4 ++-- Robust.Shared/EntitySerialization/LoadResult.cs | 2 -- Robust.Shared/EntitySerialization/Options.cs | 6 ++++-- Robust.Shared/EntitySerialization/SerializationEnums.cs | 2 +- Robust.Shared/GameObjects/EntityManager.cs | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Robust.Shared/EntitySerialization/EntitySerializer.cs b/Robust.Shared/EntitySerialization/EntitySerializer.cs index 995b842ee38..e31dad538b4 100644 --- a/Robust.Shared/EntitySerialization/EntitySerializer.cs +++ b/Robust.Shared/EntitySerialization/EntitySerializer.cs @@ -836,8 +836,8 @@ public DataNode Write( goto case MissingEntityBehaviour.AutoIncludeChildren; case MissingEntityBehaviour.AutoInclude: case MissingEntityBehaviour.AutoIncludeChildren: - if (Options.WarnOnAutoInclude) - _log.Warning($"Auto-including entity {EntMan.ToPrettyString(value)} referenced by {EntMan.ToPrettyString(CurrentEntity)}"); + 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)); diff --git a/Robust.Shared/EntitySerialization/LoadResult.cs b/Robust.Shared/EntitySerialization/LoadResult.cs index 1996b05c1c4..6ff6fc42ba9 100644 --- a/Robust.Shared/EntitySerialization/LoadResult.cs +++ b/Robust.Shared/EntitySerialization/LoadResult.cs @@ -66,6 +66,4 @@ public sealed class LoadResult /// , and were not listed as orphans /// public readonly List NullspaceEntities = new(); - - } diff --git a/Robust.Shared/EntitySerialization/Options.cs b/Robust.Shared/EntitySerialization/Options.cs index 2c588439646..613b3f37df6 100644 --- a/Robust.Shared/EntitySerialization/Options.cs +++ b/Robust.Shared/EntitySerialization/Options.cs @@ -2,6 +2,7 @@ using JetBrains.Annotations; using Robust.Shared.EntitySerialization.Components; using Robust.Shared.GameObjects; +using Robust.Shared.Log; using Robust.Shared.Map; using Robust.Shared.Maths; @@ -25,9 +26,10 @@ public record struct SerializationOptions public bool ErrorOnOrphan = true; /// - /// Whether or not to log a warning when auto-including entities while serializing. See . + /// Log level to use when auto-including entities while serializing. Null implies no logs. + /// See . /// - public bool WarnOnAutoInclude = true; + public LogLevel? LogAutoInclude = LogLevel.Info; /// /// If true, the serializer will log an error if it encounters a post map-init entity. diff --git a/Robust.Shared/EntitySerialization/SerializationEnums.cs b/Robust.Shared/EntitySerialization/SerializationEnums.cs index 2d225e24441..7a15fea9b56 100644 --- a/Robust.Shared/EntitySerialization/SerializationEnums.cs +++ b/Robust.Shared/EntitySerialization/SerializationEnums.cs @@ -27,7 +27,7 @@ public enum FileCategory : byte Map, /// - /// File is a full game save, and will in general contain one or more maps likely a few null-space entities. + /// File is a full game save, and will likely contain at least one map and a few null-space entities. /// /// /// The file might also contain additional yaml entries for things like prototypes uploaded via diff --git a/Robust.Shared/GameObjects/EntityManager.cs b/Robust.Shared/GameObjects/EntityManager.cs index 680f1832518..5f46a80ae40 100644 --- a/Robust.Shared/GameObjects/EntityManager.cs +++ b/Robust.Shared/GameObjects/EntityManager.cs @@ -304,7 +304,7 @@ public virtual void FrameUpdate(float frameTime) #region Entity Management - [Obsolete] + /// public EntityUid CreateEntityUninitialized(string? prototypeName, EntityUid euid, ComponentRegistry? overrides = null) { return CreateEntity(prototypeName, out _, overrides); From 3a6da5982038bf7c942ea0cf882daa828249dfc9 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Mon, 23 Dec 2024 18:23:37 +1300 Subject: [PATCH 11/27] error if version is too new --- .../EntitySerialization/EntityDeserializer.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index d5e193ef439..b6a1398d5bb 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -32,7 +32,8 @@ namespace Robust.Shared.EntitySerialization; public sealed class EntityDeserializer : ISerializationContext, IEntityLoadContext, ITypeSerializer { - private const int BackwardsVersion = 3; + public const int OldestSupportedVersion = 3; + public const int NewestSupportedVersion = EntitySerializer.MapFormatVersion; public SerializationManager.SerializerProvider SerializerProvider { get; } = new(); @@ -135,10 +136,18 @@ public EntityDeserializer( public bool TryProcessData() { ReadMetadata(); - if (Result.Version < BackwardsVersion) + + if (Result.Version < OldestSupportedVersion) + { + _log.Error( + $"Cannot handle this map file version, found v{Result.Version} and require at least v{OldestSupportedVersion}"); + return false; + } + + if (Result.Version > NewestSupportedVersion) { _log.Error( - $"Cannot handle this map file version, found v{Result.Version} and require at least v{BackwardsVersion}"); + $"Cannot handle this map file version, found v{Result.Version} but require at most v{NewestSupportedVersion}"); return false; } From a3ab32af31802326245fa048281f06c35da7aef6 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 13:03:49 +1300 Subject: [PATCH 12/27] Add AlwaysPushSerializationTest --- .../EntitySerialization/EntityDeserializer.cs | 2 +- .../AlwaysPushSerializationTest.cs | 154 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index b6a1398d5bb..fbf625a4e24 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -544,7 +544,7 @@ private void LoadEntity(EntityUid uid, MetaDataComponent meta, MappingDataNode e // Previously this method used generic composition pushing. I.e.: /* datanode = ISerializationManager.PushCompositionWithGenericNode( - compType, + compData, [protoData.Mapping], datanode, this); diff --git a/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs new file mode 100644 index 00000000000..8b154f25989 --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Serialization.Manager.Attributes; +using Robust.Shared.Utility; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +/// +/// Simple test component that contains a data-field with a +/// +[RegisterComponent] +public sealed partial class TestAlwaysPushComponent : Component +{ + [DataField, AlwaysPushInheritance] public List List = []; + + [DataField] public int Id; +} + +[TestFixture] +public sealed class AlwaysPushSerializationTest : RobustIntegrationTest +{ + private const string Prototype = @" +- type: entity + id: TestEntityCompositionParent + components: + - type: TestAlwaysPush + list: [ 1, 2 ] + +- type: entity + id: TestEntityCompositionChild + parent: TestEntityCompositionParent + components: + - type: TestAlwaysPush + list: [ 3 , 4 ] +"; + + /// + /// This test checks that deserializing an entity with some component that has the + /// works as intended. Previously the attribute would cause the entity + /// prototype to **always** append it's contents to the loaded entity, effectively causing + /// the data-field to grow each time a map was loaded and saved. + /// + [Test] + [TestOf(typeof(AlwaysPushInheritanceAttribute))] + public async Task TestAlwaysPushSerialization() + { + var opts = new ServerIntegrationOptions + { + ExtraPrototypes = Prototype + }; + + var server = StartServer(opts); + await server.WaitIdleAsync(); + + // Create a new map and spawn in some entities. + MapId mapId = default; + Entity parent1 = default; + Entity parent2 = default; + Entity parent3 = default; + Entity child1 = default; + Entity child2 = default; + Entity child3 = default; + + var path = new ResPath($"{nameof(TestAlwaysPushSerialization)}.yml"); + + await server.WaitPost(() => + { + server.System().CreateMap(out mapId); + var coords = new MapCoordinates(0, 0, mapId); + var uidParent1 = server.EntMan.Spawn("TestEntityCompositionParent", coords); + var uidParent2 = server.EntMan.Spawn("TestEntityCompositionParent", coords); + var uidParent3 = server.EntMan.Spawn("TestEntityCompositionParent", coords); + var uidChild1 = server.EntMan.Spawn("TestEntityCompositionChild", coords); + var uidChild2 = server.EntMan.Spawn("TestEntityCompositionChild", coords); + var uidChild3 = server.EntMan.Spawn("TestEntityCompositionChild", coords); + + parent1 = (uidParent1, server.EntMan.GetComponent(uidParent1)); + parent2 = (uidParent2, server.EntMan.GetComponent(uidParent2)); + parent3 = (uidParent3, server.EntMan.GetComponent(uidParent3)); + child1 = (uidChild1, server.EntMan.GetComponent(uidChild1)); + child2 = (uidChild2, server.EntMan.GetComponent(uidChild2)); + child3 = (uidChild3, server.EntMan.GetComponent(uidChild3)); + }); + + // Assign a unique id to each entity (so they can be identified after saving & loading a map) + parent1.Comp!.Id = 1; + parent2.Comp!.Id = 2; + parent3.Comp!.Id = 3; + child1.Comp!.Id = 4; + child2.Comp!.Id = 5; + child3.Comp!.Id = 6; + + // The inheritance pushing for the prototypes should ensure that the parent & child prototype's lists were merged. + Assert.That(parent1.Comp.List.SequenceEqual(new[] {1, 2})); + Assert.That(parent2.Comp.List.SequenceEqual(new[] {1, 2})); + Assert.That(parent3.Comp.List.SequenceEqual(new[] {1, 2})); + Assert.That(child1.Comp.List.SequenceEqual(new[] {3, 4, 1, 2})); + Assert.That(child2.Comp.List.SequenceEqual(new[] {3, 4, 1, 2})); + Assert.That(child3.Comp.List.SequenceEqual(new[] {3, 4, 1, 2})); + + // Modify data on some components. + parent2.Comp.List.Add(-1); + child2.Comp.List.Add(-1); + parent3.Comp.List.RemoveAt(1); + child3.Comp.List.RemoveAt(1); + + Assert.That(parent1.Comp.List.SequenceEqual(new[] {1, 2})); + Assert.That(parent2.Comp.List.SequenceEqual(new[] {1, 2, -1})); + Assert.That(parent3.Comp.List.SequenceEqual(new[] {1})); + Assert.That(child1.Comp.List.SequenceEqual(new[] {3, 4, 1, 2})); + Assert.That(child2.Comp.List.SequenceEqual(new[] {3, 4, 1, 2, -1})); + Assert.That(child3.Comp.List.SequenceEqual(new[] {3, 1, 2})); + + // Save map to yaml + var loader = server.System(); + var map = server.System(); + loader.SaveMap(mapId, path); + + // Delete the entities + await server.WaitPost(() => map.DeleteMap(mapId)); + var ents = server.EntMan.AllEntities(); + Assert.That(ents.Length, Is.EqualTo(0)); + + // Load the map + await server.WaitPost(() => + { + Assert.That(loader.TryLoadMap(path, out var ent, out _)); + mapId = ent!.Value.Comp.MapId; + }); + + ents = server.EntMan.AllEntities(); + Assert.That(ents.Length, Is.EqualTo(6)); + + parent1 = ents.Single(x => x.Comp.Id == 1); + parent2 = ents.Single(x => x.Comp.Id == 2); + parent3 = ents.Single(x => x.Comp.Id == 3); + child1 = ents.Single(x => x.Comp.Id == 4); + child2 = ents.Single(x => x.Comp.Id == 5); + child3 = ents.Single(x => x.Comp.Id == 6); + + // Verify that the entity data has not changed. + Assert.That(parent1.Comp.List.SequenceEqual(new[] {1, 2})); + Assert.That(parent2.Comp.List.SequenceEqual(new[] {1, 2, -1})); + Assert.That(parent3.Comp.List.SequenceEqual(new[] {1})); + Assert.That(child1.Comp.List.SequenceEqual(new[] {3, 4, 1, 2})); + Assert.That(child2.Comp.List.SequenceEqual(new[] {3, 4, 1, 2, -1})); + Assert.That(child3.Comp.List.SequenceEqual(new[] {3, 1, 2})); + } +} From 3afb6baf481a9062488c284137524a62a68059a4 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 15:09:38 +1300 Subject: [PATCH 13/27] Add auto-inclusion test --- .../EntitySerialization/EntityDeserializer.cs | 14 +- .../EntitySerialization/EntitySerializer.cs | 27 +- .../EntitySerialization/MapChunkSerializer.cs | 27 +- Robust.Shared/EntitySerialization/Options.cs | 6 + .../AutoIncludeSerializationTest.cs | 330 ++++++++++++++++++ 5 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index fbf625a4e24..7c92f68f449 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -1044,8 +1044,20 @@ EntityUid ITypeReader.Read( ISerializationContext? context, ISerializationManager.InstantiationDelegate? _) { - if (node.Value == "invalid" && CurrentComponent == "Transform") + if (node.Value == "invalid") + { + if (CurrentComponent == "Transform") + return EntityUid.Invalid; + + if (!Options.LogInvalidEntities) + return EntityUid.Invalid; + + var msg = CurrentReadingEntity is not { } curr + ? $"Encountered invalid EntityUid reference" + : $"Encountered invalid EntityUid reference wile reading entity {curr.YamlId}, component: {CurrentComponent}"; + _log.Error(msg); return EntityUid.Invalid; + } if (int.TryParse(node.Value, out var val) && UidMap.TryGetValue(val, out var entity)) return entity; diff --git a/Robust.Shared/EntitySerialization/EntitySerializer.cs b/Robust.Shared/EntitySerialization/EntitySerializer.cs index e31dad538b4..d97e7591be5 100644 --- a/Robust.Shared/EntitySerialization/EntitySerializer.cs +++ b/Robust.Shared/EntitySerialization/EntitySerializer.cs @@ -592,7 +592,12 @@ public MappingDataNode WriteTileMap() var map = new MappingDataNode(); foreach (var (tileId, yamlTileId) in _tileMap.OrderBy(x => x.Key)) { - map.Add(yamlTileId.ToString(CultureInfo.InvariantCulture), _tileDef[tileId].ID); + // 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; } @@ -660,11 +665,23 @@ public FileCategory GetCategory() default: if (Maps.Count == 1) - return FileCategory.Map; - if (Grids.Count == 1) - return FileCategory.Grid; - if (Orphans.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; } } diff --git a/Robust.Shared/EntitySerialization/MapChunkSerializer.cs b/Robust.Shared/EntitySerialization/MapChunkSerializer.cs index abb956a92d9..6b0eb16382a 100644 --- a/Robust.Shared/EntitySerialization/MapChunkSerializer.cs +++ b/Robust.Shared/EntitySerialization/MapChunkSerializer.cs @@ -124,17 +124,34 @@ private static string SerializeTiles(MapChunk chunk, EntitySerializer? serialize using (var stream = new MemoryStream(barr)) using (var writer = new BinaryWriter(stream)) { + if (serializer == null) + { + for (ushort y = 0; y < chunk.ChunkSize; y++) + { + for (ushort x = 0; x < chunk.ChunkSize; x++) + { + var tile = chunk.GetTile(x, y); + writer.Write(tile.TypeId); + writer.Write((byte) tile.Flags); + writer.Write(tile.Variant); + } + } + return Convert.ToBase64String(barr); + } + + var lastTile = -1; + var yamlId = -1; for (ushort y = 0; y < chunk.ChunkSize; y++) { for (ushort x = 0; x < chunk.ChunkSize; x++) { var tile = chunk.GetTile(x, y); - var typeId = tile.TypeId; - if (serializer != null) - typeId = serializer.GetYamlTileId(typeId); + if (tile.TypeId != lastTile) + yamlId = serializer.GetYamlTileId(tile.TypeId); - writer.Write(typeId); - writer.Write((byte)tile.Flags); + lastTile = tile.TypeId; + writer.Write(yamlId); + writer.Write((byte) tile.Flags); writer.Write(tile.Variant); } } diff --git a/Robust.Shared/EntitySerialization/Options.cs b/Robust.Shared/EntitySerialization/Options.cs index 613b3f37df6..bf7c34cc29f 100644 --- a/Robust.Shared/EntitySerialization/Options.cs +++ b/Robust.Shared/EntitySerialization/Options.cs @@ -71,6 +71,12 @@ public record struct DeserializationOptions() /// public bool LogOrphanedGrids = true; + /// + /// Whether or not to log an error when encountering an yaml entity id. + /// is exempt from this. + /// + public bool LogInvalidEntities = true; + /// /// Whether or not to automatically assign map ids to any deserialized map entities. /// If false, maps need to be manually given ids before entities are initialized. diff --git a/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs new file mode 100644 index 00000000000..0c13188bdc3 --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs @@ -0,0 +1,330 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Maths; +using Robust.Shared.Serialization.Manager.Attributes; +using Robust.Shared.Utility; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +/// +/// Simple component that stores a reference to another entity. +/// +[RegisterComponent] +public sealed partial class TestEntityRefComponent : Component +{ + [DataField] public EntityUid? Entity; + + /// + /// Give each entity a unique id to identify them across map saves & loads. + /// + [DataField] public string? Id; +} + +/// +/// Dummy tile definition for serializing grids. +/// +public sealed class TileDef(string id) : ITileDefinition +{ + public ushort TileId { get; set; } + public string Name => id; + public string ID => id; + public ResPath? Sprite => null; + public Dictionary EdgeSprites => new(); + public int EdgeSpritePriority => 0; + public float Friction => 0; + public byte Variants => 0; + public void AssignTileId(ushort id) => TileId = id; +} + +[TestFixture] +public sealed partial class AutoIncludeSerializationTest : RobustIntegrationTest +{ + + [Test] + public async Task TestAutoIncludeSerialization() + { + var server = StartServer(); + await server.WaitIdleAsync(); + var ent = server.EntMan; + var mapSys = server.System(); + var loader = server.System(); + var mapMan = server.ResolveDependency(); + var tileMan = server.ResolveDependency(); + var mapPath = new ResPath($"{nameof(AutoIncludeSerializationTest)}_map.yml"); + var gridPath = new ResPath($"{nameof(AutoIncludeSerializationTest)}_grid.yml"); + + tileMan.Register(new TileDef("space")); + var tDef = new TileDef("a"); + tileMan.Register(tDef); + + Entity Find(string name, Entity[] ents) + { + var found = ents!.FirstOrNull(x => x.Comp.Id == name); + Assert.That(found, Is.Not.Null); + return (found!.Value.Owner, server.Transform(found.Value.Owner), found.Value.Comp); + } + + // Create a map that contains an entity that references a nullspace entity. + MapId mapId = default; + Entity map = default; + Entity grid = default; + Entity onGrid = default; + Entity offGrid = default; + Entity nullSpace = default; + + await server.WaitPost(() => + { + var mapUid = mapSys.CreateMap(out mapId); + var gridUid = mapMan.CreateGridEntity(mapId); + mapSys.SetTile(gridUid, Vector2i.Zero, new Tile(tDef.TileId)); + + var onGridUid = ent.SpawnEntity(null, new EntityCoordinates(gridUid, 0.5f, 0.5f)); + var offGridUid = ent.SpawnEntity(null, new MapCoordinates(10f, 10f, mapId)); + var nullSpaceUid = ent.SpawnEntity(null, MapCoordinates.Nullspace); + + map = (mapUid, server.Transform(mapUid), ent.AddComponent(mapUid)); + grid = (gridUid, server.Transform(gridUid), ent.AddComponent(gridUid)); + onGrid = (onGridUid, server.Transform(onGridUid), ent.AddComponent(onGridUid)); + offGrid = (offGridUid, server.Transform(offGridUid), ent.AddComponent(offGridUid)); + nullSpace = (nullSpaceUid, server.Transform(nullSpaceUid), ent.AddComponent( + nullSpaceUid)); + }); + + await server.WaitRunTicks(5); + + Assert.That(map.Comp1!.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(grid.Comp1!.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(onGrid.Comp1!.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(offGrid.Comp1!.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(nullSpace.Comp1!.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + // Assign unique ids. + map.Comp2!.Id = nameof(map); + grid.Comp2!.Id = nameof(grid); + onGrid.Comp2!.Id = nameof(onGrid); + offGrid.Comp2!.Id = nameof(offGrid); + nullSpace.Comp2!.Id = nameof(nullSpace); + + // First simple map loading without any references to other entities. + // This will cause the null-space entity to be lost. + // Save the map, then delete all the entities. + Assert.That(ent.Count(), Is.EqualTo(5)); + await server.WaitPost(() => loader.SaveMap(mapId, mapPath)); + await server.WaitPost(() => loader.SaveGrid(grid, gridPath)); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(ent.Count(), Is.EqualTo(1)); + await server.WaitPost(() => ent.DeleteEntity(nullSpace)); + Assert.That(ent.Count(), Is.EqualTo(0)); + + // Load up the file that only saved the grid and check that the expected entities exist. + await server.WaitPost(() => mapSys.CreateMap(out mapId)); + await server.WaitAssertion(() => Assert.That(loader.TryLoadGrid(mapId, gridPath, out _))); + + var ents = ent.AllEntities(); + Assert.That(ents.Length, Is.EqualTo(2)); + grid = Find(nameof(grid), ents); + onGrid = Find(nameof(onGrid), ents); + Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(ent.Count(), Is.EqualTo(0)); + + // Load up the map, and check that the expected entities exist. + Entity? loadedMap = default; + HashSet>? loadedGrids = default!; + await server.WaitAssertion(() => Assert.That(loader.TryLoadMap(mapPath, out loadedMap, out loadedGrids))); + mapId = loadedMap!.Value.Comp.MapId; + Assert.That(loadedGrids.Count, Is.EqualTo(1)); + + ents = ent.AllEntities(); + Assert.That(ents.Length, Is.EqualTo(4)); // only 4 - nullspace entity was lost + map = Find(nameof(map), ents); + grid = Find(nameof(grid), ents); + onGrid = Find(nameof(onGrid), ents); + offGrid = Find(nameof(offGrid), ents); + + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(offGrid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + + // Re-spawn the nullspace entity + await server.WaitPost(() => + { + var nullSpaceUid = ent.SpawnEntity(null, MapCoordinates.Nullspace); + nullSpace = (nullSpaceUid, server.Transform(nullSpaceUid), ent.AddComponent( + nullSpaceUid)); + nullSpace.Comp2.Id = nameof(nullSpace); + }); + + // Repeat the previous saves, but with an entity that references the null-space entity. + onGrid.Comp2.Entity = nullSpace.Owner; + + Assert.That(ent.Count(), Is.EqualTo(5)); + await server.WaitPost(() => loader.SaveMap(mapId, mapPath)); + await server.WaitPost(() => loader.SaveGrid(grid, gridPath)); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(ent.Count(), Is.EqualTo(1)); + await server.WaitPost(() => ent.DeleteEntity(nullSpace)); + Assert.That(ent.Count(), Is.EqualTo(0)); + + // Load up the file that only saved the grid and check that the expected entities exist. + await server.WaitPost(() => mapSys.CreateMap(out mapId)); + await server.WaitAssertion(() => Assert.That(loader.TryLoadGrid(mapId, gridPath, out _))); + + ents = ent.AllEntities(); + Assert.That(ents.Length, Is.EqualTo(3)); + grid = Find(nameof(grid), ents); + onGrid = Find(nameof(onGrid), ents); + nullSpace = Find(nameof(nullSpace), ents); + Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(onGrid.Comp2.Entity, Is.EqualTo(nullSpace.Owner)); + Assert.That(nullSpace.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(ent.Count(), Is.EqualTo(1)); + await server.WaitPost(() => ent.DeleteEntity(nullSpace)); + Assert.That(ent.Count(), Is.EqualTo(0)); + + // Load up the map, and check that the expected entities exist. + await server.WaitAssertion(() => Assert.That(loader.TryLoadMap(mapPath, out loadedMap, out loadedGrids))); + mapId = loadedMap!.Value.Comp.MapId; + Assert.That(loadedGrids.Count, Is.EqualTo(1)); + + ents = ent.AllEntities(); + Assert.That(ents.Length, Is.EqualTo(5)); + map = Find(nameof(map), ents); + grid = Find(nameof(grid), ents); + onGrid = Find(nameof(onGrid), ents); + offGrid = Find(nameof(offGrid), ents); + nullSpace = Find(nameof(nullSpace), ents); + + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(offGrid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(onGrid.Comp2.Entity, Is.EqualTo(nullSpace.Owner)); + Assert.That(nullSpace.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + // Check that attempting to save a reference to a non-null-space entity does not auto-include it. + Entity otherMap = default; + await server.WaitPost(() => + { + var uid = mapSys.CreateMap(); + otherMap = (uid, server.Transform(uid), ent.AddComponent(uid)); + otherMap.Comp2.Id = nameof(otherMap); + }); + onGrid.Comp2.Entity = otherMap.Owner; + + // By default it should log an error, but tests don't have a nice way to validate that an error was logged, so we'll just suppress it. + var opts = SerializationOptions.Default with {MissingEntityBehaviour = MissingEntityBehaviour.Ignore}; + Assert.That(ent.Count(), Is.EqualTo(6)); + await server.WaitPost(() => loader.SaveMap(mapId, mapPath, opts)); + await server.WaitPost(() => loader.SaveGrid(grid, gridPath, opts)); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + await server.WaitPost(() => ent.DeleteEntity(nullSpace)); + await server.WaitPost(() => ent.DeleteEntity(otherMap)); + Assert.That(ent.Count(), Is.EqualTo(0)); + + // Check the grid file + await server.WaitPost(() => mapSys.CreateMap(out mapId)); + var dOpts = DeserializationOptions.Default with {LogInvalidEntities = false}; + await server.WaitAssertion(() => Assert.That(loader.TryLoadGrid(mapId, gridPath, out _, dOpts))); + ents = ent.AllEntities(); + Assert.That(ents.Length, Is.EqualTo(2)); + grid = Find(nameof(grid), ents); + onGrid = Find(nameof(onGrid), ents); + Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(ent.Count(), Is.EqualTo(0)); + + // Check the map file + await server.WaitAssertion(() => Assert.That(loader.TryLoadMap(mapPath, out loadedMap, out loadedGrids, dOpts))); + mapId = loadedMap!.Value.Comp.MapId; + Assert.That(loadedGrids.Count, Is.EqualTo(1)); + ents = ent.AllEntities(); + Assert.That(ents.Length, Is.EqualTo(4)); + map = Find(nameof(map), ents); + grid = Find(nameof(grid), ents); + onGrid = Find(nameof(onGrid), ents); + offGrid = Find(nameof(offGrid), ents); + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(offGrid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + + // repeat the check, but this time with auto inclusion fully enabled. + Entity otherEnt; + await server.WaitPost(() => + { + var uid = mapSys.CreateMap(out var otherMapId); + otherMap = (uid, server.Transform(uid), ent.AddComponent(uid)); + otherMap.Comp2.Id = nameof(otherMap); + var otherUid = ent.SpawnEntity(null, new MapCoordinates(0, 0, otherMapId)); + otherEnt = (otherUid, server.Transform(otherUid), ent.AddComponent(otherUid)); + otherEnt.Comp2.Id = nameof(otherEnt); + }); + + onGrid.Comp2.Entity = otherMap.Owner; + + Assert.That(ent.Count(), Is.EqualTo(6)); + opts = opts with {MissingEntityBehaviour = MissingEntityBehaviour.AutoIncludeChildren}; + await server.WaitPost(() => loader.SaveEntity(map.Owner, mapPath, opts)); + await server.WaitPost(() => loader.SaveEntity(grid.Owner, gridPath, opts)); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + await server.WaitPost(() => ent.DeleteEntity(otherMap)); + Assert.That(ent.Count(), Is.EqualTo(0)); + + // Check the grid file + await server.WaitPost(() => mapSys.CreateMap(out mapId)); + var mapLoadOpts = MapLoadOptions.Default with + { + DeserializationOptions = DeserializationOptions.Default with {LogOrphanedGrids = false} + }; + LoadResult? result = default; + await server.WaitAssertion(() => Assert.That(loader.TryLoadEntities(gridPath, out result, mapLoadOpts))); + Assert.That(result!.Grids.Count, Is.EqualTo(1)); + Assert.That(result.Maps.Count, Is.EqualTo(2)); + ents = ent.AllEntities(); + Assert.That(ents.Length, Is.EqualTo(4)); + grid = Find(nameof(grid), ents); + onGrid = Find(nameof(onGrid), ents); + otherMap = Find(nameof(otherMap), ents); + otherEnt = Find(nameof(otherEnt), ents); + Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(otherEnt.Comp1.ParentUid, Is.EqualTo(otherMap.Owner)); + Assert.That(otherMap.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + await server.WaitPost(() => ent.DeleteEntity(otherMap)); + await server.WaitPost(() => ent.DeleteEntity(grid.Comp1.ParentUid)); + Assert.That(ent.Count(), Is.EqualTo(0)); + + // Check the map file + await server.WaitAssertion(() => Assert.That(loader.TryLoadEntities(mapPath, out result))); + Assert.That(result!.Grids.Count, Is.EqualTo(1)); + Assert.That(result.Maps.Count, Is.EqualTo(2)); + Assert.That(loadedGrids.Count, Is.EqualTo(1)); + ents = ent.AllEntities(); + Assert.That(ents.Length, Is.EqualTo(6)); + map = Find(nameof(map), ents); + grid = Find(nameof(grid), ents); + onGrid = Find(nameof(onGrid), ents); + offGrid = Find(nameof(offGrid), ents); + otherMap = Find(nameof(otherMap), ents); + otherEnt = Find(nameof(otherEnt), ents); + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(offGrid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(otherEnt.Comp1.ParentUid, Is.EqualTo(otherMap.Owner)); + Assert.That(otherMap.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + await server.WaitPost(() => ent.DeleteEntity(map)); + await server.WaitPost(() => ent.DeleteEntity(otherMap)); + Assert.That(ent.Count(), Is.EqualTo(0)); + } +} From a77bc88560c74b4ef5da616b088504e8ec6b53b0 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 15:43:42 +1300 Subject: [PATCH 14/27] Better categorization --- .../EntitySerialization/EntityDeserializer.cs | 14 ++++++-------- Robust.Shared/EntitySerialization/LoadResult.cs | 2 ++ Robust.Shared/EntitySerialization/Options.cs | 9 +++++++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index 7c92f68f449..8ce243fe9cf 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -708,28 +708,26 @@ private void CheckCategory() switch (Result.Category) { case FileCategory.Map: - if (Result.Maps.Count == 1) + if (Result.Maps.Count == 1 && Result.Orphans.Count == 0) return; - _log.Error($"Expected file to contain a single map, but instead found: {Result.Maps.Count}"); + _log.Error($"Expected file to contain a single map, but instead found {Result.Maps.Count} maps and {Result.Orphans.Count} orphans"); break; case FileCategory.Grid: - if (Result.Maps.Count == 0 && Result.Grids.Count == 1) + if (Result.Maps.Count == 0 && Result.Grids.Count == 1 && Result.Orphans.Count == 1 && Result.Orphans.First() == Result.Grids.First().Owner) return; - _log.Error($"Expected file to contain a single grid, but instead found: {Result.Grids.Count}"); + _log.Error($"Expected file to contain a single grid, but instead found {Result.Grids.Count} grids and {Result.Orphans.Count} orphans"); break; case FileCategory.Entity: if (Result.Maps.Count == 0 && Result.Grids.Count == 0 && Result.Orphans.Count == 1) return; - _log.Error($"Expected file to contain a orphaned entity, but instead found: {Result.Orphans.Count}"); + _log.Error($"Expected file to contain a orphaned entity, but instead found {Result.Orphans.Count} orphans"); break; default: - return; + return; // No validation for full game saves } - - Result.Category = FileCategory.Unknown; } private void InferCategory() diff --git a/Robust.Shared/EntitySerialization/LoadResult.cs b/Robust.Shared/EntitySerialization/LoadResult.cs index 6ff6fc42ba9..b67ae667ed8 100644 --- a/Robust.Shared/EntitySerialization/LoadResult.cs +++ b/Robust.Shared/EntitySerialization/LoadResult.cs @@ -17,6 +17,8 @@ public sealed class LoadResult /// /// The category of the file that was loaded in. + /// This might not match the actual final result. E.g., when loading in a grid file, a map may automatically gets + /// generated for it via . /// public FileCategory Category = FileCategory.Unknown; diff --git a/Robust.Shared/EntitySerialization/Options.cs b/Robust.Shared/EntitySerialization/Options.cs index bf7c34cc29f..26889806fb1 100644 --- a/Robust.Shared/EntitySerialization/Options.cs +++ b/Robust.Shared/EntitySerialization/Options.cs @@ -121,9 +121,14 @@ public struct MapLoadOptions() public MapId? ForceMapId; /// - /// The expected type for the file currently being read. - /// Will log errors if the category doesn't match the expected one (e.g., trying to load a "map" from a file + /// The expected for the file currently being read in, at the end of the entity + /// creation step. Will log errors if the category doesn't match the expected one (e.g., trying to load a "map" from a file /// that doesn't contain any map entities). /// + /// + /// Note that the effective final category may change by the time the file has fully loaded. E.g., when loading a + /// file containing an orphaned grid, a map may be automatically created for the grid, but the category will still + /// be + /// public FileCategory? ExpectedCategory; } From 416a2f558ef634b43b3d2dc96ada8034ea989875 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 17:12:58 +1300 Subject: [PATCH 15/27] Combine test components --- .../AlwaysPushSerializationTest.cs | 133 +++++----- .../AutoIncludeSerializationTest.cs | 228 +++++++----------- .../EntitySerialization/TestComponents.cs | 57 +++++ 3 files changed, 209 insertions(+), 209 deletions(-) create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/TestComponents.cs diff --git a/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs index 8b154f25989..c2943cbb429 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; @@ -7,20 +6,10 @@ using Robust.Shared.Map; using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.Utility; +using static Robust.UnitTesting.Shared.EntitySerialization.EntitySaveTestComponent; namespace Robust.UnitTesting.Shared.EntitySerialization; -/// -/// Simple test component that contains a data-field with a -/// -[RegisterComponent] -public sealed partial class TestAlwaysPushComponent : Component -{ - [DataField, AlwaysPushInheritance] public List List = []; - - [DataField] public int Id; -} - [TestFixture] public sealed class AlwaysPushSerializationTest : RobustIntegrationTest { @@ -28,14 +17,14 @@ public sealed class AlwaysPushSerializationTest : RobustIntegrationTest - type: entity id: TestEntityCompositionParent components: - - type: TestAlwaysPush + - type: EntitySaveTest list: [ 1, 2 ] - type: entity id: TestEntityCompositionChild parent: TestEntityCompositionParent components: - - type: TestAlwaysPush + - type: EntitySaveTest list: [ 3 , 4 ] "; @@ -56,15 +45,16 @@ public async Task TestAlwaysPushSerialization() var server = StartServer(opts); await server.WaitIdleAsync(); + var entMan = server.EntMan; // Create a new map and spawn in some entities. MapId mapId = default; - Entity parent1 = default; - Entity parent2 = default; - Entity parent3 = default; - Entity child1 = default; - Entity child2 = default; - Entity child3 = default; + Entity parent1 = default; + Entity parent2 = default; + Entity parent3 = default; + Entity child1 = default; + Entity child2 = default; + Entity child3 = default; var path = new ResPath($"{nameof(TestAlwaysPushSerialization)}.yml"); @@ -72,49 +62,49 @@ await server.WaitPost(() => { server.System().CreateMap(out mapId); var coords = new MapCoordinates(0, 0, mapId); - var uidParent1 = server.EntMan.Spawn("TestEntityCompositionParent", coords); - var uidParent2 = server.EntMan.Spawn("TestEntityCompositionParent", coords); - var uidParent3 = server.EntMan.Spawn("TestEntityCompositionParent", coords); - var uidChild1 = server.EntMan.Spawn("TestEntityCompositionChild", coords); - var uidChild2 = server.EntMan.Spawn("TestEntityCompositionChild", coords); - var uidChild3 = server.EntMan.Spawn("TestEntityCompositionChild", coords); - - parent1 = (uidParent1, server.EntMan.GetComponent(uidParent1)); - parent2 = (uidParent2, server.EntMan.GetComponent(uidParent2)); - parent3 = (uidParent3, server.EntMan.GetComponent(uidParent3)); - child1 = (uidChild1, server.EntMan.GetComponent(uidChild1)); - child2 = (uidChild2, server.EntMan.GetComponent(uidChild2)); - child3 = (uidChild3, server.EntMan.GetComponent(uidChild3)); + var parent1Uid = entMan.Spawn("TestEntityCompositionParent", coords); + var parent2Uid = entMan.Spawn("TestEntityCompositionParent", coords); + var parent3Uid = entMan.Spawn("TestEntityCompositionParent", coords); + var child1Uid = entMan.Spawn("TestEntityCompositionChild", coords); + var child2Uid = entMan.Spawn("TestEntityCompositionChild", coords); + var child3Uid = entMan.Spawn("TestEntityCompositionChild", coords); + + parent1 = Get(parent1Uid, entMan); + parent2 = Get(parent2Uid, entMan); + parent3 = Get(parent3Uid, entMan); + child1 = Get(child1Uid, entMan); + child2 = Get(child2Uid, entMan); + child3 = Get(child3Uid, entMan); }); // Assign a unique id to each entity (so they can be identified after saving & loading a map) - parent1.Comp!.Id = 1; - parent2.Comp!.Id = 2; - parent3.Comp!.Id = 3; - child1.Comp!.Id = 4; - child2.Comp!.Id = 5; - child3.Comp!.Id = 6; + parent1.Comp2!.Id = nameof(parent1); + parent2.Comp2!.Id = nameof(parent2); + parent3.Comp2!.Id = nameof(parent3); + child1.Comp2!.Id = nameof(child1); + child2.Comp2!.Id = nameof(child2); + child3.Comp2!.Id = nameof(child3); // The inheritance pushing for the prototypes should ensure that the parent & child prototype's lists were merged. - Assert.That(parent1.Comp.List.SequenceEqual(new[] {1, 2})); - Assert.That(parent2.Comp.List.SequenceEqual(new[] {1, 2})); - Assert.That(parent3.Comp.List.SequenceEqual(new[] {1, 2})); - Assert.That(child1.Comp.List.SequenceEqual(new[] {3, 4, 1, 2})); - Assert.That(child2.Comp.List.SequenceEqual(new[] {3, 4, 1, 2})); - Assert.That(child3.Comp.List.SequenceEqual(new[] {3, 4, 1, 2})); + Assert.That(parent1.Comp2.List.SequenceEqual(new[] {1, 2})); + Assert.That(parent2.Comp2.List.SequenceEqual(new[] {1, 2})); + Assert.That(parent3.Comp2.List.SequenceEqual(new[] {1, 2})); + Assert.That(child1.Comp2.List.SequenceEqual(new[] {3, 4, 1, 2})); + Assert.That(child2.Comp2.List.SequenceEqual(new[] {3, 4, 1, 2})); + Assert.That(child3.Comp2.List.SequenceEqual(new[] {3, 4, 1, 2})); // Modify data on some components. - parent2.Comp.List.Add(-1); - child2.Comp.List.Add(-1); - parent3.Comp.List.RemoveAt(1); - child3.Comp.List.RemoveAt(1); - - Assert.That(parent1.Comp.List.SequenceEqual(new[] {1, 2})); - Assert.That(parent2.Comp.List.SequenceEqual(new[] {1, 2, -1})); - Assert.That(parent3.Comp.List.SequenceEqual(new[] {1})); - Assert.That(child1.Comp.List.SequenceEqual(new[] {3, 4, 1, 2})); - Assert.That(child2.Comp.List.SequenceEqual(new[] {3, 4, 1, 2, -1})); - Assert.That(child3.Comp.List.SequenceEqual(new[] {3, 1, 2})); + parent2.Comp2.List.Add(-1); + child2.Comp2.List.Add(-1); + parent3.Comp2.List.RemoveAt(1); + child3.Comp2.List.RemoveAt(1); + + Assert.That(parent1.Comp2.List.SequenceEqual(new[] {1, 2})); + Assert.That(parent2.Comp2.List.SequenceEqual(new[] {1, 2, -1})); + Assert.That(parent3.Comp2.List.SequenceEqual(new[] {1})); + Assert.That(child1.Comp2.List.SequenceEqual(new[] {3, 4, 1, 2})); + Assert.That(child2.Comp2.List.SequenceEqual(new[] {3, 4, 1, 2, -1})); + Assert.That(child3.Comp2.List.SequenceEqual(new[] {3, 1, 2})); // Save map to yaml var loader = server.System(); @@ -123,8 +113,7 @@ await server.WaitPost(() => // Delete the entities await server.WaitPost(() => map.DeleteMap(mapId)); - var ents = server.EntMan.AllEntities(); - Assert.That(ents.Length, Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(0)); // Load the map await server.WaitPost(() => @@ -133,22 +122,22 @@ await server.WaitPost(() => mapId = ent!.Value.Comp.MapId; }); - ents = server.EntMan.AllEntities(); - Assert.That(ents.Length, Is.EqualTo(6)); + Assert.That(entMan.Count(), Is.EqualTo(6)); - parent1 = ents.Single(x => x.Comp.Id == 1); - parent2 = ents.Single(x => x.Comp.Id == 2); - parent3 = ents.Single(x => x.Comp.Id == 3); - child1 = ents.Single(x => x.Comp.Id == 4); - child2 = ents.Single(x => x.Comp.Id == 5); - child3 = ents.Single(x => x.Comp.Id == 6); + // Find the deserialized entities + parent1 = Find(nameof(parent1), entMan); + parent2 = Find(nameof(parent2), entMan); + parent3 = Find(nameof(parent3), entMan); + child1 = Find(nameof(child1), entMan); + child2 = Find(nameof(child2), entMan); + child3 = Find(nameof(child3), entMan); // Verify that the entity data has not changed. - Assert.That(parent1.Comp.List.SequenceEqual(new[] {1, 2})); - Assert.That(parent2.Comp.List.SequenceEqual(new[] {1, 2, -1})); - Assert.That(parent3.Comp.List.SequenceEqual(new[] {1})); - Assert.That(child1.Comp.List.SequenceEqual(new[] {3, 4, 1, 2})); - Assert.That(child2.Comp.List.SequenceEqual(new[] {3, 4, 1, 2, -1})); - Assert.That(child3.Comp.List.SequenceEqual(new[] {3, 1, 2})); + Assert.That(parent1.Comp2.List.SequenceEqual(new[] {1, 2})); + Assert.That(parent2.Comp2.List.SequenceEqual(new[] {1, 2, -1})); + Assert.That(parent3.Comp2.List.SequenceEqual(new[] {1})); + Assert.That(child1.Comp2.List.SequenceEqual(new[] {3, 4, 1, 2})); + Assert.That(child2.Comp2.List.SequenceEqual(new[] {3, 4, 1, 2, -1})); + Assert.That(child3.Comp2.List.SequenceEqual(new[] {3, 1, 2})); } } diff --git a/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs index 0c13188bdc3..31eea68ef4f 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs @@ -7,51 +7,20 @@ using Robust.Shared.Map; using Robust.Shared.Map.Components; using Robust.Shared.Maths; -using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.Utility; +using static Robust.UnitTesting.Shared.EntitySerialization.EntitySaveTestComponent; namespace Robust.UnitTesting.Shared.EntitySerialization; -/// -/// Simple component that stores a reference to another entity. -/// -[RegisterComponent] -public sealed partial class TestEntityRefComponent : Component -{ - [DataField] public EntityUid? Entity; - - /// - /// Give each entity a unique id to identify them across map saves & loads. - /// - [DataField] public string? Id; -} - -/// -/// Dummy tile definition for serializing grids. -/// -public sealed class TileDef(string id) : ITileDefinition -{ - public ushort TileId { get; set; } - public string Name => id; - public string ID => id; - public ResPath? Sprite => null; - public Dictionary EdgeSprites => new(); - public int EdgeSpritePriority => 0; - public float Friction => 0; - public byte Variants => 0; - public void AssignTileId(ushort id) => TileId = id; -} - [TestFixture] public sealed partial class AutoIncludeSerializationTest : RobustIntegrationTest { - [Test] public async Task TestAutoIncludeSerialization() { var server = StartServer(); await server.WaitIdleAsync(); - var ent = server.EntMan; + var entMan = server.EntMan; var mapSys = server.System(); var loader = server.System(); var mapMan = server.ResolveDependency(); @@ -63,20 +32,15 @@ public async Task TestAutoIncludeSerialization() var tDef = new TileDef("a"); tileMan.Register(tDef); - Entity Find(string name, Entity[] ents) - { - var found = ents!.FirstOrNull(x => x.Comp.Id == name); - Assert.That(found, Is.Not.Null); - return (found!.Value.Owner, server.Transform(found.Value.Owner), found.Value.Comp); - } - // Create a map that contains an entity that references a nullspace entity. MapId mapId = default; - Entity map = default; - Entity grid = default; - Entity onGrid = default; - Entity offGrid = default; - Entity nullSpace = default; + Entity map = default; + Entity grid = default; + Entity onGrid = default; + Entity offGrid = default; + Entity nullSpace = default; + + void AssertCount(int expected) => Assert.That(entMan.Count(), Is.EqualTo(expected)); await server.WaitPost(() => { @@ -84,16 +48,15 @@ await server.WaitPost(() => var gridUid = mapMan.CreateGridEntity(mapId); mapSys.SetTile(gridUid, Vector2i.Zero, new Tile(tDef.TileId)); - var onGridUid = ent.SpawnEntity(null, new EntityCoordinates(gridUid, 0.5f, 0.5f)); - var offGridUid = ent.SpawnEntity(null, new MapCoordinates(10f, 10f, mapId)); - var nullSpaceUid = ent.SpawnEntity(null, MapCoordinates.Nullspace); + var onGridUid = entMan.SpawnEntity(null, new EntityCoordinates(gridUid, 0.5f, 0.5f)); + var offGridUid = entMan.SpawnEntity(null, new MapCoordinates(10f, 10f, mapId)); + var nullSpaceUid = entMan.SpawnEntity(null, MapCoordinates.Nullspace); - map = (mapUid, server.Transform(mapUid), ent.AddComponent(mapUid)); - grid = (gridUid, server.Transform(gridUid), ent.AddComponent(gridUid)); - onGrid = (onGridUid, server.Transform(onGridUid), ent.AddComponent(onGridUid)); - offGrid = (offGridUid, server.Transform(offGridUid), ent.AddComponent(offGridUid)); - nullSpace = (nullSpaceUid, server.Transform(nullSpaceUid), ent.AddComponent( - nullSpaceUid)); + map = Get(mapUid, entMan); + grid = Get(gridUid, entMan); + onGrid = Get(onGridUid, entMan); + offGrid = Get(offGridUid, entMan); + nullSpace = Get(nullSpaceUid, entMan); }); await server.WaitRunTicks(5); @@ -114,26 +77,25 @@ await server.WaitPost(() => // First simple map loading without any references to other entities. // This will cause the null-space entity to be lost. // Save the map, then delete all the entities. - Assert.That(ent.Count(), Is.EqualTo(5)); + Assert.That(entMan.Count(), Is.EqualTo(5)); await server.WaitPost(() => loader.SaveMap(mapId, mapPath)); await server.WaitPost(() => loader.SaveGrid(grid, gridPath)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - Assert.That(ent.Count(), Is.EqualTo(1)); - await server.WaitPost(() => ent.DeleteEntity(nullSpace)); - Assert.That(ent.Count(), Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(1)); + await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); + Assert.That(entMan.Count(), Is.EqualTo(0)); // Load up the file that only saved the grid and check that the expected entities exist. await server.WaitPost(() => mapSys.CreateMap(out mapId)); await server.WaitAssertion(() => Assert.That(loader.TryLoadGrid(mapId, gridPath, out _))); - var ents = ent.AllEntities(); - Assert.That(ents.Length, Is.EqualTo(2)); - grid = Find(nameof(grid), ents); - onGrid = Find(nameof(onGrid), ents); + AssertCount(2); + grid = Find(nameof(grid), entMan); + onGrid = Find(nameof(onGrid), entMan); Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - Assert.That(ent.Count(), Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(0)); // Load up the map, and check that the expected entities exist. Entity? loadedMap = default; @@ -142,12 +104,11 @@ await server.WaitPost(() => mapId = loadedMap!.Value.Comp.MapId; Assert.That(loadedGrids.Count, Is.EqualTo(1)); - ents = ent.AllEntities(); - Assert.That(ents.Length, Is.EqualTo(4)); // only 4 - nullspace entity was lost - map = Find(nameof(map), ents); - grid = Find(nameof(grid), ents); - onGrid = Find(nameof(onGrid), ents); - offGrid = Find(nameof(offGrid), ents); + AssertCount(4); + map = Find(nameof(map), entMan); + grid = Find(nameof(grid), entMan); + onGrid = Find(nameof(onGrid), entMan); + offGrid = Find(nameof(offGrid), entMan); Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); @@ -157,53 +118,50 @@ await server.WaitPost(() => // Re-spawn the nullspace entity await server.WaitPost(() => { - var nullSpaceUid = ent.SpawnEntity(null, MapCoordinates.Nullspace); - nullSpace = (nullSpaceUid, server.Transform(nullSpaceUid), ent.AddComponent( - nullSpaceUid)); + var nullSpaceUid = entMan.SpawnEntity(null, MapCoordinates.Nullspace); + nullSpace = Get(nullSpaceUid, entMan); nullSpace.Comp2.Id = nameof(nullSpace); }); // Repeat the previous saves, but with an entity that references the null-space entity. onGrid.Comp2.Entity = nullSpace.Owner; - Assert.That(ent.Count(), Is.EqualTo(5)); + Assert.That(entMan.Count(), Is.EqualTo(5)); await server.WaitPost(() => loader.SaveMap(mapId, mapPath)); await server.WaitPost(() => loader.SaveGrid(grid, gridPath)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - Assert.That(ent.Count(), Is.EqualTo(1)); - await server.WaitPost(() => ent.DeleteEntity(nullSpace)); - Assert.That(ent.Count(), Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(1)); + await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); + Assert.That(entMan.Count(), Is.EqualTo(0)); // Load up the file that only saved the grid and check that the expected entities exist. await server.WaitPost(() => mapSys.CreateMap(out mapId)); await server.WaitAssertion(() => Assert.That(loader.TryLoadGrid(mapId, gridPath, out _))); - ents = ent.AllEntities(); - Assert.That(ents.Length, Is.EqualTo(3)); - grid = Find(nameof(grid), ents); - onGrid = Find(nameof(onGrid), ents); - nullSpace = Find(nameof(nullSpace), ents); + AssertCount(3); + grid = Find(nameof(grid), entMan); + onGrid = Find(nameof(onGrid), entMan); + nullSpace = Find(nameof(nullSpace), entMan); Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); Assert.That(onGrid.Comp2.Entity, Is.EqualTo(nullSpace.Owner)); Assert.That(nullSpace.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - Assert.That(ent.Count(), Is.EqualTo(1)); - await server.WaitPost(() => ent.DeleteEntity(nullSpace)); - Assert.That(ent.Count(), Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(1)); + await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); + Assert.That(entMan.Count(), Is.EqualTo(0)); // Load up the map, and check that the expected entities exist. await server.WaitAssertion(() => Assert.That(loader.TryLoadMap(mapPath, out loadedMap, out loadedGrids))); mapId = loadedMap!.Value.Comp.MapId; Assert.That(loadedGrids.Count, Is.EqualTo(1)); - ents = ent.AllEntities(); - Assert.That(ents.Length, Is.EqualTo(5)); - map = Find(nameof(map), ents); - grid = Find(nameof(grid), ents); - onGrid = Find(nameof(onGrid), ents); - offGrid = Find(nameof(offGrid), ents); - nullSpace = Find(nameof(nullSpace), ents); + AssertCount(5); + map = Find(nameof(map), entMan); + grid = Find(nameof(grid), entMan); + onGrid = Find(nameof(onGrid), entMan); + offGrid = Find(nameof(offGrid), entMan); + nullSpace = Find(nameof(nullSpace), entMan); Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); @@ -213,73 +171,71 @@ await server.WaitPost(() => Assert.That(nullSpace.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); // Check that attempting to save a reference to a non-null-space entity does not auto-include it. - Entity otherMap = default; + Entity otherMap = default; await server.WaitPost(() => { - var uid = mapSys.CreateMap(); - otherMap = (uid, server.Transform(uid), ent.AddComponent(uid)); + var otherMapUid = mapSys.CreateMap(); + otherMap = Get(otherMapUid, entMan); otherMap.Comp2.Id = nameof(otherMap); }); onGrid.Comp2.Entity = otherMap.Owner; // By default it should log an error, but tests don't have a nice way to validate that an error was logged, so we'll just suppress it. var opts = SerializationOptions.Default with {MissingEntityBehaviour = MissingEntityBehaviour.Ignore}; - Assert.That(ent.Count(), Is.EqualTo(6)); + Assert.That(entMan.Count(), Is.EqualTo(6)); await server.WaitPost(() => loader.SaveMap(mapId, mapPath, opts)); await server.WaitPost(() => loader.SaveGrid(grid, gridPath, opts)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - await server.WaitPost(() => ent.DeleteEntity(nullSpace)); - await server.WaitPost(() => ent.DeleteEntity(otherMap)); - Assert.That(ent.Count(), Is.EqualTo(0)); + await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); + await server.WaitPost(() => entMan.DeleteEntity(otherMap)); + Assert.That(entMan.Count(), Is.EqualTo(0)); // Check the grid file await server.WaitPost(() => mapSys.CreateMap(out mapId)); var dOpts = DeserializationOptions.Default with {LogInvalidEntities = false}; await server.WaitAssertion(() => Assert.That(loader.TryLoadGrid(mapId, gridPath, out _, dOpts))); - ents = ent.AllEntities(); - Assert.That(ents.Length, Is.EqualTo(2)); - grid = Find(nameof(grid), ents); - onGrid = Find(nameof(onGrid), ents); + AssertCount(2); + grid = Find(nameof(grid), entMan); + onGrid = Find(nameof(onGrid), entMan); Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - Assert.That(ent.Count(), Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(0)); // Check the map file await server.WaitAssertion(() => Assert.That(loader.TryLoadMap(mapPath, out loadedMap, out loadedGrids, dOpts))); mapId = loadedMap!.Value.Comp.MapId; Assert.That(loadedGrids.Count, Is.EqualTo(1)); - ents = ent.AllEntities(); - Assert.That(ents.Length, Is.EqualTo(4)); - map = Find(nameof(map), ents); - grid = Find(nameof(grid), ents); - onGrid = Find(nameof(onGrid), ents); - offGrid = Find(nameof(offGrid), ents); + AssertCount(4); + map = Find(nameof(map), entMan); + grid = Find(nameof(grid), entMan); + onGrid = Find(nameof(onGrid), entMan); + offGrid = Find(nameof(offGrid), entMan); Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); Assert.That(offGrid.Comp1.ParentUid, Is.EqualTo(map.Owner)); // repeat the check, but this time with auto inclusion fully enabled. - Entity otherEnt; + Entity otherEnt; await server.WaitPost(() => { - var uid = mapSys.CreateMap(out var otherMapId); - otherMap = (uid, server.Transform(uid), ent.AddComponent(uid)); + var otherMapUid = mapSys.CreateMap(out var otherMapId); + otherMap = Get(otherMapUid, entMan); otherMap.Comp2.Id = nameof(otherMap); - var otherUid = ent.SpawnEntity(null, new MapCoordinates(0, 0, otherMapId)); - otherEnt = (otherUid, server.Transform(otherUid), ent.AddComponent(otherUid)); + var otherEntUid = entMan.SpawnEntity(null, new MapCoordinates(0, 0, otherMapId)); + otherEnt = Get(otherEntUid, entMan); otherEnt.Comp2.Id = nameof(otherEnt); }); onGrid.Comp2.Entity = otherMap.Owner; - Assert.That(ent.Count(), Is.EqualTo(6)); + Assert.That(entMan.Count(), Is.EqualTo(6)); opts = opts with {MissingEntityBehaviour = MissingEntityBehaviour.AutoIncludeChildren}; await server.WaitPost(() => loader.SaveEntity(map.Owner, mapPath, opts)); await server.WaitPost(() => loader.SaveEntity(grid.Owner, gridPath, opts)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - await server.WaitPost(() => ent.DeleteEntity(otherMap)); - Assert.That(ent.Count(), Is.EqualTo(0)); + await server.WaitPost(() => entMan.DeleteEntity(otherMap)); + Assert.That(entMan.Count(), Is.EqualTo(0)); // Check the grid file await server.WaitPost(() => mapSys.CreateMap(out mapId)); @@ -291,40 +247,38 @@ await server.WaitPost(() => await server.WaitAssertion(() => Assert.That(loader.TryLoadEntities(gridPath, out result, mapLoadOpts))); Assert.That(result!.Grids.Count, Is.EqualTo(1)); Assert.That(result.Maps.Count, Is.EqualTo(2)); - ents = ent.AllEntities(); - Assert.That(ents.Length, Is.EqualTo(4)); - grid = Find(nameof(grid), ents); - onGrid = Find(nameof(onGrid), ents); - otherMap = Find(nameof(otherMap), ents); - otherEnt = Find(nameof(otherEnt), ents); + AssertCount(4); + grid = Find(nameof(grid), entMan); + onGrid = Find(nameof(onGrid), entMan); + otherMap = Find(nameof(otherMap), entMan); + otherEnt = Find(nameof(otherEnt), entMan); Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); Assert.That(otherEnt.Comp1.ParentUid, Is.EqualTo(otherMap.Owner)); Assert.That(otherMap.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); - await server.WaitPost(() => ent.DeleteEntity(otherMap)); - await server.WaitPost(() => ent.DeleteEntity(grid.Comp1.ParentUid)); - Assert.That(ent.Count(), Is.EqualTo(0)); + await server.WaitPost(() => entMan.DeleteEntity(otherMap)); + await server.WaitPost(() => entMan.DeleteEntity(grid.Comp1.ParentUid)); + Assert.That(entMan.Count(), Is.EqualTo(0)); // Check the map file await server.WaitAssertion(() => Assert.That(loader.TryLoadEntities(mapPath, out result))); Assert.That(result!.Grids.Count, Is.EqualTo(1)); Assert.That(result.Maps.Count, Is.EqualTo(2)); Assert.That(loadedGrids.Count, Is.EqualTo(1)); - ents = ent.AllEntities(); - Assert.That(ents.Length, Is.EqualTo(6)); - map = Find(nameof(map), ents); - grid = Find(nameof(grid), ents); - onGrid = Find(nameof(onGrid), ents); - offGrid = Find(nameof(offGrid), ents); - otherMap = Find(nameof(otherMap), ents); - otherEnt = Find(nameof(otherEnt), ents); + AssertCount(6); + map = Find(nameof(map), entMan); + grid = Find(nameof(grid), entMan); + onGrid = Find(nameof(onGrid), entMan); + offGrid = Find(nameof(offGrid), entMan); + otherMap = Find(nameof(otherMap), entMan); + otherEnt = Find(nameof(otherEnt), entMan); Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); Assert.That(offGrid.Comp1.ParentUid, Is.EqualTo(map.Owner)); Assert.That(otherEnt.Comp1.ParentUid, Is.EqualTo(otherMap.Owner)); Assert.That(otherMap.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); - await server.WaitPost(() => ent.DeleteEntity(map)); - await server.WaitPost(() => ent.DeleteEntity(otherMap)); - Assert.That(ent.Count(), Is.EqualTo(0)); + await server.WaitPost(() => entMan.DeleteEntity(map)); + await server.WaitPost(() => entMan.DeleteEntity(otherMap)); + Assert.That(entMan.Count(), Is.EqualTo(0)); } } diff --git a/Robust.UnitTesting/Shared/EntitySerialization/TestComponents.cs b/Robust.UnitTesting/Shared/EntitySerialization/TestComponents.cs new file mode 100644 index 00000000000..d9d2873ee20 --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/TestComponents.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Serialization.Manager.Attributes; +using Robust.Shared.Utility; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +[RegisterComponent] +public sealed partial class EntitySaveTestComponent : Component +{ + /// + /// Give each entity a unique id to identify them across map saves & loads. + /// + [DataField] public string? Id; + + [DataField] public EntityUid? Entity; + + [DataField, AlwaysPushInheritance] public List List = []; + + /// + /// Find an entity with a with the matching id. + /// + public static Entity Find(string id, IEntityManager entMan) + { + var ents = entMan.AllEntities(); + var found = ents.Single(x => x.Comp.Id == id); + return (found.Owner, entMan.GetComponent(found.Owner), found.Comp); + } + + public static Entity Get(EntityUid uid, IEntityManager entMan) + { + return new Entity( + uid, + entMan.GetComponent(uid), + entMan.EnsureComponent(uid)); + } +} + +/// +/// Dummy tile definition for serializing grids. +/// +public sealed class TileDef(string id) : ITileDefinition +{ + public ushort TileId { get; set; } + public string Name => id; + public string ID => id; + public ResPath? Sprite => null; + public Dictionary EdgeSprites => new(); + public int EdgeSpritePriority => 0; + public float Friction => 0; + public byte Variants => 0; + public void AssignTileId(ushort id) => TileId = id; +} From 9d718073dc9d4911b2ccfb63c917ad5640c54890 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 18:55:47 +1300 Subject: [PATCH 16/27] Save -> TrySave Also better handling for saving multiple entities individually --- Robust.Server/Console/Commands/MapCommands.cs | 4 +- .../EntitySerialization/EntitySerializer.cs | 53 ++++- .../EntitySerialization/SerializationEnums.cs | 12 +- .../Systems/MapLoaderSystem.Load.cs | 31 --- .../Systems/MapLoaderSystem.Save.cs | 194 +++++++++++++----- .../Systems/MapLoaderSystem.cs | 49 +++++ .../Map/Events/MapSerializationEvents.cs | 19 +- .../AlwaysPushSerializationTest.cs | 2 +- .../AutoIncludeSerializationTest.cs | 16 +- .../Shared/GameObjects/ContainerTests.cs | 2 +- 10 files changed, 256 insertions(+), 126 deletions(-) diff --git a/Robust.Server/Console/Commands/MapCommands.cs b/Robust.Server/Console/Commands/MapCommands.cs index 3a8e5d822e2..5a9b7c1c41c 100644 --- a/Robust.Server/Console/Commands/MapCommands.cs +++ b/Robust.Server/Console/Commands/MapCommands.cs @@ -43,7 +43,7 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) return; } - _ent.System().SaveGrid(uid, args[1]); + _ent.System().TrySaveGrid(uid, args[1]); shell.WriteLine("Save successful. Look in the user data directory."); } @@ -207,7 +207,7 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) } shell.WriteLine(Loc.GetString("cmd-savemap-attempt", ("mapId", mapId), ("path", args[1]))); - _system.GetEntitySystem().SaveMap(mapId, args[1]); + _system.GetEntitySystem().TrySaveMap(mapId, new ResPath(args[1])); shell.WriteLine(Loc.GetString("cmd-savemap-success")); } } diff --git a/Robust.Shared/EntitySerialization/EntitySerializer.cs b/Robust.Shared/EntitySerialization/EntitySerializer.cs index d97e7591be5..6dcf680390a 100644 --- a/Robust.Shared/EntitySerialization/EntitySerializer.cs +++ b/Robust.Shared/EntitySerialization/EntitySerializer.cs @@ -77,6 +77,16 @@ public sealed class EntitySerializer : ISerializationContext, ITypeSerializer public EntityUid Truncate { get; private set; } + /// + /// List of all entities that have previously been ignored via . + /// + /// + /// 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. + /// + public readonly HashSet Truncated = new(); + public readonly SerializationOptions Options; /// @@ -212,6 +222,7 @@ public void SerializeEntityRecursive(EntityUid root) throw new Exception($"{EntMan.ToPrettyString(root)} is not serializable"); Truncate = EntMan.GetComponent(root).ParentUid; + Truncated.Add(Truncate); InitializeTileMap(root); HashSet entities = new(); RecursivelyIncludeChildren(root, entities); @@ -237,6 +248,10 @@ private void InitializeTileMap(EntityUid root) // 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)) @@ -253,7 +268,7 @@ private bool FindSavedTileMap(EntityUid root, [NotNullWhen(true)] out Dictionary return true; } - // If the root is a map, iterate over all of its children and grab the first grid with a mapping + // iterate over all of its children and grab the first grid with a mapping var xform = EntMan.GetComponent(root); foreach (var child in xform._children) { @@ -375,6 +390,11 @@ 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 a orphan. + // uhhh.... I guess its fine? if (EntityData.ContainsKey(saveId)) return; @@ -700,6 +720,12 @@ public int GetYamlUid(EntityUid uid) 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)) { @@ -746,9 +772,19 @@ public void ReserveYamlIds(HashSet entities) 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) @@ -800,10 +836,7 @@ public DataNode Write( ISerializationContext? context = null) { if (YamlUidMap.TryGetValue(value, out var yamlId)) - { - DebugTools.Assert(value != Truncate); return new ValueDataNode(yamlId.ToString(CultureInfo.InvariantCulture)); - } if (CurrentComponent == _xformName) { @@ -819,12 +852,6 @@ public DataNode Write( return new ValueDataNode("invalid"); } - if (Truncate != EntityUid.Invalid && value == Truncate) - { - _log.Error( - $"{EntMan.ToPrettyString(CurrentEntity)} is attempting to serialize references to a truncated parent entity {EntMan.ToPrettyString(Truncate)}."); - } - if (value == EntityUid.Invalid) { if (Options.MissingEntityBehaviour != MissingEntityBehaviour.Ignore) @@ -833,6 +860,12 @@ public DataNode Write( return new ValueDataNode("invalid"); } + 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: diff --git a/Robust.Shared/EntitySerialization/SerializationEnums.cs b/Robust.Shared/EntitySerialization/SerializationEnums.cs index 7a15fea9b56..657e7a929e6 100644 --- a/Robust.Shared/EntitySerialization/SerializationEnums.cs +++ b/Robust.Shared/EntitySerialization/SerializationEnums.cs @@ -64,11 +64,16 @@ public enum MissingEntityBehaviour /// /// Automatically include & serialize any referenced entity. Note that this means that the missing entity's - /// parents will also be included, however this will not include other children. E.g., if serializing a grid that - /// references an entity on the map, this will also cause the map to get serialized, but will not necessarily + /// parents will (generally) also be included, however this will not include other children. E.g., if serializing a + /// grid that references an entity on the map, this will also cause the map to get serialized, but will not necessarily /// serialize everything on the map. /// /// + /// If trying to serialize an entity without its parent (i.e., its parent is truncated via + /// ), this will try to respect that. E.g., if a referenced entity is on the + /// same map as a grid that is getting serialized, it should include the entity without including the map. + /// + /// /// Note that this might unexpectedly change the . I.e., trying to serialize a grid might /// accidentally lead to serializing a (partial?) map file. /// @@ -76,7 +81,8 @@ public enum MissingEntityBehaviour /// /// Variant of that will also automatically include the children of any entities that - /// that are automatically included. + /// that are automatically included. Note that because auto-inclusion generally needs to include an entity's + /// parents, this will include more than just the missing entity's direct children. /// AutoIncludeChildren, } diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs index 7dcf98c82bf..2656f9c2a7d 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs @@ -353,37 +353,6 @@ private bool TryGetReader(ResPath resPath, [NotNullWhen(true)] out TextReader? r return false; } - public bool TryReadFile(ResPath file, [NotNullWhen(true)] out MappingDataNode? data) - { - var resPath = file.ToRootedPath(); - data = null; - - if (!TryGetReader(resPath, out var reader)) - return false; - - Log.Info($"Loading file: {resPath}"); - _stopwatch.Restart(); - - using var textReader = reader; - var documents = DataNodeParser.ParseYamlStream(reader).ToArray(); - Log.Debug($"Loaded yml stream in {_stopwatch.Elapsed}"); - - // Yes, logging errors in a "try" method is kinda shit, but it was throwing exceptions when I found it and it does - // make sense to at least provide some kind of feedback for why it failed. - switch (documents.Length) - { - case < 1: - Log.Error("Stream has no YAML documents."); - return false; - case > 1: - Log.Error("Stream too many YAML documents. Map files store exactly one."); - return false; - default: - data = (MappingDataNode) documents[0].Root; - return true; - } - } - private void ApplyTransform(EntityDeserializer deserializer, MapLoadOptions opts) { if (opts.Rotation == Angle.Zero && opts.Offset == Vector2.Zero) diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs index 895d028af04..11e8a88be38 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs @@ -1,14 +1,11 @@ using System; -using Robust.Shared.ContentPack; +using System.Collections.Generic; +using System.Linq; using Robust.Shared.GameObjects; using Robust.Shared.Map; -using Robust.Shared.Map.Components; using Robust.Shared.Map.Events; -using Robust.Shared.Serialization; using Robust.Shared.Serialization.Markdown.Mapping; using Robust.Shared.Utility; -using YamlDotNet.Core; -using YamlDotNet.RepresentationModel; namespace Robust.Shared.EntitySerialization.Systems; @@ -20,112 +17,199 @@ public sealed partial class MapLoaderSystem /// /// Recursively serialize the given entity and its children. /// - public (MappingDataNode Node, FileCategory Category) SerializeEntityRecursive(EntityUid uid, SerializationOptions? options = null) + public (MappingDataNode Node, FileCategory Category) SerializeEntitiesRecursive( + HashSet entities, + SerializationOptions? options = null) { _stopwatch.Restart(); + if (!entities.All(Exists)) + throw new Exception($"Cannot serialize deleted entities"); + + Log.Info($"Serializing entities: {string.Join(", ", entities.Select(x => ToPrettyString(x).ToString()))}"); + var ev = new BeforeSerializationEvent(entities); + RaiseLocalEvent(ev); + + // In case no options were provided, we assume that if all of the starting entities are pre-init, we should + // expect that **all** entities that get serialized should be pre-init. var opts = options ?? SerializationOptions.Default with { - ExpectPreInit = (LifeStage(uid) < EntityLifeStage.MapInitialized) + ExpectPreInit = (entities.All(x => LifeStage(x) < EntityLifeStage.MapInitialized)) }; var serializer = new EntitySerializer(_dependency, opts); serializer.OnIsSerializeable += OnIsSerializable; - serializer.SerializeEntityRecursive(uid); + foreach (var ent in entities) + { + serializer.SerializeEntityRecursive(ent); + } var data = serializer.Write(); var cat = serializer.GetCategory(); + var ev2 = new AfterSerializationEvent(entities, data, cat); + RaiseLocalEvent(ev2); + Log.Debug($"Serialized {serializer.EntityData.Count} entities in {_stopwatch.Elapsed}"); return (data, cat); } /// - /// This method will serialize an entity and all of its children, and will write the result to a yaml file. - /// If you are specifically trying to save a single grid or map, you should probably use SaveGrid or SaveMap instead. + /// Serialize a standard (non-grid, non-map) entity and all of its children and write the result to a + /// yaml file. /// - public FileCategory SaveEntity(EntityUid uid, ResPath path, SerializationOptions? options = null) + public bool TrySaveEntity(EntityUid entity, ResPath path, SerializationOptions? options = null) { - if (!Exists(uid)) - throw new Exception($"{uid} does not exist."); - - var ev = new BeforeSaveEvent(uid, Transform(uid).MapUid); - RaiseLocalEvent(ev); - - Log.Debug($"Saving entity {ToPrettyString(uid)} to {path}"); - - var data = SerializeEntityRecursive(uid, options); + if (_mapQuery.HasComp(entity)) + { + Log.Error($"{ToPrettyString(entity)} is a map. Use {nameof(TrySaveMap)}."); + return false; + } - var ev2 = new AfterSaveEvent(uid, Transform(uid).MapUid, data.Node, data.Category); - RaiseLocalEvent(ev2); + if (_gridQuery.HasComp(entity)) + { + Log.Error($"{ToPrettyString(entity)} is a grid. Use {nameof(TrySaveGrid)}."); + return false; + } - var document = new YamlDocument(ev2.Node.ToYaml()); - var resPath = path.ToRootedPath(); - _resourceManager.UserData.CreateDir(resPath.Directory); + var opts = options ?? SerializationOptions.Default; + opts.Category = FileCategory.Entity; - using var writer = _resourceManager.UserData.OpenWriteText(resPath); + MappingDataNode data; + FileCategory cat; + try + { + (data, cat) = SerializeEntitiesRecursive([entity], opts); + } + catch (Exception e) { - var stream = new YamlStream {document}; - stream.Save(new YamlMappingFix(new Emitter(writer)), false); + Log.Error($"Caught exception while trying to serialize entity {ToPrettyString(entity)}:\n{e}"); + return false; } - Log.Info($"Saved {ToPrettyString(uid)} to {path}"); + if (cat != FileCategory.Entity) + { + Log.Error($"Failed to save {ToPrettyString(entity)} as a singular entity. Output: {cat}"); + return false; + } - return data.Item2; + Write(path, data); + return true; } - public void SaveMap(MapId id, string path, SerializationOptions? options = null) - => SaveMap(id, new ResPath(path), options); - - public void SaveMap(MapId mapId, ResPath path, SerializationOptions? options = null) + /// + /// Serialize a map and all of its children and write the result to a yaml file. + /// + public bool TrySaveMap(MapId mapId, ResPath path, SerializationOptions? options = null) { - if (!_mapSystem.TryGetMap(mapId, out var mapUid)) - { - Log.Error($"Unable to find map {mapId}"); - return; - } + if (_mapSystem.TryGetMap(mapId, out var mapUid)) + return TrySaveMap(mapUid.Value, path, options); - SaveMap(mapUid.Value, path, options); + Log.Error($"Unable to find map {mapId}"); + return false; } - public void SaveMap(EntityUid map, ResPath path, SerializationOptions? options = null) + /// + /// Serialize a map and all of its children and write the result to a yaml file. + /// + public bool TrySaveMap(EntityUid map, ResPath path, SerializationOptions? options = null) { - if (!HasComp(map)) + if (!_mapQuery.HasComp(map)) { Log.Error($"{ToPrettyString(map)} is not a map."); - return; + return false; } var opts = options ?? SerializationOptions.Default; opts.Category = FileCategory.Map; - var cat = SaveEntity(map, path, opts); - if (cat != FileCategory.Map) + MappingDataNode data; + FileCategory cat; + try + { + (data, cat) = SerializeEntitiesRecursive([map], opts); + } + catch (Exception e) + { + Log.Error($"Caught exception while trying to serialize map {ToPrettyString(map)}:\n{e}"); + return false; + } + + if (cat != FileCategory.Entity) + { Log.Error($"Failed to save {ToPrettyString(map)} as a map. Output: {cat}"); - } + return false; + } - public void SaveGrid(EntityUid grid, string path, SerializationOptions? options = null) - => SaveGrid(grid, new ResPath(path), options); + Write(path, data); + return true; + } - public void SaveGrid(EntityUid grid, ResPath path, SerializationOptions? options = null) + /// + /// Serialize a grid and all of its children and write the result to a yaml file. + /// + public bool TrySaveGrid(EntityUid grid, ResPath path, SerializationOptions? options = null) { - if (!HasComp(grid)) + if (!_mapQuery.HasComp(grid)) { Log.Error($"{ToPrettyString(grid)} is not a grid."); - return; + return false; } - if (HasComp(grid)) + if (_gridQuery.HasComp(grid)) { - Log.Error($"{ToPrettyString(grid)} is a map, not (just) a grid."); - return; + Log.Error($"{ToPrettyString(grid)} is a map, not (just) a grid. Use {nameof(TrySaveMap)}"); + return false; } var opts = options ?? SerializationOptions.Default; opts.Category = FileCategory.Grid; - var cat = SaveEntity(grid, path, opts); + MappingDataNode data; + FileCategory cat; + try + { + (data, cat) = SerializeEntitiesRecursive([grid], opts); + } + catch (Exception e) + { + Log.Error($"Caught exception while trying to serialize grid {ToPrettyString(grid)}:\n{e}"); + return false; + } + if (cat != FileCategory.Grid) + { Log.Error($"Failed to save {ToPrettyString(grid)} as a grid. Output: {cat}"); + return false; + } + + Write(path, data); + return true; + } + + /// + /// Serialize one or more entities and all of their children to a yaml file. + /// If possible, use the map/grid specific variants instead. + /// + public bool TrySaveEntities(HashSet entities, ResPath path, SerializationOptions? options = null) + { + if (entities.Count == 0) + return false; + + var opts = options ?? SerializationOptions.Default; + + MappingDataNode data; + try + { + (data, _) = SerializeEntitiesRecursive(entities, opts); + } + catch (Exception e) + { + Log.Error($"Caught exception while trying to serialize entities:\n{e}"); + return false; + } + + Write(path, data); + return true; } } diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs index f3281998a50..282f1e656d8 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs @@ -1,8 +1,16 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; using Robust.Shared.ContentPack; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Map.Components; +using Robust.Shared.Serialization; +using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Mapping; using Robust.Shared.Timing; +using Robust.Shared.Utility; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; namespace Robust.Shared.EntitySerialization.Systems; @@ -32,4 +40,45 @@ public override void Initialize() _mapQuery = GetEntityQuery(); _gridQuery = GetEntityQuery(); } + + private void Write(ResPath path, MappingDataNode data) + { + var document = new YamlDocument(data.ToYaml()); + using var writer = _resourceManager.UserData.OpenWriteText(path); + { + var stream = new YamlStream {document}; + stream.Save(new YamlMappingFix(new Emitter(writer)), false); + } + } + + private bool TryReadFile(ResPath file, [NotNullWhen(true)] out MappingDataNode? data) + { + var resPath = file.ToRootedPath(); + data = null; + + if (!TryGetReader(resPath, out var reader)) + return false; + + Log.Info($"Loading file: {resPath}"); + _stopwatch.Restart(); + + using var textReader = reader; + var documents = DataNodeParser.ParseYamlStream(reader).ToArray(); + Log.Debug($"Loaded yml stream in {_stopwatch.Elapsed}"); + + // Yes, logging errors in a "try" method is kinda shit, but it was throwing exceptions when I found it and it does + // make sense to at least provide some kind of feedback for why it failed. + switch (documents.Length) + { + case < 1: + Log.Error("Stream has no YAML documents."); + return false; + case > 1: + Log.Error("Stream too many YAML documents. Map files store exactly one."); + return false; + default: + data = (MappingDataNode) documents[0].Root; + return true; + } + } } diff --git a/Robust.Shared/Map/Events/MapSerializationEvents.cs b/Robust.Shared/Map/Events/MapSerializationEvents.cs index a8c0f38a841..4854b20ee5f 100644 --- a/Robust.Shared/Map/Events/MapSerializationEvents.cs +++ b/Robust.Shared/Map/Events/MapSerializationEvents.cs @@ -30,22 +30,11 @@ public sealed class BeforeEntityReadEvent } /// -/// This event is broadcast just before an entity gets serialized. +/// This event is broadcast just before the given entities (and their children) are serialized. /// -public sealed class BeforeSaveEvent(EntityUid entity, EntityUid? map) -{ - /// - /// The entity that is going to be saved. usually a map or grid. - /// - public EntityUid Entity = entity; - - /// - /// The map that the is on. - /// - public EntityUid? Map = map; -} +public readonly record struct BeforeSerializationEvent(HashSet Entities); /// -/// This event is broadcast just after an entity gets serialized, but before it gets written to a yaml file. +/// This event is broadcast just after entities (and their children) have been serialized, but before it gets written to a yaml file. /// -public readonly record struct AfterSaveEvent(EntityUid Uid, EntityUid? Map, MappingDataNode Node, FileCategory Category); +public readonly record struct AfterSerializationEvent(HashSet Entities, MappingDataNode Node, FileCategory Category); diff --git a/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs index c2943cbb429..d786d61bd55 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/AlwaysPushSerializationTest.cs @@ -109,7 +109,7 @@ await server.WaitPost(() => // Save map to yaml var loader = server.System(); var map = server.System(); - loader.SaveMap(mapId, path); + Assert.That(loader.TrySaveMap(mapId, path)); // Delete the entities await server.WaitPost(() => map.DeleteMap(mapId)); diff --git a/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs index 31eea68ef4f..627fb347915 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs @@ -78,8 +78,8 @@ await server.WaitPost(() => // This will cause the null-space entity to be lost. // Save the map, then delete all the entities. Assert.That(entMan.Count(), Is.EqualTo(5)); - await server.WaitPost(() => loader.SaveMap(mapId, mapPath)); - await server.WaitPost(() => loader.SaveGrid(grid, gridPath)); + await server.WaitPost(() => Assert.That(loader.TrySaveMap(mapId, mapPath))); + await server.WaitPost(() => Assert.That(loader.TrySaveGrid(grid, gridPath))); await server.WaitPost(() => mapSys.DeleteMap(mapId)); Assert.That(entMan.Count(), Is.EqualTo(1)); await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); @@ -127,8 +127,8 @@ await server.WaitPost(() => onGrid.Comp2.Entity = nullSpace.Owner; Assert.That(entMan.Count(), Is.EqualTo(5)); - await server.WaitPost(() => loader.SaveMap(mapId, mapPath)); - await server.WaitPost(() => loader.SaveGrid(grid, gridPath)); + await server.WaitPost(() => Assert.That(loader.TrySaveMap(mapId, mapPath))); + await server.WaitPost(() => Assert.That(loader.TrySaveGrid(grid, gridPath))); await server.WaitPost(() => mapSys.DeleteMap(mapId)); Assert.That(entMan.Count(), Is.EqualTo(1)); await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); @@ -183,8 +183,8 @@ await server.WaitPost(() => // By default it should log an error, but tests don't have a nice way to validate that an error was logged, so we'll just suppress it. var opts = SerializationOptions.Default with {MissingEntityBehaviour = MissingEntityBehaviour.Ignore}; Assert.That(entMan.Count(), Is.EqualTo(6)); - await server.WaitPost(() => loader.SaveMap(mapId, mapPath, opts)); - await server.WaitPost(() => loader.SaveGrid(grid, gridPath, opts)); + await server.WaitPost(() => Assert.That(loader.TrySaveMap(mapId, mapPath, opts))); + await server.WaitPost(() => Assert.That(loader.TrySaveGrid(grid, gridPath, opts))); await server.WaitPost(() => mapSys.DeleteMap(mapId)); await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); await server.WaitPost(() => entMan.DeleteEntity(otherMap)); @@ -231,8 +231,8 @@ await server.WaitPost(() => Assert.That(entMan.Count(), Is.EqualTo(6)); opts = opts with {MissingEntityBehaviour = MissingEntityBehaviour.AutoIncludeChildren}; - await server.WaitPost(() => loader.SaveEntity(map.Owner, mapPath, opts)); - await server.WaitPost(() => loader.SaveEntity(grid.Owner, gridPath, opts)); + await server.WaitPost(() => loader.TrySaveEntity(map.Owner, mapPath, opts)); + await server.WaitPost(() => loader.TrySaveEntity(grid.Owner, gridPath, opts)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); await server.WaitPost(() => entMan.DeleteEntity(otherMap)); Assert.That(entMan.Count(), Is.EqualTo(0)); diff --git a/Robust.UnitTesting/Shared/GameObjects/ContainerTests.cs b/Robust.UnitTesting/Shared/GameObjects/ContainerTests.cs index 3a81bda415e..5bcf4155728 100644 --- a/Robust.UnitTesting/Shared/GameObjects/ContainerTests.cs +++ b/Robust.UnitTesting/Shared/GameObjects/ContainerTests.cs @@ -318,7 +318,7 @@ await server.WaitAssertion(() => // save the map var mapLoader = sEntManager.EntitySysManager.GetEntitySystem(); - mapLoader.SaveMap(mapIdOne, path); + Assert.That(mapLoader.TrySaveMap(mapIdOne, path)); mapSys.DeleteMap(mapIdOne); }); From db9d0e3cc703e9f65efb3ceb0cc921a585119872 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 20:27:41 +1300 Subject: [PATCH 17/27] Create new partial class for map loading --- Robust.Server/Console/Commands/MapCommands.cs | 2 +- .../EntitySerialization/EntitySerializer.cs | 8 + .../EntitySerialization/SerializationEnums.cs | 2 +- .../Systems/MapLoaderSystem.Load.cs | 337 +++--------------- .../Systems/MapLoaderSystem.LoadMap.cs | 242 +++++++++++++ .../Systems/MapLoaderSystem.Save.cs | 31 +- .../Systems/MapLoaderSystem.cs | 48 +++ .../AutoIncludeSerializationTest.cs | 34 +- .../EntitySerialization/TestComponents.cs | 5 +- 9 files changed, 398 insertions(+), 311 deletions(-) create mode 100644 Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.LoadMap.cs diff --git a/Robust.Server/Console/Commands/MapCommands.cs b/Robust.Server/Console/Commands/MapCommands.cs index 5a9b7c1c41c..511fdc954f4 100644 --- a/Robust.Server/Console/Commands/MapCommands.cs +++ b/Robust.Server/Console/Commands/MapCommands.cs @@ -43,7 +43,7 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) return; } - _ent.System().TrySaveGrid(uid, args[1]); + _ent.System().TrySaveGrid(uid, new ResPath(args[1])); shell.WriteLine("Save successful. Look in the user data directory."); } diff --git a/Robust.Shared/EntitySerialization/EntitySerializer.cs b/Robust.Shared/EntitySerialization/EntitySerializer.cs index 6dcf680390a..ec85e02ab73 100644 --- a/Robust.Shared/EntitySerialization/EntitySerializer.cs +++ b/Robust.Shared/EntitySerialization/EntitySerializer.cs @@ -804,7 +804,15 @@ public void ReserveYamlId(EntityUid 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); } diff --git a/Robust.Shared/EntitySerialization/SerializationEnums.cs b/Robust.Shared/EntitySerialization/SerializationEnums.cs index 657e7a929e6..36156f1e8ab 100644 --- a/Robust.Shared/EntitySerialization/SerializationEnums.cs +++ b/Robust.Shared/EntitySerialization/SerializationEnums.cs @@ -12,7 +12,7 @@ public enum FileCategory : byte Unknown, /// - /// File should contain a single root entity, its children, and maybe some null-space entities. + /// File should contain a single orphaned entity, its children, and maybe some null-space entities. /// Entity, diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs index 2656f9c2a7d..1cfd5fbdde0 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs @@ -17,16 +17,38 @@ namespace Robust.Shared.EntitySerialization.Systems; +// This partial class file contains methods for loading generic entities and grids. Map specific methods are in another +// file public sealed partial class MapLoaderSystem { /// - /// Tries to load entities from a yaml file. If you are specifically trying to load a map or grid, you should - /// probably be using or instead + /// Tries to load entities from a yaml file. Whenever possible, you should try to use , + /// , or instead. + /// + public bool TryLoadGeneric( + ResPath file, + [NotNullWhen(true)] out HashSet>? maps, + [NotNullWhen(true)] out HashSet>? grids, + MapLoadOptions? options = null) + { + grids = null; + maps = null; + if (!TryLoadGeneric(file, out var data, options)) + return false; + + maps = data.Maps; + grids = data.Grids; + return true; + } + + /// + /// Tries to load entities from a yaml file. Whenever possible, you should try to use , + /// , or instead. /// /// The file to load. /// Data class containing information about the loaded entities /// Optional Options for configuring loading behaviour. - public bool TryLoadEntities(ResPath file, [NotNullWhen(true)] out LoadResult? result, MapLoadOptions? options = null) + public bool TryLoadGeneric(ResPath file, [NotNullWhen(true)] out LoadResult? result, MapLoadOptions? options = null) { result = null; @@ -87,8 +109,8 @@ public bool TryLoadEntities(ResPath file, [NotNullWhen(true)] out LoadResult? re } // Reparent entities if loading entities onto an existing map. - var reparented = new HashSet(); - MergeMaps(deserializer, opts, reparented); + var merged = new HashSet(); + MergeMaps(deserializer, opts, merged); if (!SetMapId(deserializer, opts)) return false; @@ -108,78 +130,37 @@ public bool TryLoadEntities(ResPath file, [NotNullWhen(true)] out LoadResult? re } if (opts.MergeMap is {} map) - MapinitalizeReparented(reparented, map); + MapInitalizeMerged(merged, map); result = deserializer.Result; Log.Debug($"Loaded map in {_stopwatch.Elapsed}"); return true; } - private bool SetMapId(EntityDeserializer deserializer, MapLoadOptions opts) - { - if (opts.ForceMapId is not {} id) - return true; - - if (deserializer.Result.Maps.Count != 1) - { - Log.Error( - $"The {nameof(MapLoadOptions.ForceMapId)} option is only supported when loading a file containing a single map."); - Delete(deserializer.Result); - return false; - } - - var map = deserializer.Result.Maps.Single(); - _mapSystem.AssignMapId(map, id); - return true; - } - /// - /// Tries to load a file and return a list of grids and maps + /// Tries to load a regular (non-map, non-grid) entity from a file. + /// The loaded entity will initially be in null-space. + /// If the file does not contain exactly one orphaned entity, this will return false and delete loaded entities. /// - public bool TryLoadEntities( - ResPath file, - [NotNullWhen(true)] out HashSet>? maps, - [NotNullWhen(true)] out HashSet>? grids, - MapLoadOptions? options = null) - { - grids = null; - maps = null; - if (!TryLoadEntities(file, out var data, options)) - return false; - - maps = data.Maps; - grids = data.Grids; - return true; - } - - /// - /// Tries to load a grid entity from a file and parent it to the given map. - /// If the file does not contain exactly one grid, this will return false and delete loaded entities. - /// - public bool TryLoadGrid( - MapId map, + public bool TryLoadEntity( ResPath path, - [NotNullWhen(true)] out Entity? grid, - DeserializationOptions? options = null, - Vector2 offset = default, - Angle rot = default) + [NotNullWhen(true)] out Entity? entity, + DeserializationOptions? options = null) { var opts = new MapLoadOptions { - MergeMap = map, - Offset = offset, - Rotation = rot, DeserializationOptions = options ?? DeserializationOptions.Default, - ExpectedCategory = FileCategory.Grid + ExpectedCategory = FileCategory.Entity }; - grid = null; - if (!TryLoadEntities(path, out var result, opts)) + entity = null; + if (!TryLoadGeneric(path, out var result, opts)) return false; - if (result.Grids.Count == 1) + if (result.Orphans.Count == 1) { - grid = result.Grids.Single(); + var uid = result.Orphans.Single(); + entity = (uid, Transform(uid)); return true; } @@ -188,120 +169,33 @@ public bool TryLoadGrid( } /// - /// Attempts to load a file containing a single map, and merge its children onto another map. After which the - /// loaded map gets deleted. - /// - public bool TryMergeMap( - MapId mapId, - ResPath path, - [NotNullWhen(true)] out Entity? map, - [NotNullWhen(true)] out HashSet>? grids, - DeserializationOptions? options = null, - Vector2 offset = default, - Angle rot = default) - { - map = null; - grids = null; - - var opts = new MapLoadOptions - { - Offset = offset, - Rotation = rot, - DeserializationOptions = options ?? DeserializationOptions.Default, - ExpectedCategory = FileCategory.Map - }; - - if (!_mapSystem.MapExists(mapId)) - throw new Exception($"Target map {mapId} does not exist"); - - opts.MergeMap = mapId; - if (!TryLoadEntities(path, out var result, opts)) - return false; - - if (!_mapSystem.TryGetMap(mapId, out var uid) || !TryComp(uid, out MapComponent? comp)) - return false; - - map = new(uid.Value, comp); - grids = result.Grids; - return true; - } - - /// - /// Attempts to load a file containing a single map, assign it the given map id. - /// - /// - /// If possible, it is better to use which automatically assigns a . - /// - /// - /// Note that this will not automatically initialize the map, unless specified via the . - /// - public bool TryLoadMapWithId( - MapId mapId, - ResPath path, - [NotNullWhen(true)] out Entity? map, - [NotNullWhen(true)] out HashSet>? grids, - DeserializationOptions? options = null, - Vector2 offset = default, - Angle rot = default) - { - map = null; - grids = null; - - var opts = new MapLoadOptions - { - Offset = offset, - Rotation = rot, - DeserializationOptions = options ?? DeserializationOptions.Default, - ExpectedCategory = FileCategory.Map - }; - - if (_mapSystem.MapExists(mapId)) - throw new Exception($"Target map already exists"); - - opts.ForceMapId = mapId; - if (!TryLoadEntities(path, out var result, opts)) - return false; - - if (!_mapSystem.TryGetMap(mapId, out var uid) || !TryComp(uid, out MapComponent? comp)) - return false; - - map = new(uid.Value, comp); - grids = result.Grids; - return true; - } - - /// - /// Attempts to load a file containing a single map. - /// If the file does not contain exactly one map, this will return false and delete all loaded entities. + /// Tries to load a grid entity from a file and parent it to the given map. + /// If the file does not contain exactly one grid, this will return false and delete loaded entities. /// - /// - /// Note that this will not automatically initialize the map, unless specified via the . - /// - public bool TryLoadMap( + public bool TryLoadGrid( + MapId map, ResPath path, - [NotNullWhen(true)] out Entity? map, - [NotNullWhen(true)] out HashSet>? grids, + [NotNullWhen(true)] out Entity? grid, DeserializationOptions? options = null, Vector2 offset = default, Angle rot = default) { var opts = new MapLoadOptions { + MergeMap = map, Offset = offset, Rotation = rot, DeserializationOptions = options ?? DeserializationOptions.Default, - ExpectedCategory = FileCategory.Map + ExpectedCategory = FileCategory.Grid }; - map = null; - grids = null; - if (!TryLoadEntities(path, out var result, opts)) + grid = null; + if (!TryLoadGeneric(path, out var result, opts)) return false; - if (result.Maps.Count == 1) + if (result.Grids.Count == 1) { - map = result.Maps.First(); - grids = result.Grids; + grid = result.Grids.Single(); return true; } @@ -309,50 +203,6 @@ public bool TryLoadMap( return false; } - /// - /// Helper method for deleting all loaded entities. - /// - public void Delete(LoadResult result) - { - foreach (var uid in result.Maps) - { - Del(uid); - } - - foreach (var uid in result.Orphans) - { - Del(uid); - } - - foreach (var uid in result.Entities) - { - Del(uid); - } - } - - private bool TryGetReader(ResPath resPath, [NotNullWhen(true)] out TextReader? reader) - { - if (_resourceManager.UserData.Exists(resPath)) - { - // Log warning if file exists in both user and content data. - if (_resourceManager.ContentFileExists(resPath)) - Log.Warning("Reading map user data instead of content"); - - reader = _resourceManager.UserData.OpenText(resPath); - return true; - } - - if (_resourceManager.TryContentFileRead(resPath, out var contentReader)) - { - reader = new StreamReader(contentReader); - return true; - } - - Log.Error($"File not found: {resPath}"); - reader = null; - return false; - } - private void ApplyTransform(EntityDeserializer deserializer, MapLoadOptions opts) { if (opts.Rotation == Angle.Zero && opts.Offset == Vector2.Zero) @@ -386,89 +236,4 @@ private void ApplyTransform(EntityDeserializer deserializer, MapLoadOptions opts DebugTools.Assert(!xform.NoLocalRotation || xform.LocalRotation == 0); } } - - private void MapinitalizeReparented(HashSet reparented, MapId targetId) - { - // fuck me I hate this map merging bullshit. - // loading a map "onto" another map shouldn't need to be supported by the generic map loading methods. - // If something needs to do that, it should implement it itself. - // AFAIK this only exists for the loadgamemap command? - - if (!_mapSystem.TryGetMap(targetId, out var targetUid)) - throw new Exception($"Target map {targetId} does not exist"); - - if (_mapSystem.IsInitialized(targetUid.Value)) - { - foreach (var uid in reparented) - { - _mapSystem.RecursiveMapInit(uid); - } - } - - var paused = _mapSystem.IsPaused(targetUid.Value); - foreach (var uid in reparented) - { - _mapSystem.RecursiveSetPaused(uid, paused); - } - } - - private void MergeMaps(EntityDeserializer deserializer, MapLoadOptions opts, HashSet reparented) - { - if (opts.MergeMap is not {} targetId) - return; - - if (!_mapSystem.TryGetMap(targetId, out var targetUid)) - throw new Exception($"Target map {targetId} does not exist"); - - deserializer.Result.Category = FileCategory.Unknown; - var rotation = opts.Rotation; - var matrix = Matrix3Helpers.CreateTransform(opts.Offset, rotation); - var target = new Entity(targetUid.Value, Transform(targetUid.Value)); - - foreach (var uid in deserializer.Result.Orphans) - { - Reparent(reparented, uid, target, matrix, rotation); - } - - deserializer.Result.Orphans.Clear(); - - foreach (var uid in deserializer.Result.Entities) - { - var xform = Transform(uid); - if (!_mapQuery.HasComp(xform.ParentUid)) - continue; - - // The original comment around this bit of logic was just: - // > Smelly - // I don't know what sloth meant by that, but I guess loading a grid-map onto another grid-map for whatever - // reason must be done without offsets? - // Or more generally, loading a mapgrid onto another (potentially non-mapgrid) map is just generally kind of weird. - if (_gridQuery.HasComponent(xform.ParentUid)) - { - Reparent(reparented, uid, target, Matrix3x2.Identity, Angle.Zero); - } - else - { - Reparent(reparented, uid, target, matrix, rotation); - } - } - - deserializer.ToDelete.UnionWith(deserializer.Result.Maps.Select(x => x.Owner)); - deserializer.Result.Maps.Clear(); - } - - private void Reparent( - HashSet reparented, - EntityUid uid, - Entity target, - in Matrix3x2 matrix, - Angle rotation) - { - reparented.Add(uid); - var xform = Transform(uid); - var angle = xform.LocalRotation + rotation; - var pos = Vector2.Transform(xform.LocalPosition, matrix); - var coords = new EntityCoordinates(target.Owner, pos); - _xform.SetCoordinates((uid, xform, MetaData(uid)), coords, rotation: angle, newParent: target.Comp); - } } diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.LoadMap.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.LoadMap.cs new file mode 100644 index 00000000000..507643c7a31 --- /dev/null +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.LoadMap.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Maths; +using Robust.Shared.Utility; +using Vector2 = System.Numerics.Vector2; + +namespace Robust.Shared.EntitySerialization.Systems; + +// This partial class file contains methods specific to loading maps +public sealed partial class MapLoaderSystem +{ + /// + /// Attempts to load a file containing a single map. + /// If the file does not contain exactly one map, this will return false and delete all loaded entities. + /// + /// + /// Note that this will not automatically initialize the map, unless specified via the . + /// + public bool TryLoadMap( + ResPath path, + [NotNullWhen(true)] out Entity? map, + [NotNullWhen(true)] out HashSet>? grids, + DeserializationOptions? options = null, + Vector2 offset = default, + Angle rot = default) + { + var opts = new MapLoadOptions + { + Offset = offset, + Rotation = rot, + DeserializationOptions = options ?? DeserializationOptions.Default, + ExpectedCategory = FileCategory.Map + }; + + map = null; + grids = null; + if (!TryLoadGeneric(path, out var result, opts)) + return false; + + if (result.Maps.Count == 1) + { + map = result.Maps.First(); + grids = result.Grids; + return true; + } + + Delete(result); + return false; + } + + /// + /// Attempts to load a file containing a single map, assign it the given map id. + /// + /// + /// If possible, it is better to use which automatically assigns a . + /// + /// + /// Note that this will not automatically initialize the map, unless specified via the . + /// + public bool TryLoadMapWithId( + MapId mapId, + ResPath path, + [NotNullWhen(true)] out Entity? map, + [NotNullWhen(true)] out HashSet>? grids, + DeserializationOptions? options = null, + Vector2 offset = default, + Angle rot = default) + { + map = null; + grids = null; + + var opts = new MapLoadOptions + { + Offset = offset, + Rotation = rot, + DeserializationOptions = options ?? DeserializationOptions.Default, + ExpectedCategory = FileCategory.Map + }; + + if (_mapSystem.MapExists(mapId)) + throw new Exception($"Target map already exists"); + + opts.ForceMapId = mapId; + if (!TryLoadGeneric(path, out var result, opts)) + return false; + + if (!_mapSystem.TryGetMap(mapId, out var uid) || !TryComp(uid, out MapComponent? comp)) + return false; + + map = new(uid.Value, comp); + grids = result.Grids; + return true; + } + + /// + /// Attempts to load a file containing a single map, and merge its children onto another map. After which the + /// loaded map gets deleted. + /// + public bool TryMergeMap( + MapId mapId, + ResPath path, + [NotNullWhen(true)] out Entity? map, + [NotNullWhen(true)] out HashSet>? grids, + DeserializationOptions? options = null, + Vector2 offset = default, + Angle rot = default) + { + map = null; + grids = null; + + var opts = new MapLoadOptions + { + Offset = offset, + Rotation = rot, + DeserializationOptions = options ?? DeserializationOptions.Default, + ExpectedCategory = FileCategory.Map + }; + + if (!_mapSystem.MapExists(mapId)) + throw new Exception($"Target map {mapId} does not exist"); + + opts.MergeMap = mapId; + if (!TryLoadGeneric(path, out var result, opts)) + return false; + + if (!_mapSystem.TryGetMap(mapId, out var uid) || !TryComp(uid, out MapComponent? comp)) + return false; + + map = new(uid.Value, comp); + grids = result.Grids; + return true; + } + + private void MergeMaps(EntityDeserializer deserializer, MapLoadOptions opts, HashSet merged) + { + if (opts.MergeMap is not {} targetId) + return; + + if (!_mapSystem.TryGetMap(targetId, out var targetUid)) + throw new Exception($"Target map {targetId} does not exist"); + + deserializer.Result.Category = FileCategory.Unknown; + var rotation = opts.Rotation; + var matrix = Matrix3Helpers.CreateTransform(opts.Offset, rotation); + var target = new Entity(targetUid.Value, Transform(targetUid.Value)); + + foreach (var uid in deserializer.Result.Orphans) + { + Merge(merged, uid, target, matrix, rotation); + } + + deserializer.Result.Orphans.Clear(); + + foreach (var uid in deserializer.Result.Entities) + { + var xform = Transform(uid); + if (!_mapQuery.HasComp(xform.ParentUid)) + continue; + + // The original comment around this bit of logic was just: + // > Smelly + // I don't know what sloth meant by that, but I guess loading a grid-map onto another grid-map for whatever + // reason must be done without offsets? + // Or more generally, loading a mapgrid onto another (potentially non-mapgrid) map is just generally kind of weird. + if (_gridQuery.HasComponent(xform.ParentUid)) + { + Merge(merged, uid, target, Matrix3x2.Identity, Angle.Zero); + } + else + { + Merge(merged, uid, target, matrix, rotation); + } + } + + deserializer.ToDelete.UnionWith(deserializer.Result.Maps.Select(x => x.Owner)); + deserializer.Result.Maps.Clear(); + } + + private void Merge( + HashSet merged, + EntityUid uid, + Entity target, + in Matrix3x2 matrix, + Angle rotation) + { + merged.Add(uid); + var xform = Transform(uid); + var angle = xform.LocalRotation + rotation; + var pos = Vector2.Transform(xform.LocalPosition, matrix); + var coords = new EntityCoordinates(target.Owner, pos); + _xform.SetCoordinates((uid, xform, MetaData(uid)), coords, rotation: angle, newParent: target.Comp); + } + + private void MapInitalizeMerged(HashSet merged, MapId targetId) + { + // fuck me I hate this map merging bullshit. + // loading a map "onto" another map shouldn't need to be supported by the generic map loading methods. + // If something needs to do that, it should implement it itself. + // AFAIK this only exists for the loadgamemap command? + + if (!_mapSystem.TryGetMap(targetId, out var targetUid)) + throw new Exception($"Target map {targetId} does not exist"); + + if (_mapSystem.IsInitialized(targetUid.Value)) + { + foreach (var uid in merged) + { + _mapSystem.RecursiveMapInit(uid); + } + } + + var paused = _mapSystem.IsPaused(targetUid.Value); + foreach (var uid in merged) + { + _mapSystem.RecursiveSetPaused(uid, paused); + } + } + + private bool SetMapId(EntityDeserializer deserializer, MapLoadOptions opts) + { + if (opts.ForceMapId is not { } id) + return true; + + if (deserializer.Result.Maps.Count != 1) + { + Log.Error( + $"The {nameof(MapLoadOptions.ForceMapId)} option is only supported when loading a file containing a single map."); + Delete(deserializer.Result); + return false; + } + + var map = deserializer.Result.Maps.Single(); + _mapSystem.AssignMapId(map, id); + return true; + } +} diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs index 11e8a88be38..82000aaefa0 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs @@ -9,6 +9,7 @@ namespace Robust.Shared.EntitySerialization.Systems; +// This partial class file contains methods for serializing and saving entities, grids, and maps. public sealed partial class MapLoaderSystem { /// @@ -135,7 +136,7 @@ public bool TrySaveMap(EntityUid map, ResPath path, SerializationOptions? option return false; } - if (cat != FileCategory.Entity) + if (cat != FileCategory.Map) { Log.Error($"Failed to save {ToPrettyString(map)} as a map. Output: {cat}"); return false; @@ -150,13 +151,13 @@ public bool TrySaveMap(EntityUid map, ResPath path, SerializationOptions? option /// public bool TrySaveGrid(EntityUid grid, ResPath path, SerializationOptions? options = null) { - if (!_mapQuery.HasComp(grid)) + if (!_gridQuery.HasComp(grid)) { Log.Error($"{ToPrettyString(grid)} is not a grid."); return false; } - if (_gridQuery.HasComp(grid)) + if (_mapQuery.HasComp(grid)) { Log.Error($"{ToPrettyString(grid)} is a map, not (just) a grid. Use {nameof(TrySaveMap)}"); return false; @@ -187,12 +188,32 @@ public bool TrySaveGrid(EntityUid grid, ResPath path, SerializationOptions? opti return true; } + /// + /// Serialize an entities and all of their children to a yaml file. + /// This makes no assumptions about the expected entity or resulting file category. + /// If possible, use the map/grid specific variants instead. + /// + public bool TrySaveGeneric( + EntityUid uid, + ResPath path, + out FileCategory category, + SerializationOptions? options = null) + { + return TrySaveGeneric([uid], path, out category, options); + } + /// /// Serialize one or more entities and all of their children to a yaml file. + /// This makes no assumptions about the expected entity or resulting file category. /// If possible, use the map/grid specific variants instead. /// - public bool TrySaveEntities(HashSet entities, ResPath path, SerializationOptions? options = null) + public bool TrySaveGeneric( + HashSet entities, + ResPath path, + out FileCategory category, + SerializationOptions? options = null) { + category = FileCategory.Unknown; if (entities.Count == 0) return false; @@ -201,7 +222,7 @@ public bool TrySaveEntities(HashSet entities, ResPath path, Serializa MappingDataNode data; try { - (data, _) = SerializeEntitiesRecursive(entities, opts); + (data, category) = SerializeEntitiesRecursive(entities, opts); } catch (Exception e) { diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs index 282f1e656d8..2cd0c65bbfe 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; using Robust.Shared.ContentPack; using Robust.Shared.GameObjects; @@ -43,6 +44,8 @@ public override void Initialize() private void Write(ResPath path, MappingDataNode data) { + Log.Info($"Saving serialized results to {path}"); + path = path.ToRootedPath(); var document = new YamlDocument(data.ToYaml()); using var writer = _resourceManager.UserData.OpenWriteText(path); { @@ -81,4 +84,49 @@ private bool TryReadFile(ResPath file, [NotNullWhen(true)] out MappingDataNode? return true; } } + + private bool TryGetReader(ResPath resPath, [NotNullWhen(true)] out TextReader? reader) + { + if (_resourceManager.UserData.Exists(resPath)) + { + // Log warning if file exists in both user and content data. + if (_resourceManager.ContentFileExists(resPath)) + Log.Warning("Reading map user data instead of content"); + + reader = _resourceManager.UserData.OpenText(resPath); + return true; + } + + if (_resourceManager.TryContentFileRead(resPath, out var contentReader)) + { + reader = new StreamReader(contentReader); + return true; + } + + Log.Error($"File not found: {resPath}"); + reader = null; + return false; + } + + /// + /// Helper method for deleting all loaded entities. + /// + public void Delete(LoadResult result) + { + foreach (var uid in result.Maps) + { + Del(uid); + } + + foreach (var uid in result.Orphans) + { + Del(uid); + } + + foreach (var uid in result.Entities) + { + Del(uid); + } + } + } diff --git a/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs index 627fb347915..00902ad5361 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs @@ -78,8 +78,8 @@ await server.WaitPost(() => // This will cause the null-space entity to be lost. // Save the map, then delete all the entities. Assert.That(entMan.Count(), Is.EqualTo(5)); - await server.WaitPost(() => Assert.That(loader.TrySaveMap(mapId, mapPath))); - await server.WaitPost(() => Assert.That(loader.TrySaveGrid(grid, gridPath))); + Assert.That(loader.TrySaveMap(mapId, mapPath)); + Assert.That(loader.TrySaveGrid(grid, gridPath)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); Assert.That(entMan.Count(), Is.EqualTo(1)); await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); @@ -102,7 +102,7 @@ await server.WaitPost(() => HashSet>? loadedGrids = default!; await server.WaitAssertion(() => Assert.That(loader.TryLoadMap(mapPath, out loadedMap, out loadedGrids))); mapId = loadedMap!.Value.Comp.MapId; - Assert.That(loadedGrids.Count, Is.EqualTo(1)); + Assert.That(loadedGrids, Has.Count.EqualTo(1)); AssertCount(4); map = Find(nameof(map), entMan); @@ -127,8 +127,8 @@ await server.WaitPost(() => onGrid.Comp2.Entity = nullSpace.Owner; Assert.That(entMan.Count(), Is.EqualTo(5)); - await server.WaitPost(() => Assert.That(loader.TrySaveMap(mapId, mapPath))); - await server.WaitPost(() => Assert.That(loader.TrySaveGrid(grid, gridPath))); + Assert.That(loader.TrySaveMap(mapId, mapPath)); + Assert.That(loader.TrySaveGrid(grid, gridPath)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); Assert.That(entMan.Count(), Is.EqualTo(1)); await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); @@ -154,7 +154,7 @@ await server.WaitPost(() => // Load up the map, and check that the expected entities exist. await server.WaitAssertion(() => Assert.That(loader.TryLoadMap(mapPath, out loadedMap, out loadedGrids))); mapId = loadedMap!.Value.Comp.MapId; - Assert.That(loadedGrids.Count, Is.EqualTo(1)); + Assert.That(loadedGrids, Has.Count.EqualTo(1)); AssertCount(5); map = Find(nameof(map), entMan); @@ -183,8 +183,8 @@ await server.WaitPost(() => // By default it should log an error, but tests don't have a nice way to validate that an error was logged, so we'll just suppress it. var opts = SerializationOptions.Default with {MissingEntityBehaviour = MissingEntityBehaviour.Ignore}; Assert.That(entMan.Count(), Is.EqualTo(6)); - await server.WaitPost(() => Assert.That(loader.TrySaveMap(mapId, mapPath, opts))); - await server.WaitPost(() => Assert.That(loader.TrySaveGrid(grid, gridPath, opts))); + Assert.That(loader.TrySaveMap(mapId, mapPath, opts)); + Assert.That(loader.TrySaveGrid(grid, gridPath, opts)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); await server.WaitPost(() => entMan.DeleteEntity(otherMap)); @@ -204,7 +204,7 @@ await server.WaitPost(() => // Check the map file await server.WaitAssertion(() => Assert.That(loader.TryLoadMap(mapPath, out loadedMap, out loadedGrids, dOpts))); mapId = loadedMap!.Value.Comp.MapId; - Assert.That(loadedGrids.Count, Is.EqualTo(1)); + Assert.That(loadedGrids, Has.Count.EqualTo(1)); AssertCount(4); map = Find(nameof(map), entMan); grid = Find(nameof(grid), entMan); @@ -231,8 +231,10 @@ await server.WaitPost(() => Assert.That(entMan.Count(), Is.EqualTo(6)); opts = opts with {MissingEntityBehaviour = MissingEntityBehaviour.AutoIncludeChildren}; - await server.WaitPost(() => loader.TrySaveEntity(map.Owner, mapPath, opts)); - await server.WaitPost(() => loader.TrySaveEntity(grid.Owner, gridPath, opts)); + Assert.That(loader.TrySaveGeneric(map.Owner, mapPath, out var cat, opts)); + Assert.That(cat, Is.EqualTo(FileCategory.Unknown)); + Assert.That(loader.TrySaveGeneric(grid.Owner, gridPath, out cat, opts)); + Assert.That(cat, Is.EqualTo(FileCategory.Unknown)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); await server.WaitPost(() => entMan.DeleteEntity(otherMap)); Assert.That(entMan.Count(), Is.EqualTo(0)); @@ -244,7 +246,7 @@ await server.WaitPost(() => DeserializationOptions = DeserializationOptions.Default with {LogOrphanedGrids = false} }; LoadResult? result = default; - await server.WaitAssertion(() => Assert.That(loader.TryLoadEntities(gridPath, out result, mapLoadOpts))); + await server.WaitAssertion(() => Assert.That(loader.TryLoadGeneric(gridPath, out result, mapLoadOpts))); Assert.That(result!.Grids.Count, Is.EqualTo(1)); Assert.That(result.Maps.Count, Is.EqualTo(2)); AssertCount(4); @@ -260,10 +262,10 @@ await server.WaitPost(() => Assert.That(entMan.Count(), Is.EqualTo(0)); // Check the map file - await server.WaitAssertion(() => Assert.That(loader.TryLoadEntities(mapPath, out result))); - Assert.That(result!.Grids.Count, Is.EqualTo(1)); - Assert.That(result.Maps.Count, Is.EqualTo(2)); - Assert.That(loadedGrids.Count, Is.EqualTo(1)); + await server.WaitAssertion(() => Assert.That(loader.TryLoadGeneric(mapPath, out result))); + Assert.That(result!.Grids, Has.Count.EqualTo(1)); + Assert.That(result.Maps, Has.Count.EqualTo(2)); + Assert.That(loadedGrids, Has.Count.EqualTo(1)); AssertCount(6); map = Find(nameof(map), entMan); grid = Find(nameof(grid), entMan); diff --git a/Robust.UnitTesting/Shared/EntitySerialization/TestComponents.cs b/Robust.UnitTesting/Shared/EntitySerialization/TestComponents.cs index d9d2873ee20..34306662d1d 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/TestComponents.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/TestComponents.cs @@ -27,8 +27,9 @@ public sealed partial class EntitySaveTestComponent : Component public static Entity Find(string id, IEntityManager entMan) { var ents = entMan.AllEntities(); - var found = ents.Single(x => x.Comp.Id == id); - return (found.Owner, entMan.GetComponent(found.Owner), found.Comp); + var matching = ents.Where(x => x.Comp.Id == id).ToArray(); + Assert.That(matching, Has.Length.EqualTo(1)); + return (matching[0].Owner, entMan.GetComponent(matching[0].Owner), matching[0].Comp); } public static Entity Get(EntityUid uid, IEntityManager entMan) From 9eddbe108f69c297eae93c17f64ca62a498149eb Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 20:27:48 +1300 Subject: [PATCH 18/27] Add OrphanSerializationTest --- .../OrphanSerializationTest.cs | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/OrphanSerializationTest.cs diff --git a/Robust.UnitTesting/Shared/EntitySerialization/OrphanSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/OrphanSerializationTest.cs new file mode 100644 index 00000000000..7bacc4b537f --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/OrphanSerializationTest.cs @@ -0,0 +1,233 @@ +using System.Numerics; +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Components; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Utility; +using static Robust.UnitTesting.Shared.EntitySerialization.EntitySaveTestComponent; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +[TestFixture] +public sealed partial class OrphanSerializationTest : RobustIntegrationTest +{ + /// + /// Check that we can save & load a file containing multiple orphaned (non-grid) entities. + /// + [Test] + public async Task TestMultipleOrphanSerialization() + { + var server = StartServer(); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var mapSys = server.System(); + var loader = server.System(); + var xform = server.System(); + var pathA = new ResPath($"{nameof(TestMultipleOrphanSerialization)}_A.yml"); + var pathB = new ResPath($"{nameof(TestMultipleOrphanSerialization)}_B.yml"); + var pathCombined = new ResPath($"{nameof(TestMultipleOrphanSerialization)}_C.yml"); + + // Spawn multiple entities on a map + MapId mapId = default; + Entity entA = default; + Entity entB = default; + Entity child = default; + + await server.WaitPost(() => + { + mapSys.CreateMap(out mapId); + var entAUid = entMan.SpawnEntity(null, new MapCoordinates(0, 0, mapId)); + var entBUid = entMan.SpawnEntity(null, new MapCoordinates(0, 0, mapId)); + var childUid = entMan.SpawnEntity(null, new EntityCoordinates(entBUid, 0, 0)); + entA = Get(entAUid, entMan); + entB = Get(entBUid, entMan); + child = Get(childUid, entMan); + entA.Comp2.Id = nameof(entA); + entB.Comp2.Id = nameof(entB); + child.Comp2.Id = nameof(child); + xform.SetLocalPosition(entB.Owner, new (100,100)); + }); + + // Entities are not in null-space + Assert.That(entA.Comp1!.ParentUid, Is.Not.EqualTo(EntityUid.Invalid)); + Assert.That(entB.Comp1!.ParentUid, Is.Not.EqualTo(EntityUid.Invalid)); + Assert.That(child.Comp1!.ParentUid, Is.EqualTo(entB.Owner)); + + // Save the entities without their map + Assert.That(loader.TrySaveEntity(entA, pathA)); + Assert.That(loader.TrySaveEntity(entB, pathB)); + Assert.That(loader.TrySaveGeneric([entA.Owner, entB.Owner], pathCombined, out var cat)); + Assert.That(cat, Is.EqualTo(FileCategory.Unknown)); + + // Delete all the entities. + Assert.That(entMan.Count(), Is.EqualTo(3)); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load in the file containing only entA. + await server.WaitPost(() => Assert.That(loader.TryLoadEntity(pathA, out _))); + Assert.That(entMan.Count(), Is.EqualTo(1)); + entA = Find(nameof(entA), entMan); + Assert.That(entA.Comp1!.ParentUid, Is.EqualTo(EntityUid.Invalid)); + await server.WaitPost(() => entMan.DeleteEntity(entA)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load in the file containing entB and its child + await server.WaitPost(() => Assert.That(loader.TryLoadEntity(pathB, out _))); + Assert.That(entMan.Count(), Is.EqualTo(2)); + entB = Find(nameof(entB), entMan); + child = Find(nameof(child), entMan); + // Even though the entities are in null-space their local position is preserved. + // This is so that you can save multiple entities on a map, without saving the map, while still preserving + // relative positions for loading them onto some other map. + Assert.That(entB.Comp1.LocalPosition, Is.Approximately(new Vector2(100, 100))); + Assert.That(entB.Comp1!.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(child.Comp1!.ParentUid, Is.EqualTo(entB.Owner)); + await server.WaitPost(() => entMan.DeleteEntity(entB)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load the file that contains both of them + LoadResult? result = null; + await server.WaitPost(() => Assert.That(loader.TryLoadGeneric(pathCombined, out result))); + Assert.That(result!.Category, Is.EqualTo(FileCategory.Unknown)); + Assert.That(result.Orphans, Has.Count.EqualTo(2)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + entA = Find(nameof(entA), entMan); + entB = Find(nameof(entB), entMan); + child = Find(nameof(child), entMan); + Assert.That(entA.Comp1!.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(entB.Comp1!.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(entB.Comp1.LocalPosition, Is.Approximately(new Vector2(100, 100))); + Assert.That(child.Comp1!.ParentUid, Is.EqualTo(entB.Owner)); + await server.WaitPost(() => entMan.DeleteEntity(entA)); + await server.WaitPost(() => entMan.DeleteEntity(entB)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + } + + /// + /// Check that we can save & load a file containing multiple orphaned grid entities. + /// + [Test] + public async Task TestOrphanedGridSerialization() + { + var server = StartServer(); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var mapSys = server.System(); + var loader = server.System(); + var xform = server.System(); + var mapMan = server.ResolveDependency(); + var tileMan = server.ResolveDependency(); + var pathA = new ResPath($"{nameof(TestOrphanedGridSerialization)}_A.yml"); + var pathB = new ResPath($"{nameof(TestOrphanedGridSerialization)}_B.yml"); + var pathCombined = new ResPath($"{nameof(TestOrphanedGridSerialization)}_C.yml"); + + tileMan.Register(new TileDef("space")); + var tDef = new TileDef("a"); + tileMan.Register(tDef); + + // Spawn multiple entities on a map + MapId mapId = default; + Entity map = default; + Entity gridA = default; + Entity gridB = default; + Entity child = default; + + await server.WaitPost(() => + { + var mapUid = mapSys.CreateMap(out mapId); + map = Get(mapUid, entMan); + + var gridAUid = mapMan.CreateGridEntity(mapId); + mapSys.SetTile(gridAUid, Vector2i.Zero, new Tile(tDef.TileId)); + gridA = Get(gridAUid, entMan); + xform.SetLocalPosition(gridA.Owner, new(100, 100)); + + var gridBUid = mapMan.CreateGridEntity(mapId); + mapSys.SetTile(gridBUid, Vector2i.Zero, new Tile(tDef.TileId)); + gridB = Get(gridBUid, entMan); + + var childUid = entMan.SpawnEntity(null, new EntityCoordinates(gridBUid, 0.5f, 0.5f)); + child = Get(childUid, entMan); + + map.Comp2.Id = nameof(map); + gridA.Comp2.Id = nameof(gridA); + gridB.Comp2.Id = nameof(gridB); + child.Comp2.Id = nameof(child); + }); + + await server.WaitRunTicks(5); + + // grids are not in null-space + Assert.That(gridA.Comp1!.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(gridB.Comp1!.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(child.Comp1!.ParentUid, Is.EqualTo(gridB.Owner)); + Assert.That(map.Comp1!.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + // Save the grids without their map + Assert.That(loader.TrySaveGrid(gridA, pathA)); + Assert.That(loader.TrySaveGrid(gridB, pathB)); + Assert.That(loader.TrySaveGeneric([gridA.Owner, gridB.Owner], pathCombined, out var cat)); + Assert.That(cat, Is.EqualTo(FileCategory.Unknown)); + + // Delete all the entities. + Assert.That(entMan.Count(), Is.EqualTo(4)); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load in the file containing only gridA. + EntityUid newMap = default; + await server.WaitPost(() => newMap = mapSys.CreateMap(out mapId)); + await server.WaitPost(() => Assert.That(loader.TryLoadGrid(mapId, pathA, out _))); + Assert.That(entMan.Count(), Is.EqualTo(1)); + gridA = Find(nameof(gridA), entMan); + Assert.That(gridA.Comp1.LocalPosition, Is.Approximately(new Vector2(100, 100))); + Assert.That(gridA.Comp1!.ParentUid, Is.EqualTo(newMap)); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load in the file containing gridB and its child + await server.WaitPost(() => newMap = mapSys.CreateMap(out mapId)); + await server.WaitPost(() => Assert.That(loader.TryLoadGrid(mapId, pathB, out _))); + Assert.That(entMan.Count(), Is.EqualTo(2)); + gridB = Find(nameof(gridB), entMan); + child = Find(nameof(child), entMan); + Assert.That(gridB.Comp1!.ParentUid, Is.EqualTo(newMap)); + Assert.That(child.Comp1!.ParentUid, Is.EqualTo(gridB.Owner)); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load the file that contains both of them. + // This uses the generic loader, and should automatically create maps for both grids. + LoadResult? result = null; + var opts = MapLoadOptions.Default with + { + DeserializationOptions = DeserializationOptions.Default with {LogOrphanedGrids = false} + }; + await server.WaitPost(() => Assert.That(loader.TryLoadGeneric(pathCombined, out result, opts))); + Assert.That(result!.Category, Is.EqualTo(FileCategory.Unknown)); + Assert.That(result.Grids, Has.Count.EqualTo(2)); + Assert.That(result.Maps, Has.Count.EqualTo(2)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + gridA = Find(nameof(gridA), entMan); + gridB = Find(nameof(gridB), entMan); + child = Find(nameof(child), entMan); + Assert.That(gridA.Comp1.LocalPosition, Is.Approximately(new Vector2(100, 100))); + Assert.That(gridA.Comp1!.ParentUid, Is.Not.EqualTo(EntityUid.Invalid)); + Assert.That(gridB.Comp1!.ParentUid, Is.Not.EqualTo(EntityUid.Invalid)); + Assert.That(child.Comp1!.ParentUid, Is.EqualTo(gridB.Owner)); + await server.WaitPost(() => + { + foreach (var ent in result.Maps) + { + entMan.DeleteEntity(ent.Owner); + } + }); + Assert.That(entMan.Count(), Is.EqualTo(0)); + } +} From 4930f2a731b916346ceab0c2caf7d59e4120d105 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 20:53:46 +1300 Subject: [PATCH 19/27] Include MapIds in BeforeSerializationEvent --- .../EntitySerialization/Systems/MapLoaderSystem.Save.cs | 4 +++- Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs | 2 +- Robust.Shared/Map/Events/MapSerializationEvents.cs | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs index 82000aaefa0..a0d6488e1d0 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Save.cs @@ -27,7 +27,9 @@ public sealed partial class MapLoaderSystem throw new Exception($"Cannot serialize deleted entities"); Log.Info($"Serializing entities: {string.Join(", ", entities.Select(x => ToPrettyString(x).ToString()))}"); - var ev = new BeforeSerializationEvent(entities); + + var maps = entities.Select(x => Transform(x).MapID).ToHashSet(); + var ev = new BeforeSerializationEvent(entities, maps); RaiseLocalEvent(ev); // In case no options were provided, we assume that if all of the starting entities are pre-init, we should diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs index 2cd0c65bbfe..a7646b8b483 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs @@ -54,7 +54,7 @@ private void Write(ResPath path, MappingDataNode data) } } - private bool TryReadFile(ResPath file, [NotNullWhen(true)] out MappingDataNode? data) + public bool TryReadFile(ResPath file, [NotNullWhen(true)] out MappingDataNode? data) { var resPath = file.ToRootedPath(); data = null; diff --git a/Robust.Shared/Map/Events/MapSerializationEvents.cs b/Robust.Shared/Map/Events/MapSerializationEvents.cs index 4854b20ee5f..c2721c1bcf0 100644 --- a/Robust.Shared/Map/Events/MapSerializationEvents.cs +++ b/Robust.Shared/Map/Events/MapSerializationEvents.cs @@ -31,8 +31,10 @@ public sealed class BeforeEntityReadEvent /// /// This event is broadcast just before the given entities (and their children) are serialized. +/// For convenience, the event also contains a set with all the maps that the entities are on. This does not +/// necessarily mean that the maps are themselves getting serialized. /// -public readonly record struct BeforeSerializationEvent(HashSet Entities); +public readonly record struct BeforeSerializationEvent(HashSet Entities, HashSet MapIds); /// /// This event is broadcast just after entities (and their children) have been serialized, but before it gets written to a yaml file. From 4c7522fe20ac151383ae3c2da1648410599e77bd Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 21:42:56 +1300 Subject: [PATCH 20/27] Addd LifetimeSerializationTest --- .../EntitySerialization/EntityDeserializer.cs | 14 +- .../LifetimeSerializationTest.cs | 163 ++++++++++++++++++ 2 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index 8ce243fe9cf..9ae01337f0d 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -196,12 +196,17 @@ public void StartEntities() { AdoptGrids(); ValidateMapIds(); - PauseMaps(); BuildEntityHierarchy(); StartEntitiesInternal(); + + // Set loaded entity metadata SetMapInitLifestage(); SetPaused(); - MapInitializeEntities(); + + // Apply entity metadata options + PauseMaps(); + InitializeMaps(); + ProcessDeletions(); } @@ -960,10 +965,13 @@ private void SetPaused() } - private void MapInitializeEntities() + private void InitializeMaps() { if (!Options.InitializeMaps) { + if (Options.PauseMaps) + return; // Already paused + foreach (var ent in Result.Maps) { if (_metaQuery.Comp(ent.Owner).EntityLifeStage < EntityLifeStage.MapInitialized) diff --git a/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs new file mode 100644 index 00000000000..29b86648de6 --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs @@ -0,0 +1,163 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Utility; +using static Robust.UnitTesting.Shared.EntitySerialization.EntitySaveTestComponent; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +[TestFixture] +public sealed partial class LifetimeSerializationTest : RobustIntegrationTest +{ + /// + /// Check that whether or not an entity has been map-initialized is preserved across saves & loads. + /// + [Test] + public async Task TestLifetimeSerialization() + { + var server = StartServer(); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var mapSys = server.System(); + var loader = server.System(); + var preInitPath = new ResPath($"{nameof(LifetimeSerializationTest)}_preInit.yml"); + var postInitPath = new ResPath($"{nameof(LifetimeSerializationTest)}_postInit.yml"); + var pausedPostInitPath = new ResPath($"{nameof(LifetimeSerializationTest)}_paused.yml"); + + // Create a pre-init map, and spawn multiple entities on it + Entity map = default; + Entity entA = default; + Entity entB = default; + Entity childA = default; + Entity childB = default; + + await server.WaitPost(() => + { + var mapUid = mapSys.CreateMap(out var mapId, runMapInit: false); + var entAUid = entMan.SpawnEntity(null, new MapCoordinates(0, 0, mapId)); + var entBUid = entMan.SpawnEntity(null, new MapCoordinates(0, 0, mapId)); + var childAUid = entMan.SpawnEntity(null, new EntityCoordinates(entAUid, 0, 0)); + var childBUid = entMan.SpawnEntity(null, new EntityCoordinates(entBUid, 0, 0)); + map = Get(mapUid, entMan); + entA = Get(entAUid, entMan); + entB = Get(entBUid, entMan); + childA = Get(childAUid, entMan); + childB = Get(childBUid, entMan); + map.Comp2.Id = nameof(map); + entA.Comp2.Id = nameof(entA); + entB.Comp2.Id = nameof(entB); + childA.Comp2.Id = nameof(childA); + childB.Comp2.Id = nameof(childB); + }); + + void AssertPaused(bool expected, params EntityUid[] uids) + { + foreach (var uid in uids) + { + Assert.That(entMan.GetComponent(uid).EntityPaused, Is.EqualTo(expected)); + } + } + + void AssertPreInit(bool expected, params EntityUid[] uids) + { + foreach (var uid in uids) + { + Assert.That(entMan!.GetComponent(uid).EntityLifeStage, + expected + ? Is.LessThan(EntityLifeStage.MapInitialized) + : Is.EqualTo(EntityLifeStage.MapInitialized)); + } + } + + // All entities should initially be un-initialized and paused. + AssertPaused(true, map, entA, entB, childA, childB); + AssertPreInit(true, map, entA, entB, childA, childB); + Assert.That(loader.TrySaveMap(map, preInitPath)); + + async Task Delete() + { + Assert.That(entMan.Count(), Is.EqualTo(5)); + await server.WaitPost(() => entMan.DeleteEntity(map)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + } + + async Task Load(ResPath f, DeserializationOptions? o) + { + Assert.That(entMan.Count(), Is.EqualTo(0)); + await server.WaitPost(() => Assert.That(loader.TryLoadMap(f, out _, out _, o))); + Assert.That(entMan.Count(), Is.EqualTo(5)); + } + + void FindAll() + { + map = Find(nameof(map), entMan); + entA = Find(nameof(entA), entMan); + entB = Find(nameof(entB), entMan); + childA = Find(nameof(childA), entMan); + childB = Find(nameof(childB), entMan); + } + + async Task Reload(ResPath f, DeserializationOptions? o = null) + { + await Delete(); + await Load(f, o); + FindAll(); + } + + // Saving and loading the pre-init map should have no effect. + await Reload(preInitPath); + AssertPaused(true, map, entA, entB, childA, childB); + AssertPreInit(true, map, entA, entB, childA, childB); + + // Saving and loading with the map-init option set to true should initialize & unpause all entities + var opts = DeserializationOptions.Default with {InitializeMaps = true}; + await Reload(preInitPath, opts); + AssertPaused(false, map, entA, entB, childA, childB); + AssertPreInit(false, map, entA, entB, childA, childB); + Assert.That(loader.TrySaveMap(map, postInitPath)); + + // re-loading the post-init map should keep everything initialized, even without explicitly asking to initialize maps. + await Reload(postInitPath); + AssertPaused(false, map, entA, entB, childA, childB); + AssertPreInit(false, map, entA, entB, childA, childB); + + // Load & initialize a pre-init map, but with the pause maps option enabled. + opts = DeserializationOptions.Default with {InitializeMaps = true, PauseMaps = true}; + await Reload(preInitPath, opts); + AssertPaused(true, map, entA, entB, childA, childB); + AssertPreInit(false, map, entA, entB, childA, childB); + Assert.That(loader.TrySaveMap(map, pausedPostInitPath)); + + // The pause map option also works when loading un-paused post-init maps + opts = DeserializationOptions.Default with {PauseMaps = true}; + await Reload(postInitPath, opts); + AssertPaused(true, map, entA, entB, childA, childB); + AssertPreInit(false, map, entA, entB, childA, childB); + + // loading & initializing a post-init map should cause no issues. + opts = DeserializationOptions.Default with {InitializeMaps = true}; + await Reload(postInitPath, opts); + AssertPaused(false, map, entA, entB, childA, childB); + AssertPreInit(false, map, entA, entB, childA, childB); + + // Loading a paused post init map does NOT automatically un-pause entities + await Reload(pausedPostInitPath); + AssertPaused(true, map, entA, entB, childA, childB); + AssertPreInit(false, map, entA, entB, childA, childB); + + // The above holds even if we are explicitly initialising maps. + opts = DeserializationOptions.Default with {InitializeMaps = true}; + await Reload(pausedPostInitPath, opts); + AssertPaused(true, map, entA, entB, childA, childB); + AssertPreInit(false, map, entA, entB, childA, childB); + + // And re-paused an already paused map should have no impact. + opts = DeserializationOptions.Default with {InitializeMaps = true, PauseMaps = true}; + await Reload(pausedPostInitPath, opts); + AssertPaused(true, map, entA, entB, childA, childB); + AssertPreInit(false, map, entA, entB, childA, childB); + } +} From 66e4ebb6d823b725650c9763cd3964181057dc07 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 22:42:08 +1300 Subject: [PATCH 21/27] Add TestMixedLifetimeSerialization --- .../LifetimeSerializationTest.cs | 222 +++++++++++++++++- 1 file changed, 218 insertions(+), 4 deletions(-) diff --git a/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs index 29b86648de6..b8e20e06b3e 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs @@ -1,4 +1,7 @@ +using System.Collections.Generic; +using System.Runtime.InteropServices; using System.Threading.Tasks; +using Linguini.Syntax.Ast; using NUnit.Framework; using Robust.Shared.EntitySerialization; using Robust.Shared.EntitySerialization.Systems; @@ -13,7 +16,7 @@ namespace Robust.UnitTesting.Shared.EntitySerialization; public sealed partial class LifetimeSerializationTest : RobustIntegrationTest { /// - /// Check that whether or not an entity has been map-initialized is preserved across saves & loads. + /// Check that whether or not an entity has been paused or map-initialized is preserved across saves & loads. /// [Test] public async Task TestLifetimeSerialization() @@ -23,9 +26,9 @@ public async Task TestLifetimeSerialization() var entMan = server.EntMan; var mapSys = server.System(); var loader = server.System(); - var preInitPath = new ResPath($"{nameof(LifetimeSerializationTest)}_preInit.yml"); - var postInitPath = new ResPath($"{nameof(LifetimeSerializationTest)}_postInit.yml"); - var pausedPostInitPath = new ResPath($"{nameof(LifetimeSerializationTest)}_paused.yml"); + var preInitPath = new ResPath($"{nameof(TestLifetimeSerialization)}_preInit.yml"); + var postInitPath = new ResPath($"{nameof(TestLifetimeSerialization)}_postInit.yml"); + var pausedPostInitPath = new ResPath($"{nameof(TestLifetimeSerialization)}_paused.yml"); // Create a pre-init map, and spawn multiple entities on it Entity map = default; @@ -160,4 +163,215 @@ async Task Reload(ResPath f, DeserializationOptions? o = null) AssertPaused(true, map, entA, entB, childA, childB); AssertPreInit(false, map, entA, entB, childA, childB); } + + /// + /// Variant of that has multiple maps and combinations. E.g., a single + /// paused entity on an un-paused map. + /// + [Test] + public async Task TestMixedLifetimeSerialization() + { + var server = StartServer(); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var meta = server.System(); + var mapSys = server.System(); + var loader = server.System(); + var path = new ResPath($"{nameof(TestMixedLifetimeSerialization)}.yml"); + var altPath = new ResPath($"{nameof(TestMixedLifetimeSerialization)}_alt.yml"); + + Entity mapA = default; // preinit Map + Entity mapB = default; // postinit unpaused Map + Entity entA = default; // postinit entity on preinit map + Entity entB = default; // paused entity on postinit unpaused map + Entity entC = default; // preinit entity on postinit map + Entity nullA = default; // postinit nullspace entity + Entity nullB = default; // preinit nullspace entity + Entity nullC = default; // paused postinit nullspace entity + + await server.WaitPost(() => + { + var mapAUid = mapSys.CreateMap(out var mapIdA, runMapInit: false); + var mapBUid = mapSys.CreateMap(out var mapIdB, runMapInit: true); + + var entAUid = entMan.SpawnEntity(null, new MapCoordinates(0, 0, mapIdA)); + entMan.RunMapInit(entAUid, entMan.GetComponent(entAUid)); + meta.SetEntityPaused(entAUid, false); + + var entBUid = entMan.SpawnEntity(null, new MapCoordinates(0, 0, mapIdB)); + meta.SetEntityPaused(entBUid, true); + + var entCUid = entMan.CreateEntityUninitialized(null, new MapCoordinates(0, 0, mapIdB)); + entMan.InitializeAndStartEntity(entCUid, doMapInit: false); + + var nullAUid = entMan.SpawnEntity(null, MapCoordinates.Nullspace); + + var nullBUid = entMan.CreateEntityUninitialized(null, MapCoordinates.Nullspace); + entMan.InitializeAndStartEntity(nullBUid, doMapInit: false); + + var nullCUid = entMan.SpawnEntity(null, MapCoordinates.Nullspace); + meta.SetEntityPaused(nullCUid, true); + + mapA = Get(mapAUid, entMan); + mapB = Get(mapBUid, entMan); + entA = Get(entAUid, entMan); + entB = Get(entBUid, entMan); + entC = Get(entCUid, entMan); + nullA = Get(nullAUid, entMan); + nullB = Get(nullBUid, entMan); + nullC = Get(nullCUid, entMan); + + mapA.Comp2.Id = nameof(mapA); + mapB.Comp2.Id = nameof(mapB); + entA.Comp2.Id = nameof(entA); + entB.Comp2.Id = nameof(entB); + entC.Comp2.Id = nameof(entC); + nullA.Comp2.Id = nameof(nullA); + nullB.Comp2.Id = nameof(nullB); + nullC.Comp2.Id = nameof(nullC); + }); + + string? Name(EntityUid uid) + { + return entMan.GetComponentOrNull(uid)?.Id; + } + + void AssertPaused(bool expected, params EntityUid[] uids) + { + foreach (var uid in uids) + { + Assert.That(entMan.GetComponent(uid).EntityPaused, Is.EqualTo(expected), Name(uid)); + } + } + + void AssertPreInit(bool expected, params EntityUid[] uids) + { + foreach (var uid in uids) + { + Assert.That(entMan!.GetComponent(uid).EntityLifeStage, + expected + ? Is.LessThan(EntityLifeStage.MapInitialized) + : Is.EqualTo(EntityLifeStage.MapInitialized)); + } + } + + void Save(ResPath f) + { + Assert.That(loader.TrySaveGeneric([mapA, mapB, nullA, nullB, nullC], f, out _)); + } + + async Task Delete() + { + Assert.That(entMan.Count(), Is.EqualTo(8)); + await server.WaitPost(() => entMan.DeleteEntity(mapA)); + await server.WaitPost(() => entMan.DeleteEntity(mapB)); + await server.WaitPost(() => entMan.DeleteEntity(nullA)); + await server.WaitPost(() => entMan.DeleteEntity(nullB)); + await server.WaitPost(() => entMan.DeleteEntity(nullC)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + } + + async Task Load(ResPath f, DeserializationOptions? o) + { + Assert.That(entMan.Count(), Is.EqualTo(0)); + var oo = MapLoadOptions.Default with + { + DeserializationOptions = o ?? DeserializationOptions.Default + }; + await server.WaitPost(() => Assert.That(loader.TryLoadGeneric(f, out _, oo))); + Assert.That(entMan.Count(), Is.EqualTo(8)); + } + + void FindAll() + { + mapA = Find(nameof(mapA), entMan); + mapB = Find(nameof(mapB), entMan); + entA = Find(nameof(entA), entMan); + entB = Find(nameof(entB), entMan); + entC = Find(nameof(entC), entMan); + nullA = Find(nameof(nullA), entMan); + nullB = Find(nameof(nullB), entMan); + nullC = Find(nameof(nullC), entMan); + } + + async Task Reload(ResPath f, DeserializationOptions? o = null) + { + await Delete(); + await Load(f, o); + FindAll(); + } + + // All entities should initially be in their respective expected states. + // entC (pre-mapinit entity on a post-mapinit map) is a bit fucky, and I don't know if that should even be allowed. + // Note that its just pre-init, not paused, as pre-mapinit entities get paused due to the maps state, not as a general result of being pre-mapinit. + // If this ever changes, these assers need fixing. + AssertPaused(true, mapA, entB, nullC); + AssertPaused(false, mapB, entA, entC, nullA, nullB); + AssertPreInit(true, mapA, entC, nullB); + AssertPreInit(false, mapB, entA, entB, nullA, nullC); + + // Saving and re-loading entities should leave their metadata unchanged. + Save(path); + await Reload(path); + AssertPaused(true, mapA, entB, nullC); + AssertPaused(false, mapB, entA, entC, nullA, nullB); + AssertPreInit(true, mapA, entC, nullB); + AssertPreInit(false, mapB, entA, entB, nullA, nullC); + + // reload maps with the mapinit option. This should only affect mapA, as entA is the only one on the map and it + // is already initialized, + var opts = DeserializationOptions.Default with {InitializeMaps = true}; + await Reload(path, opts); + AssertPaused(true, entB, nullC); + AssertPaused(false, mapA, mapB, entA, entC, nullA, nullB); + AssertPreInit(true, entC, nullB); + AssertPreInit(false, mapA, mapB, entA, entB, nullA, nullC); + + // Reloading the new configuration changes nothing + Save(altPath); + await Reload(altPath, opts); + AssertPaused(true, entB, nullC); + AssertPaused(false, mapA, mapB, entA, entC, nullA, nullB); + AssertPreInit(true, entC, nullB); + AssertPreInit(false, mapA, mapB, entA, entB, nullA, nullC); + + // Pause all maps. This will not actually pause entityA, as mapA is already paused (due to being pre-init), so + // it will not iterate through its children. Maybe this will change in future, but I don't think we should even + // be trying to actively support having post-init entities on a pre-init map. This is subject to maybe change + // one day, though if it does the option should be changed to PauseEntities to clarify that it will pause ALL + // entities, not just maps. + opts = DeserializationOptions.Default with {PauseMaps = true}; + await Reload(path, opts); + AssertPaused(true, mapA, mapB, entC, entB, nullC); + AssertPaused(false, entA, nullA, nullB); + AssertPreInit(true, mapA, entC, nullB); + AssertPreInit(false, mapB, entA, entB, nullA, nullC); + + // Reloading the new configuration changes nothing + Save(altPath); + await Reload(altPath, opts); + AssertPaused(true, mapA, mapB, entC, entB, nullC); + AssertPaused(false, entA, nullA, nullB); + AssertPreInit(true, mapA, entC, nullB); + AssertPreInit(false, mapB, entA, entB, nullA, nullC); + + // Initialise and pause all maps. Similar to the previous test with entA, this will not affect entC even + // though it is pre-init, because it is on a post-init map. Again, this is subject to maybe change one day. + // Though if it does, the option should be changed to MapInitializeEntities to clarify that it will mapinit ALL + // entities, not just maps. + opts = DeserializationOptions.Default with {InitializeMaps = true, PauseMaps = true}; + await Reload(path, opts); + AssertPaused(true, mapA, mapB, entB, entC, nullC); + AssertPaused(false, entA, nullA, nullB); + AssertPreInit(true, entC, nullB); + AssertPreInit(false, mapA, mapB, entA, entB, nullA, nullC); + + // Reloading the new configuration changes nothing + Save(altPath); + await Reload(altPath, opts); + AssertPaused(true, mapA, mapB, entB, entC, nullC); + AssertPaused(false, entA, nullA, nullB); + AssertPreInit(true, entC, nullB); + AssertPreInit(false, mapA, mapB, entA, entB, nullA, nullC); + } } From fb0347211e6d2b761259352ccccc027539337ff9 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Tue, 24 Dec 2024 23:19:25 +1300 Subject: [PATCH 22/27] Add CategorizationTest --- .../EntitySerialization/EntityDeserializer.cs | 3 +- .../EntitySerialization/CategorizationTest.cs | 129 ++++++++++++++++++ .../LifetimeSerializationTest.cs | 3 - 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/CategorizationTest.cs diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index 9ae01337f0d..70c1241b9f7 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -730,8 +730,9 @@ private void CheckCategory() _log.Error($"Expected file to contain a orphaned entity, but instead found {Result.Orphans.Count} orphans"); break; + case FileCategory.Save: // No validation for full game saves, they can contain whatever they want. default: - return; // No validation for full game saves + return; } } diff --git a/Robust.UnitTesting/Shared/EntitySerialization/CategorizationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/CategorizationTest.cs new file mode 100644 index 00000000000..b6154607f70 --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/CategorizationTest.cs @@ -0,0 +1,129 @@ +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Utility; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +[TestFixture] +public sealed partial class CategorizationTest : RobustIntegrationTest +{ + /// + /// Check that file categories are correctly assigned when saving & loading different combinations of entites. + /// + [Test] + [TestOf(typeof(FileCategory))] + public async Task TestCategorization() + { + var server = StartServer(); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var meta = server.System(); + var mapSys = server.System(); + var loader = server.System(); + var mapMan = server.ResolveDependency(); + var tileMan = server.ResolveDependency(); + var path = new ResPath($"{nameof(TestCategorization)}.yml"); + + tileMan.Register(new TileDef("space")); + var tDef = new TileDef("a"); + tileMan.Register(tDef); + + EntityUid mapA = default; + EntityUid mapB = default; + EntityUid gridA = default; // grid on map A + EntityUid gridB = default; // grid on map B + EntityUid entA = default; // ent on grid A + EntityUid entB = default; // ent on grid B + EntityUid entC = default; // a separate entity on grid B + EntityUid child = default; // child of entB + EntityUid @null = default; // nullspace entity + + await server.WaitPost(() => + { + mapA = mapSys.CreateMap(out var mapIdA); + mapB = mapSys.CreateMap(out var mapIdB); + var gridEntA = mapMan.CreateGridEntity(mapIdA); + var gridEntB = mapMan.CreateGridEntity(mapIdB); + mapSys.SetTile(gridEntA, Vector2i.Zero, new Tile(tDef.TileId)); + mapSys.SetTile(gridEntB, Vector2i.Zero, new Tile(tDef.TileId)); + gridA = gridEntA.Owner; + gridB = gridEntB.Owner; + entA = entMan.SpawnEntity(null, new EntityCoordinates(gridA, 0.5f, 0.5f)); + entB = entMan.SpawnEntity(null, new EntityCoordinates(gridB, 0.5f, 0.5f)); + entC = entMan.SpawnEntity(null, new EntityCoordinates(gridB, 0.5f, 0.5f)); + child = entMan.SpawnEntity(null, new EntityCoordinates(entB, 0.5f, 0.5f)); + @null = entMan.SpawnEntity(null, MapCoordinates.Nullspace); + }); + + FileCategory Save(params EntityUid[] ents) + { + FileCategory cat = FileCategory.Unknown; + Assert.That(loader.TrySaveGeneric(ents.ToHashSet(), path, out cat)); + return cat; + } + + async Task Load(FileCategory expected, int count) + { + var opts = MapLoadOptions.Default with + { + ExpectedCategory = expected, + DeserializationOptions = DeserializationOptions.Default with { LogOrphanedGrids = false} + }; + LoadResult? result = null; + await server.WaitPost(() => Assert.That(loader.TryLoadGeneric(path, out result, opts))); + Assert.That(result!.Category, Is.EqualTo(expected)); + Assert.That(result.Entities, Has.Count.EqualTo(count)); + return result; + } + + async Task SaveAndLoad(FileCategory expected, int count, params EntityUid[] ents) + { + var cat = Save(ents); + Assert.That(cat, Is.EqualTo(expected)); + var result = await Load(expected, count); + await server.WaitPost(() => loader.Delete(result)); + } + + // Saving a single entity works as expected, even if it also serializes their children + await SaveAndLoad(FileCategory.Entity, 1, entA); + await SaveAndLoad(FileCategory.Entity, 2, entB); + await SaveAndLoad(FileCategory.Entity, 1, child); + + // Including nullspace entities doesn't change the category, though a file containing only null-space entities + // is "unkown". Maybe in future they will get their own category + await SaveAndLoad(FileCategory.Entity, 2, entA, @null); + await SaveAndLoad(FileCategory.Entity, 3, entB, @null); + await SaveAndLoad(FileCategory.Entity, 2, child, @null); + await SaveAndLoad(FileCategory.Unknown, 1, @null); + + // More than one entity is unknown + await SaveAndLoad(FileCategory.Unknown, 3, entA, entB); + await SaveAndLoad(FileCategory.Unknown, 4, entA, entB, @null); + + // Saving grids works as expected. All counts are 1 higher than expected due to a map being automatically created. + await SaveAndLoad(FileCategory.Grid, 3, gridA); + await SaveAndLoad(FileCategory.Grid, 5, gridB); + await SaveAndLoad(FileCategory.Grid, 4, gridA, @null); + await SaveAndLoad(FileCategory.Grid, 6, gridB, @null); + + // And saving maps also works + await SaveAndLoad(FileCategory.Map, 3, mapA); + await SaveAndLoad(FileCategory.Map, 5, mapB); + await SaveAndLoad(FileCategory.Map, 4, mapA, @null); + await SaveAndLoad(FileCategory.Map, 6, mapB, @null); + + // Combinations of grids, entities, and maps, are unknown + await SaveAndLoad(FileCategory.Unknown, 4, mapA, child); + await SaveAndLoad(FileCategory.Unknown, 4, gridA, child); + await SaveAndLoad(FileCategory.Unknown, 8, gridA, mapB); + await SaveAndLoad(FileCategory.Unknown, 5, mapA, child, @null); + await SaveAndLoad(FileCategory.Unknown, 5, gridA, child, @null); + await SaveAndLoad(FileCategory.Unknown, 9, gridA, mapB, @null); + } +} diff --git a/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs index b8e20e06b3e..e5575eb3993 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using System.Runtime.InteropServices; using System.Threading.Tasks; -using Linguini.Syntax.Ast; using NUnit.Framework; using Robust.Shared.EntitySerialization; using Robust.Shared.EntitySerialization.Systems; From a47bdc3df6805612c1e3e65566483a7aaac76177 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Wed, 25 Dec 2024 13:08:42 +1300 Subject: [PATCH 23/27] explicitly serialize list of nullspace entities --- .../EntitySerialization/EntityDeserializer.cs | 128 ++++++++++-------- .../EntitySerialization/EntitySerializer.cs | 45 ++++-- .../EntitySerialization/LoadResult.cs | 4 +- .../Server/Maps/MapLoaderTest.cs | 1 + 4 files changed, 112 insertions(+), 66 deletions(-) diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index 70c1241b9f7..207abac907a 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -79,6 +79,7 @@ public sealed class EntityDeserializer : ISerializationContext, IEntityLoadConte public readonly List MapYamlIds = new(); public readonly List GridYamlIds = new(); public readonly List OrphanYamlIds = new(); + public readonly List NullspaceYamlIds = new(); public readonly Dictionary RenamedPrototypes; public readonly HashSet DeletedPrototypes; @@ -171,8 +172,8 @@ public void CreateEntities() // Load the prototype data onto entities, e.g. transform parents, etc. LoadEntities(); - // Get the lists of maps, grids, and orphan entities - GetMapsAndGrids(); + // Get the lists of maps, grids, orphan, and nullspace entities + GetRootEntities(); // grids prior to engine v175 might've been serialized with empty chunks which now throw debug asserts. RemoveEmptyChunks(); @@ -180,9 +181,6 @@ public void CreateEntities() // Assign MapSaveTileMapComponent to all read grids. This is used to avoid large file diffs if the tile map changes. StoreGridTileMap(); - // Separate maps and orphaned entities out from the "true" null-space entities. - ProcessNullspaceEntities(); - if (Options.AssignMapids) AssignMapIds(); @@ -203,6 +201,8 @@ public void StartEntities() SetMapInitLifestage(); SetPaused(); + GetRootNodes(); + // Apply entity metadata options PauseMaps(); InitializeMaps(); @@ -477,25 +477,20 @@ private void ReadMapsAndGrids() if (Result.Version < 7) return; - var maps = Data.Get("maps"); - foreach (var node in maps) - { - var yamlId = ((ValueDataNode) node).AsInt(); - MapYamlIds.Add(yamlId); - } - - var grids = Data.Get("grids"); - foreach (var node in grids) - { - var yamlId = ((ValueDataNode) node).AsInt(); - GridYamlIds.Add(yamlId); - } + ReadYamlIdList(Data, "maps", MapYamlIds); + ReadYamlIdList(Data, "grids", GridYamlIds); + ReadYamlIdList(Data, "orphans", OrphanYamlIds); + ReadYamlIdList(Data, "nullspace", NullspaceYamlIds); + } - var orphans = Data.Get("orphans"); - foreach (var node in orphans) + private void ReadYamlIdList(MappingDataNode data, string key, List list) + { + var sequence = data.Get(key); + list.EnsureCapacity(sequence.Count); + foreach (var node in sequence) { var yamlId = ((ValueDataNode) node).AsInt(); - OrphanYamlIds.Add(yamlId); + list.Add(yamlId); } } @@ -587,11 +582,11 @@ private void LoadEntity(EntityUid uid, MetaDataComponent meta, MappingDataNode e meta.LastComponentRemoved = Timing.CurTick; } - private void GetMapsAndGrids() + private void GetRootEntities() { if (Result.Version < 7) { - GetMapsAndGridsFallback(); + GetRootEntitiesFallback(); return; } @@ -619,11 +614,20 @@ private void GetMapsAndGrids() foreach (var yamlId in OrphanYamlIds) { var uid = UidMap[yamlId]; - if (EntMan.HasComponent(uid) || _xformQuery.Comp(uid).ParentUid.IsValid()) + if (_mapQuery.HasComponent(uid) || _xformQuery.Comp(uid).ParentUid.IsValid()) _log.Error($"Entity {EntMan.ToPrettyString(uid)} was incorrectly labelled as an orphan?"); else Result.Orphans.Add(uid); } + + foreach (var yamlId in NullspaceYamlIds) + { + var uid = UidMap[yamlId]; + if (_mapQuery.HasComponent(uid) || _xformQuery.Comp(uid).ParentUid.IsValid()) + _log.Error($"Entity {EntMan.ToPrettyString(uid)} was incorrectly labelled as a null-space entity?"); + else + Result.NullspaceEntities.Add(uid); + } } public void AssignMapIds() @@ -634,8 +638,11 @@ public void AssignMapIds() } } - private void GetMapsAndGridsFallback() + private void GetRootEntitiesFallback() { + // Older versions did not support non-grid orphaned entities or nullspace entities. + // So we just check for grids & maps. + foreach (var uid in Result.Entities) { if (_gridQuery.TryComp(uid, out var grid)) @@ -655,10 +662,9 @@ private void GetMapsAndGridsFallback() private void RemoveEmptyChunks() { - var gridQuery = EntMan.GetEntityQuery(); foreach (var uid in Entities.Keys) { - if (!gridQuery.TryGetComponent(uid, out var gridComp)) + if (!_gridQuery.TryGetComponent(uid, out var gridComp)) continue; foreach (var (index, chunk) in gridComp.Chunks) @@ -755,7 +761,7 @@ private void AdoptGrids() { foreach (var grid in Result.Grids) { - if (EntMan.HasComponent(grid.Owner)) + if (_mapQuery.HasComponent(grid.Owner)) continue; var xform = _xformQuery.Comp(grid.Owner); @@ -835,38 +841,14 @@ private void BuildEntityHierarchy(EntityUid uid, HashSet processed) return; SortedEntities.Add(uid); - if (parent == EntityUid.Invalid) - Result.RootNodes.Add(uid); - } - - private void ProcessNullspaceEntities() - { - foreach (var uid in Result.RootNodes) - { - if (EntMan.HasComponent(uid)) - { - DebugTools.Assert(Result.Maps.Any(x => x.Owner == uid)); - continue; - } - - if (Result.Orphans.Contains(uid)) - continue; - - Result.NullspaceEntities.Add(uid); - - // Null-space grids are not yet supported. - // So it shouldn't have been possible to save a grid without it being flagged as an orphan. - DebugTools.Assert(!EntMan.HasComponent(uid)); - } } private void StartEntitiesInternal() { _stopwatch.Restart(); - var metaQuery = EntMan.GetEntityQuery(); foreach (var uid in SortedEntities) { - StartupEntity(uid, metaQuery.GetComponent(uid)); + StartupEntity(uid, _metaQuery.GetComponent(uid)); } _log.Debug($"Started up {Result.Entities.Count} entities in {_stopwatch.Elapsed}"); } @@ -998,6 +980,46 @@ private void ProcessDeletions() } } + private void GetRootNodes() + { + Result.RootNodes.UnionWith(Result.Orphans); + Result.RootNodes.UnionWith(Result.NullspaceEntities); + foreach (var map in Result.Maps) + { + Result.RootNodes.Add(map.Owner); + } + + // These asserts are probably a bit over-kill + // but might as well check nothing has gone wrong somehow. +#if DEBUG + var grids = Result.Grids.Select(x => x.Owner).ToHashSet(); + var maps = Result.Maps.Select(x => x.Owner).ToHashSet(); + var totalRoots = 0; + foreach (var uid in Result.Entities) + { + DebugTools.AssertEqual(maps.Contains(uid), _mapQuery.HasComp(uid)); + DebugTools.AssertEqual(grids.Contains(uid), _gridQuery.HasComp(uid)); + + if (!_xformQuery.TryComp(uid, out var xform)) + continue; + + if (xform.ParentUid != EntityUid.Invalid) + continue; + + totalRoots++; + DebugTools.Assert(Result.RootNodes.Contains(uid)); + DebugTools.Assert(Result.Orphans.Contains(uid) + || Result.NullspaceEntities.Contains(uid) + || maps.Contains(uid)); + } + DebugTools.AssertEqual(Result.RootNodes.Count, totalRoots); + DebugTools.AssertEqual(maps.Intersect(Result.Orphans).Count(), 0); + DebugTools.AssertEqual(maps.Intersect(Result.NullspaceEntities).Count(), 0); + DebugTools.AssertEqual(grids.Intersect(Result.NullspaceEntities).Count(), 0); + DebugTools.AssertEqual(Result.Orphans.Intersect(Result.NullspaceEntities).Count(), 0); +#endif + } + // Create custom object serializers that will correctly allow data to be overriden by the map file. bool IEntityLoadContext.TryGetComponent(string componentName, [NotNullWhen(true)] out IComponent? component) { diff --git a/Robust.Shared/EntitySerialization/EntitySerializer.cs b/Robust.Shared/EntitySerialization/EntitySerializer.cs index ec85e02ab73..6b4e095be8a 100644 --- a/Robust.Shared/EntitySerialization/EntitySerializer.cs +++ b/Robust.Shared/EntitySerialization/EntitySerializer.cs @@ -13,7 +13,6 @@ using Robust.Shared.Map; using Robust.Shared.Map.Components; using Robust.Shared.Prototypes; -using Robust.Shared.Reflection; using Robust.Shared.Serialization; using Robust.Shared.Serialization.Manager; using Robust.Shared.Serialization.Markdown; @@ -110,6 +109,13 @@ public sealed class EntitySerializer : ISerializationContext, ITypeSerializer public readonly List Maps = new(); + /// + /// 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 . + /// + public readonly List Nullspace = new(); + /// /// Yaml ids of all serialized grid entities. /// @@ -133,6 +139,8 @@ public sealed class EntitySerializer : ISerializationContext, ITypeSerializer _yamlQuery; private readonly EntityQuery _gridQuery; private readonly EntityQuery _mapQuery; + private readonly EntityQuery _metaQuery; + private readonly EntityQuery _xformQuery; /// /// C# event for checking whether an entity is serializable. Can be used by content to prevent specific entities @@ -159,6 +167,8 @@ public EntitySerializer(IDependencyCollection _dependency, SerializationOptions _yamlQuery = EntMan.GetEntityQuery(); _gridQuery = EntMan.GetEntityQuery(); _mapQuery = EntMan.GetEntityQuery(); + _metaQuery = EntMan.GetEntityQuery(); + _xformQuery = EntMan.GetEntityQuery(); Options = options; } @@ -221,7 +231,7 @@ public void SerializeEntityRecursive(EntityUid root) if (!IsSerializable(root)) throw new Exception($"{EntMan.ToPrettyString(root)} is not serializable"); - Truncate = EntMan.GetComponent(root).ParentUid; + Truncate = _xformQuery.GetComponent(root).ParentUid; Truncated.Add(Truncate); InitializeTileMap(root); HashSet entities = new(); @@ -269,7 +279,7 @@ private bool FindSavedTileMap(EntityUid root, [NotNullWhen(true)] out Dictionary } // iterate over all of its children and grab the first grid with a mapping - var xform = EntMan.GetComponent(root); + var xform = _xformQuery.GetComponent(root); foreach (var child in xform._children) { if (!EntMan.TryGetComponent(child, out MapSaveTileMapComponent? cComp)) @@ -329,7 +339,7 @@ private void RecursivelyIncludeChildren(EntityUid uid, HashSet ents) return; ents.Add(uid); - var xform = EntMan.GetComponent(uid); + var xform = _xformQuery.GetComponent(uid); foreach (var child in xform._children) { RecursivelyIncludeChildren(child, ents); @@ -341,11 +351,11 @@ private void GetRootNode(EntityUid uid, HashSet ents) if (!IsSerializable(uid)) throw new NotSupportedException($"Attempted to auto-include an unserializable entity: {EntMan.ToPrettyString(uid)}"); - var xform = EntMan.GetComponent(uid); + var xform = _xformQuery.GetComponent(uid); while (xform.ParentUid.IsValid() && xform.ParentUid != Truncate) { uid = xform.ParentUid; - xform = EntMan.GetComponent(uid); + xform = _xformQuery.GetComponent(uid); if (!IsSerializable(uid)) throw new NotSupportedException($"Encountered an un-serializable parent entity: {EntMan.ToPrettyString(uid)}"); @@ -364,7 +374,7 @@ private void RecursivelyIncludeParents(EntityUid uid, HashSet ents) if (!IsSerializable(uid)) throw new NotSupportedException($"Encountered an un-serializable parent entity: {EntMan.ToPrettyString(uid)}"); - uid = EntMan.GetComponent(uid).ParentUid; + uid = _xformQuery.GetComponent(uid).ParentUid; } } @@ -398,7 +408,7 @@ private void SerializeEntityInternal(EntityUid uid) if (EntityData.ContainsKey(saveId)) return; - var meta = EntMan.GetComponent(uid); + var meta = _metaQuery.GetComponent(uid); var protoId = meta.EntityPrototype?.ID ?? string.Empty; switch (meta.EntityLifeStage) @@ -415,12 +425,20 @@ private void SerializeEntityInternal(EntityUid uid) 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 { @@ -456,7 +474,6 @@ private void SerializeEntityInternal(EntityUid uid) } var components = new SequenceDataNode(); - var xform = EntMan.GetComponent(uid); if (xform.NoLocalRotation && xform.LocalRotation != 0) { _log.Error($"Encountered a no-rotation entity with non-zero local rotation: {EntMan.ToPrettyString(uid)}"); @@ -569,12 +586,18 @@ private void SerializeEntityInternal(EntityUid uid) public MappingDataNode Write() { + DebugTools.AssertEqual(Maps.ToHashSet().Count, Maps.Count, "Duplicate maps?"); + DebugTools.AssertEqual(Grids.ToHashSet().Count, Grids.Count, "Duplicate frids?"); + 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()}, }; @@ -886,8 +909,8 @@ public DataNode Write( case MissingEntityBehaviour.IncludeNullspace: if (!EntMan.TryGetComponent(value, out TransformComponent? xform) || xform.ParentUid != EntityUid.Invalid - || EntMan.HasComponent(value) - || EntMan.HasComponent(value)) + || _gridQuery.HasComp(value) + || _mapQuery.HasComp(value)) { goto case MissingEntityBehaviour.Error; } diff --git a/Robust.Shared/EntitySerialization/LoadResult.cs b/Robust.Shared/EntitySerialization/LoadResult.cs index b67ae667ed8..51308d717cb 100644 --- a/Robust.Shared/EntitySerialization/LoadResult.cs +++ b/Robust.Shared/EntitySerialization/LoadResult.cs @@ -51,7 +51,7 @@ public sealed class LoadResult /// Set of entities that are not parented to other entities. This will be a combination of , /// , and . /// - public readonly List RootNodes = new(); + public readonly HashSet RootNodes = new(); public readonly HashSet> Maps = new(); @@ -67,5 +67,5 @@ public sealed class LoadResult /// List of null-space entities. This contains all entities without a parent that don't have a /// , and were not listed as orphans /// - public readonly List NullspaceEntities = new(); + public readonly HashSet NullspaceEntities = new(); } diff --git a/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs b/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs index 44a13a7c460..b32fd71baa6 100644 --- a/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs +++ b/Robust.UnitTesting/Server/Maps/MapLoaderTest.cs @@ -27,6 +27,7 @@ public sealed partial class MapLoaderTest : RobustIntegrationTest - 1 orphans: - 1 +nullspace: [] tilemap: {} entities: - proto: """" From 4f7ff866db714181460d8a283a0a9ac58796012e Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Wed, 25 Dec 2024 14:02:53 +1300 Subject: [PATCH 24/27] Add backwards compatibility test --- .../EntitySerialization/EntitySerializer.cs | 8 +- .../EntitySerialization/SerializationEnums.cs | 6 +- .../AutoIncludeSerializationTest.cs | 68 ++- .../BackwardsCompatibilityTest.v3.cs | 428 ++++++++++++++++++ .../BackwardsCompatibilityTest.v4.cs | 254 +++++++++++ .../BackwardsCompatibilityTest.v5.cs | 256 +++++++++++ .../BackwardsCompatibilityTest.v6.cs | 263 +++++++++++ .../BackwardsCompatibilityTest.v7.cs | 344 ++++++++++++++ ...nTest.cs => LifestageSerializationTest.cs} | 18 +- 9 files changed, 1605 insertions(+), 40 deletions(-) create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v3.cs create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v4.cs create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v5.cs create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v6.cs create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v7.cs rename Robust.UnitTesting/Shared/EntitySerialization/{LifetimeSerializationTest.cs => LifestageSerializationTest.cs} (95%) diff --git a/Robust.Shared/EntitySerialization/EntitySerializer.cs b/Robust.Shared/EntitySerialization/EntitySerializer.cs index 6b4e095be8a..ee46c014649 100644 --- a/Robust.Shared/EntitySerialization/EntitySerializer.cs +++ b/Robust.Shared/EntitySerialization/EntitySerializer.cs @@ -302,7 +302,7 @@ private void ProcessAutoInclude() switch (Options.MissingEntityBehaviour) { - case MissingEntityBehaviour.AutoInclude: + case MissingEntityBehaviour.PartialInclude: // Include the entity and any of its direct parents foreach (var uid in _autoInclude) { @@ -310,7 +310,7 @@ private void ProcessAutoInclude() } break; case MissingEntityBehaviour.IncludeNullspace: - case MissingEntityBehaviour.AutoIncludeChildren: + case MissingEntityBehaviour.AutoInclude: // Find the root transform of all the included entities var roots = new HashSet(); foreach (var uid in _autoInclude) @@ -914,9 +914,9 @@ public DataNode Write( { goto case MissingEntityBehaviour.Error; } - goto case MissingEntityBehaviour.AutoIncludeChildren; + goto case MissingEntityBehaviour.AutoInclude; + case MissingEntityBehaviour.PartialInclude: case MissingEntityBehaviour.AutoInclude: - case MissingEntityBehaviour.AutoIncludeChildren: if (Options.LogAutoInclude is {} level) _log.Log(level, $"Auto-including entity {EntMan.ToPrettyString(value)} referenced by {EntMan.ToPrettyString(CurrentEntity)}"); _autoInclude.Add(value); diff --git a/Robust.Shared/EntitySerialization/SerializationEnums.cs b/Robust.Shared/EntitySerialization/SerializationEnums.cs index 36156f1e8ab..f64fe4c3980 100644 --- a/Robust.Shared/EntitySerialization/SerializationEnums.cs +++ b/Robust.Shared/EntitySerialization/SerializationEnums.cs @@ -77,12 +77,12 @@ public enum MissingEntityBehaviour /// Note that this might unexpectedly change the . I.e., trying to serialize a grid might /// accidentally lead to serializing a (partial?) map file. /// - AutoInclude, + PartialInclude, /// - /// Variant of that will also automatically include the children of any entities that + /// Variant of that will also automatically include the children of any entities that /// that are automatically included. Note that because auto-inclusion generally needs to include an entity's /// parents, this will include more than just the missing entity's direct children. /// - AutoIncludeChildren, + AutoInclude, } diff --git a/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs index 00902ad5361..df6e895f635 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/AutoIncludeSerializationTest.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using NUnit.Framework; using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Components; using Robust.Shared.EntitySerialization.Systems; using Robust.Shared.GameObjects; using Robust.Shared.Map; @@ -77,13 +79,13 @@ await server.WaitPost(() => // First simple map loading without any references to other entities. // This will cause the null-space entity to be lost. // Save the map, then delete all the entities. - Assert.That(entMan.Count(), Is.EqualTo(5)); + AssertCount(5); Assert.That(loader.TrySaveMap(mapId, mapPath)); Assert.That(loader.TrySaveGrid(grid, gridPath)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - Assert.That(entMan.Count(), Is.EqualTo(1)); + AssertCount(1); await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); - Assert.That(entMan.Count(), Is.EqualTo(0)); + AssertCount(0); // Load up the file that only saved the grid and check that the expected entities exist. await server.WaitPost(() => mapSys.CreateMap(out mapId)); @@ -95,7 +97,7 @@ await server.WaitPost(() => Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - Assert.That(entMan.Count(), Is.EqualTo(0)); + AssertCount(0); // Load up the map, and check that the expected entities exist. Entity? loadedMap = default; @@ -126,13 +128,13 @@ await server.WaitPost(() => // Repeat the previous saves, but with an entity that references the null-space entity. onGrid.Comp2.Entity = nullSpace.Owner; - Assert.That(entMan.Count(), Is.EqualTo(5)); + AssertCount(5); Assert.That(loader.TrySaveMap(mapId, mapPath)); Assert.That(loader.TrySaveGrid(grid, gridPath)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - Assert.That(entMan.Count(), Is.EqualTo(1)); + AssertCount(1); await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); - Assert.That(entMan.Count(), Is.EqualTo(0)); + AssertCount(0); // Load up the file that only saved the grid and check that the expected entities exist. await server.WaitPost(() => mapSys.CreateMap(out mapId)); @@ -147,9 +149,9 @@ await server.WaitPost(() => Assert.That(nullSpace.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - Assert.That(entMan.Count(), Is.EqualTo(1)); + AssertCount(1); await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); - Assert.That(entMan.Count(), Is.EqualTo(0)); + AssertCount(0); // Load up the map, and check that the expected entities exist. await server.WaitAssertion(() => Assert.That(loader.TryLoadMap(mapPath, out loadedMap, out loadedGrids))); @@ -182,13 +184,13 @@ await server.WaitPost(() => // By default it should log an error, but tests don't have a nice way to validate that an error was logged, so we'll just suppress it. var opts = SerializationOptions.Default with {MissingEntityBehaviour = MissingEntityBehaviour.Ignore}; - Assert.That(entMan.Count(), Is.EqualTo(6)); + AssertCount(6); Assert.That(loader.TrySaveMap(mapId, mapPath, opts)); Assert.That(loader.TrySaveGrid(grid, gridPath, opts)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); await server.WaitPost(() => entMan.DeleteEntity(otherMap)); - Assert.That(entMan.Count(), Is.EqualTo(0)); + AssertCount(0); // Check the grid file await server.WaitPost(() => mapSys.CreateMap(out mapId)); @@ -199,7 +201,7 @@ await server.WaitPost(() => onGrid = Find(nameof(onGrid), entMan); Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); - Assert.That(entMan.Count(), Is.EqualTo(0)); + AssertCount(0); // Check the map file await server.WaitAssertion(() => Assert.That(loader.TryLoadMap(mapPath, out loadedMap, out loadedGrids, dOpts))); @@ -216,28 +218,35 @@ await server.WaitPost(() => Assert.That(offGrid.Comp1.ParentUid, Is.EqualTo(map.Owner)); // repeat the check, but this time with auto inclusion fully enabled. - Entity otherEnt; + Entity otherEnt = default; await server.WaitPost(() => { var otherMapUid = mapSys.CreateMap(out var otherMapId); otherMap = Get(otherMapUid, entMan); otherMap.Comp2.Id = nameof(otherMap); + var otherEntUid = entMan.SpawnEntity(null, new MapCoordinates(0, 0, otherMapId)); otherEnt = Get(otherEntUid, entMan); otherEnt.Comp2.Id = nameof(otherEnt); + + var nullSpaceUid = entMan.SpawnEntity(null, MapCoordinates.Nullspace); + nullSpace = Get(nullSpaceUid, entMan); + nullSpace.Comp2.Id = nameof(nullSpace); }); onGrid.Comp2.Entity = otherMap.Owner; + otherEnt.Comp2!.Entity = nullSpace; - Assert.That(entMan.Count(), Is.EqualTo(6)); - opts = opts with {MissingEntityBehaviour = MissingEntityBehaviour.AutoIncludeChildren}; + AssertCount(7); + opts = opts with {MissingEntityBehaviour = MissingEntityBehaviour.AutoInclude}; Assert.That(loader.TrySaveGeneric(map.Owner, mapPath, out var cat, opts)); Assert.That(cat, Is.EqualTo(FileCategory.Unknown)); Assert.That(loader.TrySaveGeneric(grid.Owner, gridPath, out cat, opts)); Assert.That(cat, Is.EqualTo(FileCategory.Unknown)); await server.WaitPost(() => mapSys.DeleteMap(mapId)); await server.WaitPost(() => entMan.DeleteEntity(otherMap)); - Assert.That(entMan.Count(), Is.EqualTo(0)); + await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); + AssertCount(0); // Check the grid file await server.WaitPost(() => mapSys.CreateMap(out mapId)); @@ -247,40 +256,51 @@ await server.WaitPost(() => }; LoadResult? result = default; await server.WaitAssertion(() => Assert.That(loader.TryLoadGeneric(gridPath, out result, mapLoadOpts))); - Assert.That(result!.Grids.Count, Is.EqualTo(1)); - Assert.That(result.Maps.Count, Is.EqualTo(2)); - AssertCount(4); + Assert.That(result!.Grids, Has.Count.EqualTo(1)); + Assert.That(result.Orphans, Is.Empty); // Grid was orphaned, but was adopted after a new map was created + Assert.That(result.Maps, Has.Count.EqualTo(2)); + Assert.That(result.NullspaceEntities, Has.Count.EqualTo(1)); + Assert.That(entMan.Count(), Is.EqualTo(1)); // auto-generated map isn't marked as "loaded" + AssertCount(5); grid = Find(nameof(grid), entMan); onGrid = Find(nameof(onGrid), entMan); otherMap = Find(nameof(otherMap), entMan); otherEnt = Find(nameof(otherEnt), entMan); + nullSpace = Find(nameof(nullSpace), entMan); Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); Assert.That(otherEnt.Comp1.ParentUid, Is.EqualTo(otherMap.Owner)); Assert.That(otherMap.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(nullSpace.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); await server.WaitPost(() => entMan.DeleteEntity(otherMap)); await server.WaitPost(() => entMan.DeleteEntity(grid.Comp1.ParentUid)); - Assert.That(entMan.Count(), Is.EqualTo(0)); + await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); + AssertCount(0); // Check the map file await server.WaitAssertion(() => Assert.That(loader.TryLoadGeneric(mapPath, out result))); - Assert.That(result!.Grids, Has.Count.EqualTo(1)); + Assert.That(result.Orphans, Is.Empty); + Assert.That(result.NullspaceEntities, Has.Count.EqualTo(1)); + Assert.That(result.Grids, Has.Count.EqualTo(1)); Assert.That(result.Maps, Has.Count.EqualTo(2)); - Assert.That(loadedGrids, Has.Count.EqualTo(1)); - AssertCount(6); + Assert.That(entMan.Count(), Is.EqualTo(2)); + AssertCount(7); map = Find(nameof(map), entMan); grid = Find(nameof(grid), entMan); onGrid = Find(nameof(onGrid), entMan); offGrid = Find(nameof(offGrid), entMan); otherMap = Find(nameof(otherMap), entMan); otherEnt = Find(nameof(otherEnt), entMan); + nullSpace = Find(nameof(nullSpace), entMan); Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); Assert.That(offGrid.Comp1.ParentUid, Is.EqualTo(map.Owner)); Assert.That(otherEnt.Comp1.ParentUid, Is.EqualTo(otherMap.Owner)); Assert.That(otherMap.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(nullSpace.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); await server.WaitPost(() => entMan.DeleteEntity(map)); await server.WaitPost(() => entMan.DeleteEntity(otherMap)); - Assert.That(entMan.Count(), Is.EqualTo(0)); + await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); + AssertCount(0); } } diff --git a/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v3.cs b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v3.cs new file mode 100644 index 00000000000..0bd17631e67 --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v3.cs @@ -0,0 +1,428 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.ContentPack; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Components; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Utility; +using static Robust.UnitTesting.Shared.EntitySerialization.EntitySaveTestComponent; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +[TestFixture] +public sealed partial class BackwardsCompatibilityTest +{ + /// + /// Check that v3 maps can be loaded. This simply tries to load a file and doesn't do a lot of extra validation. + /// + /// + /// The file was pilfered from content integration tests ("floor3x3.yml") and modified slightly. + /// + [Test] + public async Task TestLoadV3() + { + var server = StartServer(new ServerIntegrationOptions {ExtraPrototypes = PrototypeV3}); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var mapSys = server.System(); + var loader = server.System(); + var meta = server.System(); + var tileMan = server.ResolveDependency(); + var resourceManager = server.ResolveDependency(); + + tileMan.Register(new TileDef("Space")); + tileMan.Register(new TileDef("A")); + tileMan.Register(new TileDef("B")); + var gridPath = new ResPath($"{nameof(MapDataV3Grid)}.yml"); + resourceManager.MountString(gridPath.ToString(), MapDataV3Grid); + + MapId mapId = default; + EntityUid mapUid = default; + + Entity map; + Entity ent; + Entity grid; + + Assert.That(entMan.Count(), Is.EqualTo(0)); + await server.WaitPost(() => mapUid = mapSys.CreateMap(out mapId)); + await server.WaitPost(() => Assert.That(loader.TryLoadGrid(mapId, gridPath, out _))); + + Assert.That(entMan.Count(), Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(2)); + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(mapUid)); + + Assert.That(meta.EntityPaused(ent), Is.False); + Assert.That(meta.EntityPaused(grid), Is.False); + Assert.That(meta.EntityPaused(mapUid), Is.False); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(mapUid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + + await server.WaitPost(() => entMan.DeleteEntity(mapUid)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + var mapPath = new ResPath($"{nameof(MapDataV3Map)}.yml"); + resourceManager.MountString(mapPath.ToString(), MapDataV3Map); + await server.WaitPost(() => Assert.That(loader.TryLoadMap(mapPath, out _, out _))); + + Assert.That(entMan.Count(), Is.EqualTo(1)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + map = Find(nameof(map), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + Assert.That(meta.EntityPaused(ent), Is.True); + Assert.That(meta.EntityPaused(grid), Is.True); + Assert.That(meta.EntityPaused(map), Is.True); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + Assert.That(entMan.GetComponent(map).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + + await server.WaitPost(() => entMan.DeleteEntity(map)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Repeat test, but with the initialize maps option enabled. + // Apparently mounted strings can only be read a single time. + // So have to re-mount them. + var mapPath2 = new ResPath($"{nameof(MapDataV3Map)}2.yml"); + resourceManager.MountString(mapPath2.ToString(), MapDataV3Map); + + var opts = DeserializationOptions.Default with {InitializeMaps = true}; + await server.WaitPost(() => Assert.That(loader.TryLoadMap(mapPath2, out _, out _, opts))); + + Assert.That(entMan.Count(), Is.EqualTo(1)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + map = Find(nameof(map), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + Assert.That(meta.EntityPaused(ent), Is.False); + Assert.That(meta.EntityPaused(grid), Is.False); + Assert.That(meta.EntityPaused(map), Is.False); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(map).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + + await server.WaitPost(() => entMan.DeleteEntity(map)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + } + + private const string MapDataV3Grid = @" +meta: + format: 3 + name: DemoStation + author: Space-Wizards + postmapinit: false +tilemap: + 0: Space + 1: 1 + 2: 2 + 3: 3 + 4: 4 + 5: 5 + 6: 6 + 7: 7 + 8: 8 + 9: 9 + 10: 10 + 11: 11 + 12: 12 + 13: 13 + 14: 14 + 15: 15 + 16: 16 + 17: 17 + 18: 18 + 19: 19 + 20: 20 + 21: 21 + 22: 22 + 23: 23 + 24: 24 + 25: 25 + 26: 26 + 27: 27 + 28: 28 + 29: 29 + 30: 30 + 31: 31 + 32: 32 + 33: 33 + 34: 34 + 35: 35 + 36: 36 + 37: 37 + 38: 38 + 39: 39 + 40: 40 + 41: 41 + 42: 42 + 43: 43 + 44: 44 + 45: 45 + 46: 46 + 47: 47 + 48: 48 + 49: 49 + 50: 50 + 51: 51 + 52: 52 + 53: 53 + 54: 54 + 55: 55 + 56: 56 + 57: 57 + 58: 58 + 59: 59 + 60: 60 + 61: 61 + 62: 62 + 63: 63 + 64: 64 + 65: 65 + 66: 66 + 67: 67 + 68: 68 + 69: 69 + 70: 70 + 71: 71 + 72: 72 + 73: 73 + 74: 74 + 75: 75 + 76: 76 + 77: 77 + 78: 78 + 79: 79 + 80: 80 + 81: 81 + 82: 82 + 83: 83 + 84: 84 + 85: 85 + 86: 86 + 87: 87 + 88: 88 +entities: +- uid: 0 + components: + - type: MetaData + - parent: null + type: Transform + - chunks: + -1,-1: + ind: -1,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAAPgAAAA== + -1,0: + ind: -1,0 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,0: + ind: 0,0 + tiles: CwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,-1: + ind: 0,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + type: MapGrid + - type: Broadphase + - angularDamping: 0.05 + linearDamping: 0.05 + fixedRotation: False + bodyType: Dynamic + type: Physics + - fixtures: [] + type: Fixtures + - type: OccluderTree + - id: grid + type: EntitySaveTest +- uid: 1 + type: V3TestProto + components: + - pos: 0.5,0.5 + parent: 0 + type: Transform + - id: ent + type: EntitySaveTest +... +"; + + private const string MapDataV3Map = @" +meta: + format: 3 + name: DemoStation + author: Space-Wizards + postmapinit: false +tilemap: + 0: Space + 1: 1 + 2: 2 + 3: 3 + 4: 4 + 5: 5 + 6: 6 + 7: 7 + 8: 8 + 9: 9 + 10: 10 + 11: 11 + 12: 12 + 13: 13 + 14: 14 + 15: 15 + 16: 16 + 17: 17 + 18: 18 + 19: 19 + 20: 20 + 21: 21 + 22: 22 + 23: 23 + 24: 24 + 25: 25 + 26: 26 + 27: 27 + 28: 28 + 29: 29 + 30: 30 + 31: 31 + 32: 32 + 33: 33 + 34: 34 + 35: 35 + 36: 36 + 37: 37 + 38: 38 + 39: 39 + 40: 40 + 41: 41 + 42: 42 + 43: 43 + 44: 44 + 45: 45 + 46: 46 + 47: 47 + 48: 48 + 49: 49 + 50: 50 + 51: 51 + 52: 52 + 53: 53 + 54: 54 + 55: 55 + 56: 56 + 57: 57 + 58: 58 + 59: 59 + 60: 60 + 61: 61 + 62: 62 + 63: 63 + 64: 64 + 65: 65 + 66: 66 + 67: 67 + 68: 68 + 69: 69 + 70: 70 + 71: 71 + 72: 72 + 73: 73 + 74: 74 + 75: 75 + 76: 76 + 77: 77 + 78: 78 + 79: 79 + 80: 80 + 81: 81 + 82: 82 + 83: 83 + 84: 84 + 85: 85 + 86: 86 + 87: 87 + 88: 88 +entities: +- uid: 123 + components: + - type: MetaData + - type: Transform + - type: Map + - type: EntitySaveTest + id: map +- uid: 0 + components: + - type: MetaData + - parent: 123 + type: Transform + - chunks: + -1,-1: + ind: -1,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAAPgAAAA== + -1,0: + ind: -1,0 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,0: + ind: 0,0 + tiles: CwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,-1: + ind: 0,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + type: MapGrid + - type: Broadphase + - angularDamping: 0.05 + linearDamping: 0.05 + fixedRotation: False + bodyType: Dynamic + type: Physics + - fixtures: [] + type: Fixtures + - type: OccluderTree + - id: grid + type: EntitySaveTest +- uid: 1 + type: V3TestProto + components: + - pos: 0.5,0.5 + parent: 0 + type: Transform + - id: ent + type: EntitySaveTest +... +"; + + private const string PrototypeV3 = @" +- type: entity + id: V3TestProto + components: + - type: EntitySaveTest + list: [ 1, 2 ] +"; +} diff --git a/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v4.cs b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v4.cs new file mode 100644 index 00000000000..ec21ab37b41 --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v4.cs @@ -0,0 +1,254 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.ContentPack; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Components; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Utility; +using static Robust.UnitTesting.Shared.EntitySerialization.EntitySaveTestComponent; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +[TestFixture] +public sealed partial class BackwardsCompatibilityTest +{ + /// + /// Check that v4 maps can be loaded. This simply tries to load a file and doesn't do a lot of extra validation. + /// TBH I don't even know if theres a real difference between v4 and v5. + /// + /// + /// The file was pilfered from content integration tests ("floor3x3.yml") and modified slightly. + /// + [Test] + public async Task TestLoadV4() + { + var server = StartServer(new ServerIntegrationOptions {ExtraPrototypes = PrototypeV4}); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var mapSys = server.System(); + var loader = server.System(); + var meta = server.System(); + var tileMan = server.ResolveDependency(); + var resourceManager = server.ResolveDependency(); + + tileMan.Register(new TileDef("Space")); + tileMan.Register(new TileDef("A")); + tileMan.Register(new TileDef("B")); + var gridPath = new ResPath($"{nameof(MapDataV4Grid)}.yml"); + resourceManager.MountString(gridPath.ToString(), MapDataV4Grid); + + MapId mapId = default; + EntityUid mapUid = default; + + Entity map; + Entity ent; + Entity grid; + + Assert.That(entMan.Count(), Is.EqualTo(0)); + await server.WaitPost(() => mapUid = mapSys.CreateMap(out mapId)); + await server.WaitPost(() => Assert.That(loader.TryLoadGrid(mapId, gridPath, out _))); + + Assert.That(entMan.Count(), Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(2)); + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(mapUid)); + + Assert.That(meta.EntityPaused(ent), Is.False); + Assert.That(meta.EntityPaused(grid), Is.False); + Assert.That(meta.EntityPaused(mapUid), Is.False); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(mapUid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + + await server.WaitPost(() => entMan.DeleteEntity(mapUid)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + var mapPath = new ResPath($"{nameof(MapDataV4Map)}.yml"); + resourceManager.MountString(mapPath.ToString(), MapDataV4Map); + await server.WaitPost(() => Assert.That(loader.TryLoadMap(mapPath, out _, out _))); + + Assert.That(entMan.Count(), Is.EqualTo(1)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + map = Find(nameof(map), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + Assert.That(meta.EntityPaused(ent), Is.True); + Assert.That(meta.EntityPaused(grid), Is.True); + Assert.That(meta.EntityPaused(map), Is.True); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + Assert.That(entMan.GetComponent(map).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + + await server.WaitPost(() => entMan.DeleteEntity(map)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Repeat test, but with the initialize maps option enabled. + // Apparently mounted strings can only be read a single time. + // So have to re-mount them. + var mapPath2 = new ResPath($"{nameof(MapDataV4Map)}2.yml"); + resourceManager.MountString(mapPath2.ToString(), MapDataV4Map); + + var opts = DeserializationOptions.Default with {InitializeMaps = true}; + await server.WaitPost(() => Assert.That(loader.TryLoadMap(mapPath2, out _, out _, opts))); + + Assert.That(entMan.Count(), Is.EqualTo(1)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + map = Find(nameof(map), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + Assert.That(meta.EntityPaused(ent), Is.False); + Assert.That(meta.EntityPaused(grid), Is.False); + Assert.That(meta.EntityPaused(map), Is.False); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(map).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + + await server.WaitPost(() => entMan.DeleteEntity(map)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + } + + private const string MapDataV4Grid = @" +meta: + format: 4 + postmapinit: false +tilemap: + 0: Space + 11: A + 68: B +entities: +- proto: """" + entities: + - uid: 2 + components: + - type: MetaData + - parent: invalid + type: Transform + - chunks: + -1,-1: + ind: -1,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAARAAAAA== + -1,0: + ind: -1,0 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,0: + ind: 0,0 + tiles: CwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,-1: + ind: 0,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + type: MapGrid + - type: Broadphase + - angularDamping: 0.05 + linearDamping: 0.05 + fixedRotation: False + bodyType: Dynamic + type: Physics + - type: OccluderTree + - id: grid + type: EntitySaveTest +- proto: V4TestProto + entities: + - uid: 1 + components: + - pos: 0.5,0.5 + parent: 2 + type: Transform + - id: ent + type: EntitySaveTest +... +"; + + private const string MapDataV4Map = @" +meta: + format: 4 + postmapinit: false +tilemap: + 0: Space + 11: A + 68: B +entities: +- proto: """" + entities: + - uid: 123 + components: + - type: MetaData + - type: Transform + - type: Map + - type: EntitySaveTest + id: map + - uid: 2 + components: + - type: MetaData + - parent: 123 + type: Transform + - chunks: + -1,-1: + ind: -1,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAARAAAAA== + -1,0: + ind: -1,0 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,0: + ind: 0,0 + tiles: CwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,-1: + ind: 0,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + type: MapGrid + - type: Broadphase + - angularDamping: 0.05 + linearDamping: 0.05 + fixedRotation: False + bodyType: Dynamic + type: Physics + - type: OccluderTree + - id: grid + type: EntitySaveTest +- proto: V4TestProto + entities: + - uid: 1 + components: + - pos: 0.5,0.5 + parent: 2 + type: Transform + - id: ent + type: EntitySaveTest +"; + + private const string PrototypeV4 = @" +- type: entity + id: V4TestProto + components: + - type: EntitySaveTest + list: [ 1, 2 ] +"; +} diff --git a/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v5.cs b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v5.cs new file mode 100644 index 00000000000..95acc003789 --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v5.cs @@ -0,0 +1,256 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.ContentPack; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Components; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Utility; +using static Robust.UnitTesting.Shared.EntitySerialization.EntitySaveTestComponent; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +[TestFixture] +public sealed partial class BackwardsCompatibilityTest +{ + /// + /// Check that v5 maps can be loaded. This simply tries to load a file and doesn't do a lot of extra validation. + /// + /// + /// The file was pilfered from content integration tests ("floor3x3.yml") and modified slightly. + /// + [Test] + public async Task TestLoadV5() + { + var server = StartServer(new ServerIntegrationOptions {ExtraPrototypes = PrototypeV5}); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var mapSys = server.System(); + var loader = server.System(); + var meta = server.System(); + var tileMan = server.ResolveDependency(); + var resourceManager = server.ResolveDependency(); + + tileMan.Register(new TileDef("Space")); + tileMan.Register(new TileDef("A")); + tileMan.Register(new TileDef("B")); + var gridPath = new ResPath($"{nameof(MapDataV5Grid)}.yml"); + resourceManager.MountString(gridPath.ToString(), MapDataV5Grid); + + MapId mapId = default; + EntityUid mapUid = default; + + Entity map; + Entity ent; + Entity grid; + + Assert.That(entMan.Count(), Is.EqualTo(0)); + await server.WaitPost(() => mapUid = mapSys.CreateMap(out mapId)); + await server.WaitPost(() => Assert.That(loader.TryLoadGrid(mapId, gridPath, out _))); + + Assert.That(entMan.Count(), Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(2)); + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(mapUid)); + + Assert.That(meta.EntityPaused(ent), Is.False); + Assert.That(meta.EntityPaused(grid), Is.False); + Assert.That(meta.EntityPaused(mapUid), Is.False); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(mapUid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + + await server.WaitPost(() => entMan.DeleteEntity(mapUid)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + var mapPath = new ResPath($"{nameof(MapDataV5Map)}.yml"); + resourceManager.MountString(mapPath.ToString(), MapDataV5Map); + await server.WaitPost(() => Assert.That(loader.TryLoadMap(mapPath, out _, out _))); + + Assert.That(entMan.Count(), Is.EqualTo(1)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + map = Find(nameof(map), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + Assert.That(meta.EntityPaused(ent), Is.True); + Assert.That(meta.EntityPaused(grid), Is.True); + Assert.That(meta.EntityPaused(map), Is.True); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + Assert.That(entMan.GetComponent(map).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + + await server.WaitPost(() => entMan.DeleteEntity(map)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Repeat test, but with the initialize maps option enabled. + // Apparently mounted strings can only be read a single time. + // So have to re-mount them. + var mapPath2 = new ResPath($"{nameof(MapDataV5Map)}2.yml"); + resourceManager.MountString(mapPath2.ToString(), MapDataV5Map); + + var opts = DeserializationOptions.Default with {InitializeMaps = true}; + await server.WaitPost(() => Assert.That(loader.TryLoadMap(mapPath2, out _, out _, opts))); + + Assert.That(entMan.Count(), Is.EqualTo(1)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + map = Find(nameof(map), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + Assert.That(meta.EntityPaused(ent), Is.False); + Assert.That(meta.EntityPaused(grid), Is.False); + Assert.That(meta.EntityPaused(map), Is.False); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(map).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + + await server.WaitPost(() => entMan.DeleteEntity(map)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + } + + private const string MapDataV5Grid = @" +meta: + format: 5 + postmapinit: false +tilemap: + 0: Space + 11: A + 69: B +entities: +- proto: """" + entities: + - uid: 2 + components: + - type: MetaData + - parent: invalid + type: Transform + - chunks: + -1,-1: + ind: -1,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAARQAAAA== + -1,0: + ind: -1,0 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,0: + ind: 0,0 + tiles: CwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,-1: + ind: 0,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + type: MapGrid + - type: Broadphase + - angularDamping: 0.05 + linearDamping: 0.05 + fixedRotation: False + bodyType: Dynamic + type: Physics + - fixtures: {} + type: Fixtures + - type: OccluderTree + - id: grid + type: EntitySaveTest +- proto: V5TestProto + entities: + - uid: 1 + components: + - pos: 0.5,0.5 + parent: 2 + type: Transform + - id: ent + type: EntitySaveTest +"; + + private const string MapDataV5Map = @" +meta: + format: 5 + postmapinit: false +tilemap: + 0: Space + 11: A + 69: B +entities: +- proto: """" + entities: + - uid: 123 + components: + - type: MetaData + - type: Transform + - type: Map + - type: EntitySaveTest + id: map + - uid: 2 + components: + - type: MetaData + - parent: 123 + type: Transform + - chunks: + -1,-1: + ind: -1,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAARQAAAA== + -1,0: + ind: -1,0 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,0: + ind: 0,0 + tiles: CwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + 0,-1: + ind: 0,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + type: MapGrid + - type: Broadphase + - angularDamping: 0.05 + linearDamping: 0.05 + fixedRotation: False + bodyType: Dynamic + type: Physics + - fixtures: {} + type: Fixtures + - type: OccluderTree + - id: grid + type: EntitySaveTest +- proto: V5TestProto + entities: + - uid: 1 + components: + - pos: 0.5,0.5 + parent: 2 + type: Transform + - id: ent + type: EntitySaveTest +"; + + private const string PrototypeV5 = @" +- type: entity + id: V5TestProto + components: + - type: EntitySaveTest + list: [ 1, 2 ] +"; +} diff --git a/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v6.cs b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v6.cs new file mode 100644 index 00000000000..c76b1c3c81f --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v6.cs @@ -0,0 +1,263 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.ContentPack; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Components; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Utility; +using static Robust.UnitTesting.Shared.EntitySerialization.EntitySaveTestComponent; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +[TestFixture] +public sealed partial class BackwardsCompatibilityTest +{ + /// + /// Check that v6 maps can be loaded. This simply tries to load a file and doesn't do a lot of extra validation. + /// + /// + /// The file was pilfered from content integration tests ("floor3x3.yml") and modified slightly. + /// + [Test] + public async Task TestLoadV6() + { + var server = StartServer(new ServerIntegrationOptions {ExtraPrototypes = PrototypeV6}); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var mapSys = server.System(); + var loader = server.System(); + var meta = server.System(); + var tileMan = server.ResolveDependency(); + var resourceManager = server.ResolveDependency(); + + tileMan.Register(new TileDef("Space")); + tileMan.Register(new TileDef("A")); + tileMan.Register(new TileDef("B")); + var gridPath = new ResPath($"{nameof(MapDataV6Grid)}.yml"); + resourceManager.MountString(gridPath.ToString(), MapDataV6Grid); + + MapId mapId = default; + EntityUid mapUid = default; + + Entity map; + Entity ent; + Entity grid; + + Assert.That(entMan.Count(), Is.EqualTo(0)); + await server.WaitPost(() => mapUid = mapSys.CreateMap(out mapId)); + await server.WaitPost(() => Assert.That(loader.TryLoadGrid(mapId, gridPath, out _))); + + Assert.That(entMan.Count(), Is.EqualTo(0)); + Assert.That(entMan.Count(), Is.EqualTo(2)); + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(mapUid)); + + Assert.That(meta.EntityPaused(ent), Is.False); + Assert.That(meta.EntityPaused(grid), Is.False); + Assert.That(meta.EntityPaused(mapUid), Is.False); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(mapUid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + + await server.WaitPost(() => entMan.DeleteEntity(mapUid)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + var mapPath = new ResPath($"{nameof(MapDataV6Map)}.yml"); + resourceManager.MountString(mapPath.ToString(), MapDataV6Map); + await server.WaitPost(() => Assert.That(loader.TryLoadMap(mapPath, out _, out _))); + + Assert.That(entMan.Count(), Is.EqualTo(1)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + map = Find(nameof(map), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + Assert.That(meta.EntityPaused(ent), Is.True); + Assert.That(meta.EntityPaused(grid), Is.True); + Assert.That(meta.EntityPaused(map), Is.True); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + Assert.That(entMan.GetComponent(map).EntityLifeStage, + Is.EqualTo(EntityLifeStage.Initialized)); + + await server.WaitPost(() => entMan.DeleteEntity(map)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Repeat test, but with the initialize maps option enabled. + // Apparently mounted strings can only be read a single time. + // So have to re-mount them. + var mapPath2 = new ResPath($"{nameof(MapDataV6Map)}2.yml"); + resourceManager.MountString(mapPath2.ToString(), MapDataV6Map); + + var opts = DeserializationOptions.Default with {InitializeMaps = true}; + await server.WaitPost(() => Assert.That(loader.TryLoadMap(mapPath2, out _, out _, opts))); + + Assert.That(entMan.Count(), Is.EqualTo(1)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + map = Find(nameof(map), entMan); + + Assert.That(ent.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(grid.Comp1.ParentUid, Is.EqualTo(map.Owner)); + Assert.That(map.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + Assert.That(meta.EntityPaused(ent), Is.False); + Assert.That(meta.EntityPaused(grid), Is.False); + Assert.That(meta.EntityPaused(map), Is.False); + + Assert.That(entMan.GetComponent(ent).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(grid).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + Assert.That(entMan.GetComponent(map).EntityLifeStage, + Is.EqualTo(EntityLifeStage.MapInitialized)); + + await server.WaitPost(() => entMan.DeleteEntity(map)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + } + + private const string MapDataV6Grid = @" +meta: + format: 6 + postmapinit: false +tilemap: + 0: Space + 11: A + 89: B +entities: +- proto: """" + entities: + - uid: 2 + components: + - type: MetaData + - type: Transform + parent: invalid + - type: MapGrid + chunks: + -1,-1: + ind: -1,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAWQAAAAAA + version: 6 + -1,0: + ind: -1,0 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + version: 6 + 0,0: + ind: 0,0 + tiles: CwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + version: 6 + 0,-1: + ind: 0,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + version: 6 + - type: Broadphase + - type: Physics + bodyStatus: InAir + angularDamping: 0.05 + linearDamping: 0.05 + fixedRotation: False + bodyType: Dynamic + - type: OccluderTree + - type: EntitySaveTest + id: grid +- proto: V6TestProto + entities: + - uid: 1 + components: + - type: Transform + pos: 0.5,0.5 + parent: 2 + - type: EntitySaveTest + id: ent +"; + + private const string MapDataV6Map = @" +meta: + format: 6 + postmapinit: false +tilemap: + 0: Space + 11: A + 89: B +entities: +- proto: """" + entities: + - uid: 123 + components: + - type: MetaData + - type: Transform + - type: Map + mapPaused: True + - type: EntitySaveTest + id: map + - uid: 2 + components: + - type: MetaData + - type: Transform + parent: 123 + - type: MapGrid + chunks: + -1,-1: + ind: -1,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAWQAAAAAA + version: 6 + -1,0: + ind: -1,0 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + version: 6 + 0,0: + ind: 0,0 + tiles: CwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + version: 6 + 0,-1: + ind: 0,-1 + tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + version: 6 + - type: Broadphase + - type: Physics + bodyStatus: InAir + angularDamping: 0.05 + linearDamping: 0.05 + fixedRotation: False + bodyType: Dynamic + - type: OccluderTree + - type: EntitySaveTest + id: grid +- proto: V6TestProto + entities: + - uid: 1 + components: + - type: Transform + pos: 0.5,0.5 + parent: 2 + - type: EntitySaveTest + id: ent +"; + + private const string PrototypeV6 = @" +- type: entity + id: V6TestProto + components: + - type: EntitySaveTest + list: [ 1, 2 ] +"; +} diff --git a/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v7.cs b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v7.cs new file mode 100644 index 00000000000..cee38152352 --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v7.cs @@ -0,0 +1,344 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.ContentPack; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Components; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Utility; +using static Robust.UnitTesting.Shared.EntitySerialization.EntitySaveTestComponent; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +/// +/// Test that older file formats can still be loaded. +/// +[TestFixture] +public sealed partial class BackwardsCompatibilityTest : RobustIntegrationTest +{ + /// + /// Check that v7 maps can be loaded. This just re-uses some map files that are generated by other tests, and then + /// checks that the post-load debug asserts still pass. specifically, it uses the second to last file from + /// and the initial file from + /// . + /// + /// + /// At the time of writing, v7 is the current version, but this is here for when the version increases in the future. + /// + [Test] + public async Task TestLoadV7() + { + var server = StartServer(); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var mapSys = server.System(); + var loader = server.System(); + var tileMan = server.ResolveDependency(); + var resourceManager = server.ResolveDependency(); + + tileMan.Register(new TileDef("space")); + tileMan.Register(new TileDef("a")); + + void AssertCount(int expected) => Assert.That(entMan.Count(), Is.EqualTo(expected)); + + await server.WaitPost(() => mapSys.CreateMap(out _)); + var mapLoadOpts = MapLoadOptions.Default with + { + DeserializationOptions = DeserializationOptions.Default with {LogOrphanedGrids = false} + }; + + // Test the file from AutoIncludeSerializationTest + { + var path = new ResPath($"{nameof(MapDataV7)}.yml"); + resourceManager.MountString(path.ToString(), MapDataV7); + + Entity grid; + Entity onGrid; + Entity otherMap; + Entity otherEnt; + Entity nullSpace; + + LoadResult? result = default; + await server.WaitAssertion(() => Assert.That(loader.TryLoadGeneric(path, out result, mapLoadOpts))); + + Assert.That(result!.Version, Is.EqualTo(7)); + Assert.That(result.Grids, Has.Count.EqualTo(1)); + Assert.That(result.Orphans, Is.Empty); // Grid was orphaned, but was adopted after a new map was created + Assert.That(result.Maps, Has.Count.EqualTo(2)); + Assert.That(result.NullspaceEntities, Has.Count.EqualTo(1)); + Assert.That(entMan.Count(), Is.EqualTo(1)); // auto-generated map isn't marked as "loaded" + AssertCount(5); + + grid = Find(nameof(grid), entMan); + onGrid = Find(nameof(onGrid), entMan); + otherMap = Find(nameof(otherMap), entMan); + otherEnt = Find(nameof(otherEnt), entMan); + nullSpace = Find(nameof(nullSpace), entMan); + + Assert.That(onGrid.Comp1.ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(otherEnt.Comp1.ParentUid, Is.EqualTo(otherMap.Owner)); + Assert.That(otherMap.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + Assert.That(nullSpace.Comp1.ParentUid, Is.EqualTo(EntityUid.Invalid)); + + await server.WaitPost(() => entMan.DeleteEntity(otherMap)); + await server.WaitPost(() => entMan.DeleteEntity(grid.Comp1.ParentUid)); + await server.WaitPost(() => entMan.DeleteEntity(nullSpace)); + AssertCount(0); + } + + + // Test the file from LifestageSerializationTest.TestMixedLifestageSerialization + { + var pathLifestage = new ResPath($"{nameof(MapDataV7Lifestage)}.yml"); + resourceManager.MountString(pathLifestage.ToString(), MapDataV7Lifestage); + + Entity mapA; // preinit Map + Entity mapB; // postinit unpaused Map + Entity entA; // postinit entity on preinit map + Entity entB; // paused entity on postinit unpaused map + Entity entC; // preinit entity on postinit map + Entity nullA; // postinit nullspace entity + Entity nullB; // preinit nullspace entity + Entity nullC; // paused postinit nullspace entity + + Assert.That(entMan.Count(), Is.EqualTo(0)); + LoadResult? result = default; + await server.WaitPost(() => Assert.That(loader.TryLoadGeneric(pathLifestage, out result))); + Assert.That(result!.Version, Is.EqualTo(7)); + Assert.That(entMan.Count(), Is.EqualTo(8)); + + mapA = Find(nameof(mapA), entMan); + mapB = Find(nameof(mapB), entMan); + entA = Find(nameof(entA), entMan); + entB = Find(nameof(entB), entMan); + entC = Find(nameof(entC), entMan); + nullA = Find(nameof(nullA), entMan); + nullB = Find(nameof(nullB), entMan); + nullC = Find(nameof(nullC), entMan); + + AssertPaused(true, mapA, entB, nullC); + AssertPaused(false, mapB, entA, entC, nullA, nullB); + AssertPreInit(true, mapA, entC, nullB); + AssertPreInit(false, mapB, entA, entB, nullA, nullC); + + void AssertPaused(bool expected, params EntityUid[] uids) + { + foreach (var uid in uids) + { + Assert.That(entMan.GetComponent(uid).EntityPaused, Is.EqualTo(expected)); + } + } + + void AssertPreInit(bool expected, params EntityUid[] uids) + { + foreach (var uid in uids) + { + Assert.That(entMan!.GetComponent(uid).EntityLifeStage, + expected + ? Is.LessThan(EntityLifeStage.MapInitialized) + : Is.EqualTo(EntityLifeStage.MapInitialized)); + } + } + } + } + + private const string MapDataV7 = @" +meta: + format: 7 + category: Unknown + engineVersion: 238.0.0 + forkId: """" + forkVersion: """" + time: 12/25/2024 00:40:09 + entityCount: 5 +maps: +- 3 +grids: +- 1 +orphans: +- 1 +nullspace: +- 5 +tilemap: + 1: space + 0: a +entities: +- proto: """" + entities: + - uid: 1 + paused: false + components: + - type: MetaData + name: grid + - type: Transform + parent: invalid + - type: MapGrid + chunks: + 0,0: + ind: 0,0 + tiles: AAAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAAAQAAAAAA + version: 6 + - type: Broadphase + - type: Physics + - type: Fixtures + fixtures: {} + - type: OccluderTree + - type: EntitySaveTest + list: [] + id: grid + - uid: 2 + mapInit: true + components: + - type: MetaData + - type: Transform + pos: 0.5,0.5 + parent: 1 + - type: EntitySaveTest + list: [] + entity: 3 + id: onGrid + - uid: 3 + mapInit: true + components: + - type: MetaData + name: Map Entity + - type: Transform + - type: Map + mapInitialized: True + - type: PhysicsMap + - type: GridTree + - type: MovedGrids + - type: Broadphase + - type: OccluderTree + - type: EntitySaveTest + list: [] + id: otherMap + - uid: 4 + mapInit: true + components: + - type: MetaData + - type: Transform + parent: 3 + - type: EntitySaveTest + list: [] + entity: 5 + id: otherEnt + - uid: 5 + mapInit: true + components: + - type: MetaData + - type: Transform + - type: EntitySaveTest + list: [] + id: nullSpace +"; + + private const string MapDataV7Lifestage = @" +meta: + format: 7 + category: Unknown + engineVersion: 238.0.0 + forkId: """" + forkVersion: """" + time: 12/25/2024 00:50:59 + entityCount: 8 +maps: +- 1 +- 3 +grids: [] +orphans: [] +nullspace: +- 6 +- 7 +- 8 +tilemap: {} +entities: +- proto: """" + entities: + - uid: 1 + components: + - type: MetaData + name: Map Entity + - type: Transform + - type: Map + mapPaused: True + - type: PhysicsMap + - type: GridTree + - type: MovedGrids + - type: Broadphase + - type: OccluderTree + - type: EntitySaveTest + list: [] + id: mapA + - uid: 2 + mapInit: true + components: + - type: MetaData + - type: Transform + parent: 1 + - type: EntitySaveTest + list: [] + id: entA + - uid: 3 + mapInit: true + components: + - type: MetaData + name: Map Entity + - type: Transform + - type: Map + mapInitialized: True + - type: PhysicsMap + - type: GridTree + - type: MovedGrids + - type: Broadphase + - type: OccluderTree + - type: EntitySaveTest + list: [] + id: mapB + - uid: 4 + mapInit: true + paused: true + components: + - type: MetaData + - type: Transform + parent: 3 + - type: EntitySaveTest + list: [] + id: entB + - uid: 5 + paused: false + components: + - type: MetaData + - type: Transform + parent: 3 + - type: EntitySaveTest + list: [] + id: entC + - uid: 6 + mapInit: true + components: + - type: MetaData + - type: Transform + - type: EntitySaveTest + list: [] + id: nullA + - uid: 7 + paused: false + components: + - type: MetaData + - type: Transform + - type: EntitySaveTest + list: [] + id: nullB + - uid: 8 + mapInit: true + paused: true + components: + - type: MetaData + - type: Transform + - type: EntitySaveTest + list: [] + id: nullC +"; +} diff --git a/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/LifestageSerializationTest.cs similarity index 95% rename from Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs rename to Robust.UnitTesting/Shared/EntitySerialization/LifestageSerializationTest.cs index e5575eb3993..1d7ed7c5976 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/LifetimeSerializationTest.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/LifestageSerializationTest.cs @@ -10,22 +10,22 @@ namespace Robust.UnitTesting.Shared.EntitySerialization; [TestFixture] -public sealed partial class LifetimeSerializationTest : RobustIntegrationTest +public sealed partial class LifestageSerializationTest : RobustIntegrationTest { /// /// Check that whether or not an entity has been paused or map-initialized is preserved across saves & loads. /// [Test] - public async Task TestLifetimeSerialization() + public async Task TestLifestageSerialization() { var server = StartServer(); await server.WaitIdleAsync(); var entMan = server.EntMan; var mapSys = server.System(); var loader = server.System(); - var preInitPath = new ResPath($"{nameof(TestLifetimeSerialization)}_preInit.yml"); - var postInitPath = new ResPath($"{nameof(TestLifetimeSerialization)}_postInit.yml"); - var pausedPostInitPath = new ResPath($"{nameof(TestLifetimeSerialization)}_paused.yml"); + var preInitPath = new ResPath($"{nameof(TestLifestageSerialization)}_preInit.yml"); + var postInitPath = new ResPath($"{nameof(TestLifestageSerialization)}_postInit.yml"); + var pausedPostInitPath = new ResPath($"{nameof(TestLifestageSerialization)}_paused.yml"); // Create a pre-init map, and spawn multiple entities on it Entity map = default; @@ -162,11 +162,11 @@ async Task Reload(ResPath f, DeserializationOptions? o = null) } /// - /// Variant of that has multiple maps and combinations. E.g., a single + /// Variant of that has multiple maps and combinations. E.g., a single /// paused entity on an un-paused map. /// [Test] - public async Task TestMixedLifetimeSerialization() + public async Task TestMixedLifestageSerialization() { var server = StartServer(); await server.WaitIdleAsync(); @@ -174,8 +174,8 @@ public async Task TestMixedLifetimeSerialization() var meta = server.System(); var mapSys = server.System(); var loader = server.System(); - var path = new ResPath($"{nameof(TestMixedLifetimeSerialization)}.yml"); - var altPath = new ResPath($"{nameof(TestMixedLifetimeSerialization)}_alt.yml"); + var path = new ResPath($"{nameof(TestMixedLifestageSerialization)}.yml"); + var altPath = new ResPath($"{nameof(TestMixedLifestageSerialization)}_alt.yml"); Entity mapA = default; // preinit Map Entity mapB = default; // postinit unpaused Map From 1221931c47686a28be40dd053d6d9a43832a8ac5 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Wed, 25 Dec 2024 16:39:52 +1300 Subject: [PATCH 25/27] Version comments also fixes wrong v4 format --- Robust.Shared/EntitySerialization/EntitySerializer.cs | 5 +++++ .../EntitySerialization/BackwardsCompatibilityTest.v4.cs | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Robust.Shared/EntitySerialization/EntitySerializer.cs b/Robust.Shared/EntitySerialization/EntitySerializer.cs index ee46c014649..48077e60318 100644 --- a/Robust.Shared/EntitySerialization/EntitySerializer.cs +++ b/Robust.Shared/EntitySerialization/EntitySerializer.cs @@ -40,6 +40,11 @@ namespace Robust.Shared.EntitySerialization; public sealed class EntitySerializer : ISerializationContext, ITypeSerializer { 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(); diff --git a/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v4.cs b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v4.cs index ec21ab37b41..8ca5b74a9f3 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v4.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v4.cs @@ -16,7 +16,6 @@ public sealed partial class BackwardsCompatibilityTest { /// /// Check that v4 maps can be loaded. This simply tries to load a file and doesn't do a lot of extra validation. - /// TBH I don't even know if theres a real difference between v4 and v5. /// /// /// The file was pilfered from content integration tests ("floor3x3.yml") and modified slightly. @@ -139,6 +138,8 @@ public async Task TestLoadV4() private const string MapDataV4Grid = @" meta: format: 4 + name: DemoStation + author: Space-Wizards postmapinit: false tilemap: 0: Space @@ -190,6 +191,8 @@ public async Task TestLoadV4() private const string MapDataV4Map = @" meta: format: 4 + name: DemoStation + author: Space-Wizards postmapinit: false tilemap: 0: Space From b8313f3e2ba19a50df8132eadb4e2347cae20aed Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Fri, 27 Dec 2024 15:41:41 +1300 Subject: [PATCH 26/27] add MapMergeTest --- .../EntitySerialization/EntityDeserializer.cs | 11 +- Robust.Shared/EntitySerialization/Options.cs | 7 +- .../Systems/MapLoaderSystem.Load.cs | 1 + .../Systems/MapLoaderSystem.LoadMap.cs | 43 ++-- .../BackwardsCompatibilityTest.v3.cs | 12 +- .../EntitySerialization/MapMergeTest.cs | 231 ++++++++++++++++++ 6 files changed, 276 insertions(+), 29 deletions(-) create mode 100644 Robust.UnitTesting/Shared/EntitySerialization/MapMergeTest.cs diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index 207abac907a..0b3c9a135a4 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -32,7 +32,13 @@ namespace Robust.Shared.EntitySerialization; public sealed class EntityDeserializer : ISerializationContext, IEntityLoadContext, ITypeSerializer { + // See the comments around EntitySerializer's version const for information about the different versions. + // TBH version three isn't even really fully supported anymore, simply due to changes in engine component serialization. + // E.g., PR #3923 changed the physics fixture serialization from a sequence to a dictionary/mapping. + // So any unmodified v3 file will with a grid will fail to load, though that's technically not due to any map + // file formatting changes. public const int OldestSupportedVersion = 3; + public const int NewestSupportedVersion = EntitySerializer.MapFormatVersion; public SerializationManager.SerializerProvider SerializerProvider { get; } = new(); @@ -997,7 +1003,10 @@ private void GetRootNodes() var totalRoots = 0; foreach (var uid in Result.Entities) { - DebugTools.AssertEqual(maps.Contains(uid), _mapQuery.HasComp(uid)); + if (ToDelete.Contains(uid)) + continue; + + DebugTools.Assert(maps.Contains(uid) == _mapQuery.HasComp(uid)); DebugTools.AssertEqual(grids.Contains(uid), _gridQuery.HasComp(uid)); if (!_xformQuery.TryComp(uid, out var xform)) diff --git a/Robust.Shared/EntitySerialization/Options.cs b/Robust.Shared/EntitySerialization/Options.cs index 26889806fb1..0237f3f9c7a 100644 --- a/Robust.Shared/EntitySerialization/Options.cs +++ b/Robust.Shared/EntitySerialization/Options.cs @@ -60,7 +60,7 @@ public record struct DeserializationOptions() /// /// If true, all maps that get created while loading this file will get paused. - /// Note that this will not automatically unpause maps that were saved while paused. + /// Note that the converse is not true, paused maps will not get un-paused if this is false. /// Pre-mapinit maps are assumed to be paused. /// public bool PauseMaps = false; @@ -97,6 +97,11 @@ public struct MapLoadOptions() /// I.e., this will merge map contents onto an existing map. This will also cause any maps that get loaded to /// delete themselves after their children have been moved. /// + /// + /// Note that this option effectively causes and + /// to have no effect, as the target map is not a map that was + /// created by the deserialization. + /// public MapId? MergeMap = null; /// diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs index 1cfd5fbdde0..83cb096dffa 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs @@ -216,6 +216,7 @@ private void ApplyTransform(EntityDeserializer deserializer, MapLoadOptions opts // We want to apply the transforms to all children of any loaded maps. However, we can't just iterate over the // children of loaded maps, as transform component has not yet been initialized. I.e. xform.Children is empty. + // Hence we iterate over all entities and check which ones are attached to maps. foreach (var uid in deserializer.Result.Entities) { var xform = Transform(uid); diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.LoadMap.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.LoadMap.cs index 507643c7a31..83cbfb407a5 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.LoadMap.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.LoadMap.cs @@ -105,13 +105,11 @@ public bool TryLoadMapWithId( public bool TryMergeMap( MapId mapId, ResPath path, - [NotNullWhen(true)] out Entity? map, [NotNullWhen(true)] out HashSet>? grids, DeserializationOptions? options = null, Vector2 offset = default, Angle rot = default) { - map = null; grids = null; var opts = new MapLoadOptions @@ -132,7 +130,6 @@ public bool TryMergeMap( if (!_mapSystem.TryGetMap(mapId, out var uid) || !TryComp(uid, out MapComponent? comp)) return false; - map = new(uid.Value, comp); grids = result.Grids; return true; } @@ -150,36 +147,36 @@ private void MergeMaps(EntityDeserializer deserializer, MapLoadOptions opts, Has var matrix = Matrix3Helpers.CreateTransform(opts.Offset, rotation); var target = new Entity(targetUid.Value, Transform(targetUid.Value)); - foreach (var uid in deserializer.Result.Orphans) - { - Merge(merged, uid, target, matrix, rotation); - } - - deserializer.Result.Orphans.Clear(); - + // We want to apply the transforms to all children of any loaded maps. However, we can't just iterate over the + // children of loaded maps, as transform component has not yet been initialized. I.e. xform.Children is empty. + // Hence we iterate over all entities and check which ones are attached to maps. + HashSet maps = new(); + HashSet logged = new(); foreach (var uid in deserializer.Result.Entities) { var xform = Transform(uid); if (!_mapQuery.HasComp(xform.ParentUid)) continue; - // The original comment around this bit of logic was just: - // > Smelly - // I don't know what sloth meant by that, but I guess loading a grid-map onto another grid-map for whatever - // reason must be done without offsets? - // Or more generally, loading a mapgrid onto another (potentially non-mapgrid) map is just generally kind of weird. - if (_gridQuery.HasComponent(xform.ParentUid)) - { - Merge(merged, uid, target, Matrix3x2.Identity, Angle.Zero); - } - else + if (_gridQuery.HasComponent(xform.ParentUid) && logged.Add(xform.ParentUid)) { - Merge(merged, uid, target, matrix, rotation); + Log.Error($"Merging a grid-map onto another map is not supported."); + continue; } + + maps.Add(xform.ParentUid); + Merge(merged, uid, target, matrix, rotation); } - deserializer.ToDelete.UnionWith(deserializer.Result.Maps.Select(x => x.Owner)); - deserializer.Result.Maps.Clear(); + deserializer.ToDelete.UnionWith(maps); + deserializer.Result.Maps.RemoveWhere(x => maps.Contains(x.Owner)); + + foreach (var uid in deserializer.Result.Orphans) + { + Merge(merged, uid, target, matrix, rotation); + } + + deserializer.Result.Orphans.Clear(); } private void Merge( diff --git a/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v3.cs b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v3.cs index 0bd17631e67..ab7a478a6f2 100644 --- a/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v3.cs +++ b/Robust.UnitTesting/Shared/EntitySerialization/BackwardsCompatibilityTest.v3.cs @@ -19,6 +19,8 @@ public sealed partial class BackwardsCompatibilityTest /// /// /// The file was pilfered from content integration tests ("floor3x3.yml") and modified slightly. + /// See also the comments around that point out that v3 + /// isn't even really loadable anymore. /// [Test] public async Task TestLoadV3() @@ -33,8 +35,10 @@ public async Task TestLoadV3() var resourceManager = server.ResolveDependency(); tileMan.Register(new TileDef("Space")); - tileMan.Register(new TileDef("A")); - tileMan.Register(new TileDef("B")); + for (var i = 1; i <= 88; i++) + { + tileMan.Register(new TileDef(i.ToString())); + } var gridPath = new ResPath($"{nameof(MapDataV3Grid)}.yml"); resourceManager.MountString(gridPath.ToString(), MapDataV3Grid); @@ -257,7 +261,7 @@ public async Task TestLoadV3() fixedRotation: False bodyType: Dynamic type: Physics - - fixtures: [] + - fixtures: {} type: Fixtures - type: OccluderTree - id: grid @@ -402,7 +406,7 @@ public async Task TestLoadV3() fixedRotation: False bodyType: Dynamic type: Physics - - fixtures: [] + - fixtures: {} type: Fixtures - type: OccluderTree - id: grid diff --git a/Robust.UnitTesting/Shared/EntitySerialization/MapMergeTest.cs b/Robust.UnitTesting/Shared/EntitySerialization/MapMergeTest.cs new file mode 100644 index 00000000000..4b69a0fb60c --- /dev/null +++ b/Robust.UnitTesting/Shared/EntitySerialization/MapMergeTest.cs @@ -0,0 +1,231 @@ +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Utility; +using static Robust.UnitTesting.Shared.EntitySerialization.EntitySaveTestComponent; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +/// +/// Test that loading a pre-init map/grid onto a post-init map should initialize, while loading a post-init map/grid +/// onto a paused map should pause it. +/// +[TestFixture] +public sealed partial class MapMergeTest : RobustIntegrationTest +{ + [Test] + public async Task TestMapMerge() + { + var server = StartServer(); + await server.WaitIdleAsync(); + var entMan = server.EntMan; + var mapSys = server.System(); + var loader = server.System(); + var mapMan = server.ResolveDependency(); + var tileMan = server.ResolveDependency(); + + var mapPath = new ResPath($"{nameof(TestMapMerge)}_map.yml"); + var gridPath = new ResPath($"{nameof(TestMapMerge)}_grid.yml"); + + tileMan.Register(new TileDef("space")); + var tDef = new TileDef("a"); + tileMan.Register(tDef); + + MapId mapId = default; + Entity map = default; + Entity ent = default; + Entity grid = default; + + await server.WaitPost(() => + { + var mapUid = mapSys.CreateMap(out mapId, runMapInit: false); + var gridEnt = mapMan.CreateGridEntity(mapId); + mapSys.SetTile(gridEnt, Vector2i.Zero, new Tile(tDef.TileId)); + var entUid = entMan.SpawnEntity(null, new MapCoordinates(10, 10, mapId)); + map = Get(mapUid, entMan); + ent = Get(entUid, entMan); + grid = Get(gridEnt.Owner, entMan); + }); + + void AssertPaused(EntityUid uid, bool expected = true) + { + Assert.That(entMan.GetComponent(uid).EntityPaused, Is.EqualTo(expected)); + } + + void AssertPreInit(EntityUid uid, bool expected = true) + { + Assert.That(entMan!.GetComponent(uid).EntityLifeStage, + expected + ? Is.LessThan(EntityLifeStage.MapInitialized) + : Is.EqualTo(EntityLifeStage.MapInitialized)); + } + + map.Comp2!.Id = nameof(map); + ent.Comp2!.Id = nameof(ent); + grid.Comp2!.Id = nameof(grid); + + AssertPaused(map); + AssertPreInit(map); + AssertPaused(ent); + AssertPreInit(ent); + AssertPaused(grid); + AssertPreInit(grid); + + // Save then delete everything + Assert.That(loader.TrySaveMap(map, mapPath)); + Assert.That(loader.TrySaveGrid(grid, gridPath)); + Assert.That(entMan.Count(), Is.EqualTo(3)); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load a grid onto a pre-init map. + await server.WaitPost(() => mapSys.CreateMap(out mapId, runMapInit: false)); + Assert.That(mapSys.IsInitialized(mapId), Is.False); + Assert.That(mapSys.IsPaused(mapId), Is.True); + Assert.That(loader.TryLoadGrid(mapId, gridPath, out _)); + Assert.That(entMan.Count(), Is.EqualTo(1)); + grid = Find(nameof(grid), entMan); + AssertPaused(grid); + AssertPreInit(grid); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Merge a map onto a pre-init map. + await server.WaitPost(() => mapSys.CreateMap(out mapId, runMapInit: false)); + Assert.That(mapSys.IsInitialized(mapId), Is.False); + Assert.That(mapSys.IsPaused(mapId), Is.True); + Assert.That(loader.TryMergeMap(mapId, mapPath, out _)); + Assert.That(entMan.Count(), Is.EqualTo(2)); // The loaded map entity gets deleted after merging + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + AssertPaused(grid); + AssertPreInit(grid); + AssertPaused(ent); + AssertPreInit(ent); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load a grid onto a post-init map. + await server.WaitPost(() => mapSys.CreateMap(out mapId, runMapInit: true)); + Assert.That(mapSys.IsInitialized(mapId), Is.True); + Assert.That(mapSys.IsPaused(mapId), Is.False); + Assert.That(loader.TryLoadGrid(mapId, gridPath, out _)); + Assert.That(entMan.Count(), Is.EqualTo(1)); + grid = Find(nameof(grid), entMan); + AssertPaused(grid, false); + AssertPreInit(grid, false); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Merge a map onto a post-init map. + await server.WaitPost(() => mapSys.CreateMap(out mapId, runMapInit: true)); + Assert.That(mapSys.IsInitialized(mapId), Is.True); + Assert.That(mapSys.IsPaused(mapId), Is.False); + Assert.That(loader.TryMergeMap(mapId, mapPath, out _)); + Assert.That(entMan.Count(), Is.EqualTo(2)); + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + AssertPaused(grid, false); + AssertPreInit(grid, false); + AssertPaused(ent, false); + AssertPreInit(ent, false); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load a grid onto a paused post-init map. + await server.WaitPost(() => mapSys.CreateMap(out mapId, runMapInit: true)); + await server.WaitPost(() => mapSys.SetPaused(mapId, true)); + Assert.That(mapSys.IsInitialized(mapId), Is.True); + Assert.That(mapSys.IsPaused(mapId), Is.True); + Assert.That(loader.TryLoadGrid(mapId, gridPath, out _)); + Assert.That(entMan.Count(), Is.EqualTo(1)); + grid = Find(nameof(grid), entMan); + AssertPaused(grid); + AssertPreInit(grid, false); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Merge a map onto a paused post-init map. + await server.WaitPost(() => mapSys.CreateMap(out mapId, runMapInit: true)); + await server.WaitPost(() => mapSys.SetPaused(mapId, true)); + Assert.That(mapSys.IsInitialized(mapId), Is.True); + Assert.That(mapSys.IsPaused(mapId), Is.True); + Assert.That(loader.TryMergeMap(mapId, mapPath, out _)); + Assert.That(entMan.Count(), Is.EqualTo(2)); + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + AssertPaused(grid); + AssertPreInit(grid, false); + AssertPaused(ent); + AssertPreInit(ent, false); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Check that the map initialization deserialziation options have no effect. + // We are loading onto an existing map, deserialization shouldn't modify it directly. + + + // Load a grid onto a pre-init map, with InitializeMaps = true + await server.WaitPost(() => mapSys.CreateMap(out mapId, runMapInit: false)); + Assert.That(mapSys.IsInitialized(mapId), Is.False); + Assert.That(mapSys.IsPaused(mapId), Is.True); + var opts = DeserializationOptions.Default with {InitializeMaps = true}; + Assert.That(loader.TryLoadGrid(mapId, gridPath, out _, opts)); + Assert.That(entMan.Count(), Is.EqualTo(1)); + grid = Find(nameof(grid), entMan); + AssertPaused(grid); + AssertPreInit(grid); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Merge a map onto a pre-init map, with InitializeMaps = true + await server.WaitPost(() => mapSys.CreateMap(out mapId, runMapInit: false)); + Assert.That(mapSys.IsInitialized(mapId), Is.False); + Assert.That(mapSys.IsPaused(mapId), Is.True); + opts = DeserializationOptions.Default with {InitializeMaps = true}; + Assert.That(loader.TryMergeMap(mapId, mapPath, out _)); + Assert.That(entMan.Count(), Is.EqualTo(2)); // The loaded map entity gets deleted after merging + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + AssertPaused(grid); + AssertPreInit(grid); + AssertPaused(ent); + AssertPreInit(ent); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load a grid onto a post-init map, with PauseMaps = true + await server.WaitPost(() => mapSys.CreateMap(out mapId, runMapInit: true)); + Assert.That(mapSys.IsInitialized(mapId), Is.True); + Assert.That(mapSys.IsPaused(mapId), Is.False); + opts = DeserializationOptions.Default with {PauseMaps = true}; + Assert.That(loader.TryLoadGrid(mapId, gridPath, out _)); + Assert.That(entMan.Count(), Is.EqualTo(1)); + grid = Find(nameof(grid), entMan); + AssertPaused(grid, false); + AssertPreInit(grid, false); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + + // Load a grid onto a post-init map, with PauseMaps = true + await server.WaitPost(() => mapSys.CreateMap(out mapId, runMapInit: true)); + Assert.That(mapSys.IsInitialized(mapId), Is.True); + Assert.That(mapSys.IsPaused(mapId), Is.False); + opts = DeserializationOptions.Default with {PauseMaps = true}; + Assert.That(loader.TryMergeMap(mapId, mapPath, out _)); + Assert.That(entMan.Count(), Is.EqualTo(2)); + ent = Find(nameof(ent), entMan); + grid = Find(nameof(grid), entMan); + AssertPaused(grid, false); + AssertPreInit(grid, false); + AssertPaused(ent, false); + AssertPreInit(ent, false); + await server.WaitPost(() => mapSys.DeleteMap(mapId)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + } +} From 5530625ce5b5854db62302f377263c55bee69638 Mon Sep 17 00:00:00 2001 From: ElectroJr Date: Sat, 28 Dec 2024 00:00:27 +1300 Subject: [PATCH 27/27] Add NetEntity support --- .../EntitySerialization/EntityDeserializer.cs | 58 +++++++++++++++---- .../EntitySerialization/EntitySerializer.cs | 45 +++++++++++--- Robust.Shared/GameObjects/NetEntity.cs | 3 +- .../Prototypes/YamlValidationContext.cs | 46 +++++++++++++-- 4 files changed, 124 insertions(+), 28 deletions(-) diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index 0b3c9a135a4..607af94a1d4 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -30,7 +30,8 @@ namespace Robust.Shared.EntitySerialization; /// serialization than the methods provided by . /// public sealed class EntityDeserializer : ISerializationContext, IEntityLoadContext, - ITypeSerializer + ITypeSerializer, + ITypeSerializer { // See the comments around EntitySerializer's version const for information about the different versions. // TBH version three isn't even really fully supported anymore, simply due to changes in engine component serialization. @@ -1053,7 +1054,7 @@ ValidationNode ITypeValidator.Validate( IDependencyCollection dependencies, ISerializationContext? context) { - if (node.Value is "null" or "invalid") + if (node.Value is "invalid") return new ValidatedValueNode(node); if (!int.TryParse(node.Value, out _)) @@ -1062,12 +1063,12 @@ ValidationNode ITypeValidator.Validate( return new ValidatedValueNode(node); } - public DataNode Write( + DataNode ITypeWriter.Write( ISerializationManager serializationManager, EntityUid value, IDependencyCollection dependencies, - bool alwaysWrite = false, - ISerializationContext? context = null) + bool alwaysWrite, + ISerializationContext? context) { return value.IsValid() ? new ValueDataNode(value.Id.ToString(CultureInfo.InvariantCulture)) @@ -1104,15 +1105,48 @@ EntityUid ITypeReader.Read( return EntityUid.Invalid; } - [MustUseReturnValue] - public EntityUid Copy( + ValidationNode ITypeValidator.Validate( + ISerializationManager serializationManager, + ValueDataNode node, + IDependencyCollection dependencies, + ISerializationContext? context) + { + if (node.Value is "invalid") + return new ValidatedValueNode(node); + + if (!int.TryParse(node.Value, out _)) + return new ErrorNode(node, "Invalid NetEntity"); + + return new ValidatedValueNode(node); + } + + NetEntity ITypeReader.Read( + ISerializationManager serializationManager, + ValueDataNode node, + IDependencyCollection dependencies, + SerializationHookContext hookCtx, + ISerializationContext? context, + ISerializationManager.InstantiationDelegate? instanceProvider) + { + var uid = serializationManager.Read(node, context); + + if (EntMan.TryGetNetEntity(uid, out var nent)) + return nent.Value; + + _log.Error($"Failed to get NetEntity entity {EntMan.ToPrettyString(uid)}"); + return NetEntity.Invalid; + } + + DataNode ITypeWriter.Write( ISerializationManager serializationManager, - EntityUid source, - EntityUid target, - bool skipHook, - ISerializationContext? context = null) + NetEntity value, + IDependencyCollection dependencies, + bool alwaysWrite, + ISerializationContext? context) { - return new(source.Id); + return value.IsValid() + ? new ValueDataNode(value.Id.ToString(CultureInfo.InvariantCulture)) + : new ValueDataNode("invalid"); } #endregion diff --git a/Robust.Shared/EntitySerialization/EntitySerializer.cs b/Robust.Shared/EntitySerialization/EntitySerializer.cs index 48077e60318..63742106c17 100644 --- a/Robust.Shared/EntitySerialization/EntitySerializer.cs +++ b/Robust.Shared/EntitySerialization/EntitySerializer.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; -using JetBrains.Annotations; using Robust.Shared.Configuration; using Robust.Shared.EntitySerialization.Components; using Robust.Shared.EntitySerialization.Systems; @@ -37,7 +36,9 @@ namespace Robust.Shared.EntitySerialization; /// document using the various "Write" methods. (e.g., ). After a one has finished using /// the generated data, the serializer needs to be reset () using it again to serialize other entities. /// -public sealed class EntitySerializer : ISerializationContext, ITypeSerializer +public sealed class EntitySerializer : ISerializationContext, + ITypeSerializer, + ITypeSerializer { public const int MapFormatVersion = 7; // v6->v7: PR #5572 - Added more metadata, List maps/grids/orphans, include some life-stage information @@ -855,7 +856,7 @@ ValidationNode ITypeValidator.Validate( IDependencyCollection dependencies, ISerializationContext? context) { - if (node.Value is "null" or "invalid") + if (node.Value == "invalid") return new ValidatedValueNode(node); if (!int.TryParse(node.Value, out _)) @@ -943,15 +944,41 @@ EntityUid ITypeReader.Read( return node.Value == "invalid" ? EntityUid.Invalid : EntityUid.Parse(node.Value); } - [MustUseReturnValue] - public EntityUid Copy( + public ValidationNode Validate( ISerializationManager serializationManager, - EntityUid source, - EntityUid target, - bool skipHook, + 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? 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) { - return new(source.Id); + var uid = EntMan.GetEntity(value); + return serializationManager.WriteValue(uid, alwaysWrite, context); } #endregion diff --git a/Robust.Shared/GameObjects/NetEntity.cs b/Robust.Shared/GameObjects/NetEntity.cs index e65dedf919e..bfad64bb8c4 100644 --- a/Robust.Shared/GameObjects/NetEntity.cs +++ b/Robust.Shared/GameObjects/NetEntity.cs @@ -3,6 +3,7 @@ using Robust.Shared.IoC; using Robust.Shared.Prototypes; using Robust.Shared.Serialization; +using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.Timing; using Robust.Shared.Utility; using Robust.Shared.ViewVariables; @@ -12,7 +13,7 @@ namespace Robust.Shared.GameObjects; /// /// Network identifier for entities; used by client and server to refer to the same entity where their local may differ. /// -[Serializable, NetSerializable] +[Serializable, NetSerializable, CopyByRef] public readonly struct NetEntity : IEquatable, IComparable, ISpanFormattable { public readonly int Id; diff --git a/Robust.Shared/Prototypes/YamlValidationContext.cs b/Robust.Shared/Prototypes/YamlValidationContext.cs index 1838f9e8807..eb87bf3d61d 100644 --- a/Robust.Shared/Prototypes/YamlValidationContext.cs +++ b/Robust.Shared/Prototypes/YamlValidationContext.cs @@ -11,7 +11,10 @@ namespace Robust.Shared.Prototypes; -internal sealed class YamlValidationContext : ISerializationContext, ITypeSerializer +internal sealed class YamlValidationContext : + ISerializationContext, + ITypeSerializer, + ITypeSerializer { public SerializationManager.SerializerProvider SerializerProvider { get; } = new(); public bool WritingReadingPrototypes => true; @@ -24,7 +27,7 @@ public YamlValidationContext() ValidationNode ITypeValidator.Validate(ISerializationManager serializationManager, ValueDataNode node, IDependencyCollection dependencies, ISerializationContext? context) { - if (node.Value == "null" || node.Value == "invalid") + if (node.Value == "invalid") return new ValidatedValueNode(node); return new ErrorNode(node, "Prototypes should not contain EntityUids", true); @@ -52,11 +55,42 @@ EntityUid ITypeReader.Read(ISerializationManager seria return EntityUid.Parse(node.Value); } - [MustUseReturnValue] - public EntityUid Copy(ISerializationManager serializationManager, EntityUid source, EntityUid target, - bool skipHook, + public ValidationNode Validate( + ISerializationManager serializationManager, + ValueDataNode node, + IDependencyCollection dependencies, ISerializationContext? context = null) { - return new((int)source); + if (node.Value == "invalid") + return new ValidatedValueNode(node); + + return new ErrorNode(node, "Prototypes should not contain NetEntities"); + } + + public NetEntity Read( + ISerializationManager serializationManager, + ValueDataNode node, + IDependencyCollection dependencies, + SerializationHookContext hookCtx, + ISerializationContext? context = null, + ISerializationManager.InstantiationDelegate? instanceProvider = null) + { + if (node.Value == "invalid") + return NetEntity.Invalid; + + return NetEntity.Parse(node.Value); + } + + public DataNode Write( + ISerializationManager serializationManager, + NetEntity value, + IDependencyCollection dependencies, + bool alwaysWrite = false, + ISerializationContext? context = null) + { + if (!value.Valid) + return new ValueDataNode("invalid"); + + return new ValueDataNode(value.Id.ToString(CultureInfo.InvariantCulture)); } }