diff --git a/Resources/Locale/en-US/commands.ftl b/Resources/Locale/en-US/commands.ftl index 4d4e3264618..51a5964d0c1 100644 --- a/Resources/Locale/en-US/commands.ftl +++ b/Resources/Locale/en-US/commands.ftl @@ -175,6 +175,20 @@ cmd-hint-loadmap-y-position = [y-position] cmd-hint-loadmap-rotation = [rotation] cmd-hint-loadmap-uids = [float] +cmd-savegame-desc = Serializes all game entities to disk. Will save all entities, paused an unpaused. +cmd-savegame-help = savegame +cmd-savegame-attempt = Attempting to save full game state to {$path}. +cmd-savegame-success = Game state successfully saved. +cmd-savegame-error = Could not save the game state! See server log for details. +cmd-savegame-disabled = Game saves are disabled on this server. + +cmd-loadgame-desc = Loads a full game state from disk into the game. Flushes all existing entities +cmd-loadgame-help = loadgame +cmd-loadgame-attempt = Attempting to load full game state from {$path}. +cmd-loadgame-success = Game state successfully loaded. +cmd-loadgame-error = Could not load the game state! See server log for details. +cmd-loadgame-disabled = Game saves are disabled on this server. + cmd-hint-savebp-id = ## 'flushcookies' command diff --git a/Robust.Client/GameObjects/EntitySystems/ContainerSystem.cs b/Robust.Client/GameObjects/EntitySystems/ContainerSystem.cs index 865fb785709..41982515691 100644 --- a/Robust.Client/GameObjects/EntitySystems/ContainerSystem.cs +++ b/Robust.Client/GameObjects/EntitySystems/ContainerSystem.cs @@ -116,7 +116,7 @@ private void HandleComponentState(EntityUid uid, ContainerManagerComponent compo if (!component.Containers.TryGetValue(id, out var container)) { var type = _serializer.FindSerializedType(typeof(BaseContainer), data.ContainerType); - container = _dynFactory.CreateInstanceUnchecked(type!, inject:false); + container = _dynFactory.CreateInstanceUnchecked(type!, inject: false); container.Init(this, id, (uid, component)); component.Containers.Add(id, container); } @@ -169,15 +169,15 @@ private void HandleComponentState(EntityUid uid, ContainerManagerComponent compo { var entity = stateEnts[i]; var netEnt = stateNetEnts[i]; + if (!entity.IsValid()) { - DebugTools.Assert(netEnt.IsValid()); - AddExpectedEntity(netEnt, container); + if (netEnt.IsValid()) + AddExpectedEntity(netEnt, container); continue; } var meta = MetaData(entity); - DebugTools.Assert(meta.NetEntity == netEnt); // If an entity is currently in the shadow realm, it means we probably left PVS and are now getting // back into range. We do not want to directly insert this entity, as IF the container and entity diff --git a/Robust.Server/Console/Commands/MapCommands.cs b/Robust.Server/Console/Commands/MapCommands.cs index 8e5d4bd8666..41e659884ab 100644 --- a/Robust.Server/Console/Commands/MapCommands.cs +++ b/Robust.Server/Console/Commands/MapCommands.cs @@ -1,10 +1,13 @@ using System.Linq; using System.Numerics; +using Robust.Shared; +using Robust.Shared.Configuration; using Robust.Shared.Console; using Robust.Shared.ContentPack; using Robust.Shared.EntitySerialization; using Robust.Shared.EntitySerialization.Systems; using Robust.Shared.GameObjects; +using Robust.Shared.GameSaves; using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Map; @@ -334,4 +337,112 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) shell.WriteLine(Loc.GetString("cmd-loadmap-error", ("path", args[1]))); } } + + public sealed class SaveGame : LocalizedCommands + { + [Dependency] private readonly IEntitySystemManager _system = default!; + [Dependency] private readonly IResourceManager _resource = default!; + [Dependency] private readonly IConfigurationManager _config = default!; + + public override string Command => "savegame"; + + public override CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + switch (args.Length) + { + case 1: + var opts = CompletionHelper.UserFilePath(args[0], _resource.UserData); + return CompletionResult.FromHintOptions(opts, Loc.GetString("cmd-hint-savemap-path")); + case 2: + return CompletionResult.FromHint(Loc.GetString("cmd-hint-savemap-force")); + } + return CompletionResult.Empty; + } + + public override void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (!_config.GetCVar(CVars.GameSavesEnabled)) + { + shell.WriteLine(Loc.GetString("cmd-savegame-disabled")); + return; + } + + if (args.Length < 1) + { + shell.WriteLine(Help); + return; + } + + shell.WriteLine(Loc.GetString("cmd-savegame-attempt", ("path", args[0]))); + bool saveSuccess = _system.GetEntitySystem().TrySaveGame(new ResPath(args[0])); + if(saveSuccess) + { + shell.WriteLine(Loc.GetString("cmd-savegame-success")); + } + else + { + shell.WriteError(Loc.GetString("cmd-savegame-error")); + } + } + } + + public sealed class LoadGame : LocalizedCommands + { + [Dependency] private readonly IEntityManager _entMan = default!; + [Dependency] private readonly IEntitySystemManager _system = default!; + [Dependency] private readonly IResourceManager _resource = default!; + [Dependency] private readonly IConfigurationManager _config = default!; + + public override string Command => "loadgame"; + + public override CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + switch (args.Length) + { + case 1: + var opts = CompletionHelper.UserFilePath(args[0], _resource.UserData); + return CompletionResult.FromHintOptions(opts, Loc.GetString("cmd-hint-savemap-path")); + case 2: + return CompletionResult.FromHint(Loc.GetString("cmd-hint-savemap-force")); + } + return CompletionResult.Empty; + } + + public override void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (!_config.GetCVar(CVars.GameSavesEnabled)) + { + shell.WriteLine(Loc.GetString("cmd-savegame-disabled")); + return; + } + + if (args.Length < 1) + { + shell.WriteLine(Help); + return; + } + + var flush = false; + if (args.Length == 2 && !bool.TryParse(args[1], out flush)) + { + shell.WriteError(Loc.GetString("cmd-parse-failure-bool", ("arg", args[1]))); + return; + } + + shell.WriteLine(Loc.GetString("cmd-loadgame-attempt", ("path", args[0]))); + + if (flush) + _entMan.FlushEntities(); + + bool loadSuccess = _system.GetEntitySystem().TryLoadGame(new ResPath(args[0])); + if(loadSuccess) + { + shell.WriteLine(Loc.GetString("cmd-loadgame-success")); + } + else + { + shell.WriteError(Loc.GetString("cmd-loadgame-error")); + } + } + } } diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs index 3019547cc5b..6ff7b7eaa8a 100644 --- a/Robust.Shared/CVars.cs +++ b/Robust.Shared/CVars.cs @@ -1962,5 +1962,26 @@ internal static readonly CVarDef /// public static readonly CVarDef LoadingShowDebug = CVarDef.Create("loading.show_debug", DefaultShowDebug, CVar.CLIENTONLY); + + /* + * GAME SAVES + */ + + /// + /// Whether to allow saving and loading all entities. + /// Should be enabled only after the repository is tested, and it's confirmed that + /// saving and loading in stable scenarios doesn't throw any errors. + /// + public static readonly CVarDef GameSavesEnabled = + CVarDef.Create("gamesaves.enabled", true, CVar.SERVER | CVar.REPLICATED); + + /// + /// ZSTD compression level to use when compressing game saves. + /// + public static readonly CVarDef GameSavesCompressLevel = + CVarDef.Create("gamesaves.compress_level", 3, CVar.ARCHIVE); + + public static readonly CVarDef GameSavesAutoloadName = + CVarDef.Create("gamesaves.autoload_name", "save", CVar.SERVERONLY); } } diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs index 3c08280693b..ee116f7b0a3 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.Load.cs @@ -80,7 +80,7 @@ public bool TryLoadGeneric(ResPath file, [NotNullWhen(true)] out LoadResult? res return TryLoadGeneric(data, file.ToString(), out result, options); } - private bool TryLoadGeneric( + public bool TryLoadGeneric( MappingDataNode data, string fileName, [NotNullWhen(true)] out LoadResult? result, diff --git a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs index 55077e374f7..c83d2d4bcc7 100644 --- a/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs +++ b/Robust.Shared/EntitySerialization/Systems/MapLoaderSystem.cs @@ -135,6 +135,10 @@ public void Delete(LoadResult result) { Del(uid); } - } + foreach (var uid in result.NullspaceEntities) + { + Del(uid); + } + } } diff --git a/Robust.Shared/GameObjects/EntityManager.Network.cs b/Robust.Shared/GameObjects/EntityManager.Network.cs index 370d43269a0..478b360fec6 100644 --- a/Robust.Shared/GameObjects/EntityManager.Network.cs +++ b/Robust.Shared/GameObjects/EntityManager.Network.cs @@ -183,7 +183,7 @@ public NetEntity GetNetEntity(EntityUid uid, MetaDataComponent? metadata = null) if (uid == EntityUid.Invalid) return NetEntity.Invalid; - if (!MetaQuery.Resolve(uid, ref metadata)) + if (!MetaQuery.Resolve(uid, ref metadata, logMissing: false)) return NetEntity.Invalid; return metadata.NetEntity; diff --git a/Robust.Shared/GameSaves/GameSaveEvent.cs b/Robust.Shared/GameSaves/GameSaveEvent.cs new file mode 100644 index 00000000000..fcdfdd14810 --- /dev/null +++ b/Robust.Shared/GameSaves/GameSaveEvent.cs @@ -0,0 +1,16 @@ +using Robust.Shared.GameObjects; +using Robust.Shared.Utility; + +namespace Robust.Shared.GameSaves; + +/// +/// Event that is raised before all entities from the current state are saved to file. +/// +[ByRefEvent] +public readonly record struct BeforeGameSaveEvent(ResPath SavePath); + +/// +/// Event that is raised before loading all saved entities from the file. +/// +[ByRefEvent] +public readonly record struct BeforeGameLoadEvent(ResPath SavePath); diff --git a/Robust.Shared/GameSaves/GameSavesSystem.cs b/Robust.Shared/GameSaves/GameSavesSystem.cs new file mode 100644 index 00000000000..8e0adc10d7e --- /dev/null +++ b/Robust.Shared/GameSaves/GameSavesSystem.cs @@ -0,0 +1,170 @@ +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Robust.Shared.Configuration; +using Robust.Shared.ContentPack; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Serialization; +using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Utility; +using SharpZstd.Interop; + +namespace Robust.Shared.GameSaves; + +public sealed class GameSavesSystem : EntitySystem +{ + [Dependency] private readonly IResourceManager _resourceManager = default!; + [Dependency] private readonly IRobustSerializer _serializer = default!; + [Dependency] private readonly IConfigurationManager _config = default!; + [Dependency] private readonly MapLoaderSystem _mapLoader = default!; + + /// + /// File extension that represents a ZSTD compressed YAML file with a single mapping data node. + /// + public const string Extension = ".rtsave"; + + private bool _enabled; + + public override void Initialize() + { + base.Initialize(); + _zstdContext = new ZStdCompressionContext(); + _zstdContext.SetParameter(ZSTD_cParameter.ZSTD_c_compressionLevel, _config.GetCVar(CVars.GameSavesCompressLevel)); + Subs.CVar(_config, CVars.GameSavesEnabled, value => _enabled = value, true); + } + + public bool TrySaveGame(ResPath path) + { + if (!_enabled) + return false; + + var ev = new BeforeGameSaveEvent(path); + RaiseLocalEvent(ref ev); + + if (!_mapLoader.TrySerializeAllEntities(out var data)) + return false; + + WriteCompressedZstd(path, data); + return true; + } + + public bool TryLoadGame(ResPath path) + { + if (!_enabled) + return false; + + var ev = new BeforeGameLoadEvent(path); + RaiseLocalEvent(ref ev); + + if (!TryReadCompressedZstd(path, out var data)) + return false; + + if (!_mapLoader.TryLoadGeneric(data, path.Filename, out _)) + return false; + + return true; + } + + private ZStdCompressionContext _zstdContext = default!; + + /// + /// Compresses a YAML data node using ZSTD compression. + /// + /// Path to a file without a file extension + /// Mapping data node to compress in the specified path. + private void WriteCompressedZstd(ResPath path, MappingDataNode data) + { + var uncompressedStream = new MemoryStream(); + + _serializer.SerializeDirect(uncompressedStream, MappingNodeToString(data)); + var uncompressed = uncompressedStream.ToArray(); + var poolData = ArrayPool.Shared.Rent(uncompressed.Length); + uncompressed.CopyTo(poolData.AsSpan(0, uncompressed.Length)); + + if (_resourceManager.UserData.RootDir == null) + return; // can't save anything + + byte[]? buf = null; + try + { + // Compress stream to buffer. + // First 4 bytes of buffer are reserved for the length of the uncompressed stream. + var bound = ZStd.CompressBound(uncompressed.Length); + buf = ArrayPool.Shared.Rent(4 + bound); + var compressedLength = _zstdContext.Compress2( + buf.AsSpan(4, bound), + poolData.AsSpan(0, uncompressed.Length)); + + BitConverter.TryWriteBytes(buf.AsSpan(0, 4), uncompressed.Length); + var filePath = Path.Combine(_resourceManager.UserData.RootDir, path.Filename + Extension); + File.WriteAllBytes(filePath, buf.AsSpan(0, 4 + (int)compressedLength).ToArray()); + } + finally + { + ArrayPool.Shared.Return(poolData); + if (buf != null) + ArrayPool.Shared.Return(buf); + } + } + + private bool TryReadCompressedZstd(ResPath path, [NotNullWhen(true)] out MappingDataNode? data) + { + data = null; + + var intBuf = new byte[4]; + + if (_resourceManager.UserData.RootDir == null) return false; + + var filePath = Path.Combine(_resourceManager.UserData.RootDir, path.Filename + Extension); + if (!File.Exists(filePath)) return false; + + using var fileStream = File.OpenRead(filePath); + + fileStream.ReadExactly(intBuf); + var uncompressedSize = BitConverter.ToInt32(intBuf); + + using var decompressStream = new ZStdDecompressStream(fileStream, false); + + var decompressedStream = uncompressedSize > 0 + ? new MemoryStream(uncompressedSize) + : new MemoryStream(); + + decompressStream.CopyTo(decompressedStream); + decompressedStream.Position = 0; + if (uncompressedSize > 0) DebugTools.Assert(uncompressedSize == decompressedStream.Length); + + while (decompressedStream.Position < decompressedStream.Length) + { + _serializer.DeserializeDirect(decompressedStream, out var yml); + if (!TryParseMappingNode(yml, out var node)) + return false; + + data = node; + return true; + } + + return false; + } + + private string MappingNodeToString(MappingDataNode node) + { + return node.ToString(); + } + + private bool TryParseMappingNode(string yml, [NotNullWhen(true)] out MappingDataNode? node) + { + var stream = new StringReader(yml); + foreach (var document in DataNodeParser.ParseYamlStream(stream)) + { + node = (MappingDataNode)document.Root; + return true; + } + + node = null; + return false; + } +}