#nullable enable using System; using System.Collections.Generic; using System.IO.Compression; using System.Linq; using System.Text; using System.Threading; using System.Xml.Linq; using System.Text.RegularExpressions; using Barotrauma.IO; using Microsoft.Xna.Framework; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using Barotrauma.Networking; namespace Barotrauma { public readonly struct CampaignDataPath { public readonly string LoadPath; public readonly string SavePath; public CampaignDataPath(string loadPath, string savePath) { if (IsBackupPath(savePath, out _)) { throw new ArgumentException("Save path cannot be a backup path.", nameof(savePath)); } LoadPath = loadPath; SavePath = savePath; } /// /// Empty path used for non-campaign game sessions. /// public static readonly CampaignDataPath Empty = new CampaignDataPath(loadPath: string.Empty, savePath: string.Empty); /// /// Creates a CampaignDataPath with the same load and save path. /// public static CampaignDataPath CreateRegular(string savePath) => new CampaignDataPath(savePath, savePath); public static bool IsBackupPath(string path, out uint foundIndex) { string extension = Path.GetExtension(path); bool startsWith = extension.StartsWith(SaveUtil.BackupExtension, StringComparison.OrdinalIgnoreCase); if (!startsWith) { foundIndex = 0; return false; } bool hasIndex = SaveUtil.TryGetBackupIndexFromFileName(path, out foundIndex); return hasIndex; } } static class SaveUtil { public const string GameSessionFileName = "gamesession.xml"; private static readonly string LegacySaveFolder = Path.Combine("Data", "Saves"); private static readonly string LegacyMultiplayerSaveFolder = Path.Combine(LegacySaveFolder, "Multiplayer"); #if OSX /// /// These exist because we used to have a workaround here that set the save folder to /// Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "Library", "Application Support", "Daedalic Entertainment GmbH", "Barotrauma") /// on Mac, because apparently LocalApplicationData returned something different than the expected path. That seems to have changed in .NET8, and now we /// can use the same LocalApplicationData on all platforms. We however still check the old path in case someone has their saves there. /// public static readonly string LegacyMacSaveFolder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.Personal), "Library", "Application Support", "Daedalic Entertainment GmbH", "Barotrauma"); public static string LegacyMacMultiplayerSaveFolder = Path.Combine(LegacyMacSaveFolder, "Multiplayer"); #endif //C:/Users/*user*/AppData/Local/Daedalic Entertainment GmbH/ on Windows ///home/*user*/.local/share/Daedalic Entertainment GmbH/ on Linux ///Users/*user*/Library/Application Support/Daedalic Entertainment GmbH/ on Mac public static readonly string DefaultSaveFolder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Daedalic Entertainment GmbH", "Barotrauma"); public static string DefaultMultiplayerSaveFolder = Path.Combine(DefaultSaveFolder, "Multiplayer"); public static readonly string SubmarineDownloadFolder = Path.Combine("Submarines", "Downloaded"); public static readonly string CampaignDownloadFolder = Path.Combine("Data", "Saves", "Multiplayer_Downloaded"); public const string BackupExtension = ".bk"; /// /// .save.bk /// public const string FullBackupExtension = $".save{BackupExtension}"; /// /// .save.bk0 /// public const string BackupExtensionFormat = $"{FullBackupExtension}{{0}}"; /// /// .xml.bk /// public const string BackupCharacterDataExtensionStart = $".xml{BackupExtension}"; /// /// .xml.bk0 /// public const string BackupCharacterDataFormat = $"{BackupCharacterDataExtensionStart}{{0}}"; public static int MaxBackupCount = 3; public static string TempPath { #if SERVER get { return Path.Combine(GetSaveFolder(SaveType.Singleplayer), "temp_server"); } #else get { string tempFolder = Path.Combine(GetSaveFolder(SaveType.Singleplayer), "temp"); #if DEBUG if (GameClient.MultiClientTestMode && GameMain.Client != null) { //append the name of the client to the download folder to avoid multiple clients //from trying to download a file into the same path at the same time tempFolder += "_" + GameMain.Client.Name; } #endif return tempFolder; } #endif } public static void EnsureSaveFolderExists() { try { // Create the default save folder (only) if it doesn't exist yet. // note, uses System.IO.Directory.CreateDirectory instead of Directory.CreateDirectory from Baro namespace on purpose. System.IO.Directory.CreateDirectory(DefaultSaveFolder); } catch (Exception e) { DebugConsole.ThrowError($"Failed to create the default save folder \"{DefaultSaveFolder}\"!", e); } } public enum SaveType { Singleplayer, Multiplayer } /// /// Saves the game to a file. /// /// The path to the save file. /// /// Indicates if the save is happening during loading in multiplayer /// to ensure the campaign ID matches the one in the save file. /// Used to work around some quirks with the backup system. /// public static void SaveGame(CampaignDataPath filePath, bool isSavingOnLoading = false) { if (!isSavingOnLoading && File.Exists(filePath.SavePath)) { BackupSave(filePath.SavePath); } DebugConsole.Log("Saving the game to: " + filePath); Directory.CreateDirectory(TempPath, catchUnauthorizedAccessExceptions: true); try { ClearFolder(TempPath, new string[] { GameMain.GameSession.SubmarineInfo.FilePath }); } catch (Exception e) { LogErrorAndSendToClients("Failed to clear folder", e); return; } try { GameMain.GameSession.Save(Path.Combine(TempPath, GameSessionFileName), isSavingOnLoading); if (!isSavingOnLoading) { // Reset the campaign data path, since if we had a different load path, it would be invalid now GameMain.GameSession.DataPath = CampaignDataPath.CreateRegular(filePath.SavePath); } } catch (Exception e) { LogErrorAndSendToClients("Error saving gamesession", e); return; } try { string? mainSubPath = null; if (GameMain.GameSession.SubmarineInfo != null) { mainSubPath = Path.Combine(TempPath, GameMain.GameSession.SubmarineInfo.Name + ".sub"); GameMain.GameSession.SubmarineInfo.SaveAs(mainSubPath); for (int i = 0; i < GameMain.GameSession.OwnedSubmarines.Count; i++) { if (GameMain.GameSession.OwnedSubmarines[i].Name == GameMain.GameSession.SubmarineInfo.Name) { GameMain.GameSession.OwnedSubmarines[i] = GameMain.GameSession.SubmarineInfo; } } } if (GameMain.GameSession.OwnedSubmarines != null) { for (int i = 0; i < GameMain.GameSession.OwnedSubmarines.Count; i++) { SubmarineInfo storedInfo = GameMain.GameSession.OwnedSubmarines[i]; string subPath = Path.Combine(TempPath, storedInfo.Name + ".sub"); if (mainSubPath == subPath) { continue; } storedInfo.SaveAs(subPath); } } } catch (Exception e) { LogErrorAndSendToClients("Error saving submarine", e); return; } try { CompressDirectory(TempPath, filePath.SavePath); } catch (Exception e) { LogErrorAndSendToClients("Error compressing save file", e); } void LogErrorAndSendToClients(string errorMsg, Exception e) { DebugConsole.ThrowError(errorMsg, e); #if SERVER if (GameMain.Server != null) { foreach (var client in GameMain.Server.ConnectedClients) { GameMain.Server.SendDirectChatMessage(errorMsg + '\n' + e.StackTrace.CleanupStackTrace(), client, ChatMessageType.Error); } } #endif } } public static void LoadGame(CampaignDataPath path) { //ensure there's no gamesession/sub loaded because it'd lead to issues when starting a new one (e.g. trying to determine which level to load based on the placement of the sub) //can happen if a gamesession is interrupted ungracefully (exception during loading) Submarine.Unload(); GameMain.GameSession = null; DebugConsole.Log("Loading save file: " + path.LoadPath); DecompressToDirectory(path.LoadPath, TempPath); XDocument doc = XMLExtensions.TryLoadXml(Path.Combine(TempPath, GameSessionFileName)); if (doc == null) { return; } if (!IsSaveFileCompatible(doc)) { throw new Exception($"The save file \"{path.LoadPath}\" is not compatible with this version of Barotrauma."); } var ownedSubmarines = LoadOwnedSubmarines(doc, out SubmarineInfo selectedSub); GameMain.GameSession = new GameSession(selectedSub, ownedSubmarines, doc, path); } public static List LoadOwnedSubmarines(XDocument saveDoc, out SubmarineInfo selectedSub) { string subPath = Path.Combine(TempPath, saveDoc.Root.GetAttributeString("submarine", "")) + ".sub"; selectedSub = new SubmarineInfo(subPath); List ownedSubmarines = new List(); var ownedSubsElement = saveDoc.Root?.Element("ownedsubmarines"); if (ownedSubsElement == null) { return ownedSubmarines; } foreach (var subElement in ownedSubsElement.Elements()) { string subName = subElement.GetAttributeString("name", ""); string ownedSubPath = Path.Combine(TempPath, subName + ".sub"); if (!File.Exists(ownedSubPath)) { DebugConsole.ThrowError($"Could not find the submarine \"{subName}\" ({ownedSubPath})! The save file may be corrupted. Removing the submarine from owned submarines..."); } else { ownedSubmarines.Add(new SubmarineInfo(ownedSubPath)); } } return ownedSubmarines; } public static bool IsSaveFileCompatible(XDocument? saveDoc) => IsSaveFileCompatible(saveDoc?.Root); public static bool IsSaveFileCompatible(XElement? saveDocRoot) { if (saveDocRoot?.Attribute("version") == null) { return false; } return true; } public static void DeleteSave(string filePath) { try { File.Delete(filePath, catchUnauthorizedAccessExceptions: false); string[] backups = GetBackupPaths(Path.GetDirectoryName(filePath) ?? "", Path.GetFileNameWithoutExtension(filePath)); foreach (string backup in backups) { File.Delete(backup, catchUnauthorizedAccessExceptions: false); } } catch (Exception e) { DebugConsole.ThrowError("ERROR: deleting save file \"" + filePath + "\" failed.", e); } //deleting a multiplayer save file -> also delete character data var fullPath = Path.GetFullPath(Path.GetDirectoryName(filePath) ?? ""); if (fullPath.Equals(Path.GetFullPath(DefaultMultiplayerSaveFolder)) || fullPath == Path.GetFullPath(GetSaveFolder(SaveType.Multiplayer))) { string characterDataSavePath = MultiPlayerCampaign.GetCharacterDataSavePath(filePath); if (File.Exists(characterDataSavePath)) { try { File.Delete(characterDataSavePath, catchUnauthorizedAccessExceptions: false); } catch (Exception e) { DebugConsole.ThrowError("ERROR: deleting character data file \"" + characterDataSavePath + "\" failed.", e); } } } } public static string GetSaveFolder(SaveType saveType) { string folder = string.Empty; if (!string.IsNullOrEmpty(GameSettings.CurrentConfig.SavePath)) { folder = GameSettings.CurrentConfig.SavePath; if (saveType == SaveType.Multiplayer) { folder = Path.Combine(folder, "Multiplayer"); } if (!Directory.Exists(folder)) { DebugConsole.AddWarning($"Could not find the custom save folder \"{folder}\", creating the folder..."); try { Directory.CreateDirectory(folder, catchUnauthorizedAccessExceptions: false); } catch (Exception e) { DebugConsole.ThrowError($"Could not find the custom save folder \"{folder}\". Using the default save path instead.", e); folder = string.Empty; } } } if (string.IsNullOrEmpty(folder)) { folder = saveType == SaveType.Singleplayer ? DefaultSaveFolder : DefaultMultiplayerSaveFolder; } return folder; } public static IReadOnlyList GetSaveFiles(SaveType saveType, bool includeInCompatible = true, bool logLoadErrors = true) { string defaultFolder = saveType == SaveType.Singleplayer ? DefaultSaveFolder : DefaultMultiplayerSaveFolder; if (!Directory.Exists(defaultFolder)) { DebugConsole.Log("Save folder \"" + defaultFolder + " not found! Attempting to create a new folder..."); try { Directory.CreateDirectory(defaultFolder, catchUnauthorizedAccessExceptions: false); } catch (Exception e) { DebugConsole.ThrowError("Failed to create the folder \"" + defaultFolder + "\"!", e); } } List files = Directory.GetFiles(defaultFolder, "*.save", System.IO.SearchOption.TopDirectoryOnly).ToList(); var folder = GetSaveFolder(saveType); if (!string.IsNullOrEmpty(folder) && Directory.Exists(folder)) { files.AddRange(Directory.GetFiles(folder, "*.save", System.IO.SearchOption.TopDirectoryOnly)); } string legacyFolder = saveType == SaveType.Singleplayer ? LegacySaveFolder : LegacyMultiplayerSaveFolder; if (Directory.Exists(legacyFolder)) { files.AddRange(Directory.GetFiles(legacyFolder, "*.save", System.IO.SearchOption.TopDirectoryOnly)); } #if OSX string legacyMacFolder = saveType == SaveType.Singleplayer ? LegacyMacSaveFolder : LegacyMacMultiplayerSaveFolder; if (Directory.Exists(legacyMacFolder)) { files.AddRange(Directory.GetFiles(legacyMacFolder, "*.save", System.IO.SearchOption.TopDirectoryOnly)); } #endif files = files.Distinct().ToList(); List saveInfos = new List(); foreach (string file in files) { var docRoot = ExtractGameSessionRootElementFromSaveFile(file, logLoadErrors); if (!includeInCompatible && !IsSaveFileCompatible(docRoot)) { continue; } if (docRoot == null) { saveInfos.Add(new CampaignMode.SaveInfo( FilePath: file, SaveTime: Option.None, SubmarineName: "", RespawnMode: RespawnMode.None, EnabledContentPackageNames: ImmutableArray.Empty)); } else { List enabledContentPackageNames = new List(); //backwards compatibility string enabledContentPackagePathsStr = docRoot.GetAttributeStringUnrestricted("selectedcontentpackages", string.Empty); foreach (string packagePath in enabledContentPackagePathsStr.Split('|')) { if (string.IsNullOrEmpty(packagePath)) { continue; } //change paths to names string fileName = Path.GetFileNameWithoutExtension(packagePath); if (fileName == "filelist") { enabledContentPackageNames.Add(Path.GetFileName(Path.GetDirectoryName(packagePath) ?? "")); } else { enabledContentPackageNames.Add(fileName); } } string enabledContentPackageNamesStr = docRoot.GetAttributeStringUnrestricted("selectedcontentpackagenames", string.Empty); //split on pipes, excluding pipes preceded by \ foreach (string packageName in Regex.Split(enabledContentPackageNamesStr, @"(? 255) { throw new Exception( $"Failed to compress \"{sDir}\" (file name length > 255)."); } // File name length is encoded as a 32-bit little endian integer here zipStream.WriteByte((byte)sRelativePath.Length); zipStream.WriteByte(0); zipStream.WriteByte(0); zipStream.WriteByte(0); // File name content is encoded as little-endian UTF-16 var strBytes = Encoding.Unicode.GetBytes(sRelativePath.CleanUpPathCrossPlatform(correctFilenameCase: false)); zipStream.Write(strBytes, 0, strBytes.Length); //Compress file content byte[] bytes = File.ReadAllBytes(Path.Combine(sDir, sRelativePath)); zipStream.Write(BitConverter.GetBytes(bytes.Length), 0, sizeof(int)); zipStream.Write(bytes, 0, bytes.Length); } public static void CompressDirectory(string sInDir, string sOutFile) { IEnumerable sFiles = Directory.GetFiles(sInDir, "*.*", System.IO.SearchOption.AllDirectories); int iDirLen = sInDir[^1] == Path.DirectorySeparatorChar ? sInDir.Length : sInDir.Length + 1; using var outFile = File.Open(sOutFile, System.IO.FileMode.Create, System.IO.FileAccess.Write) ?? throw new Exception($"Failed to create file \"{sOutFile}\""); using GZipStream str = new GZipStream(outFile, CompressionMode.Compress); foreach (string sFilePath in sFiles) { string sRelativePath = sFilePath.Substring(iDirLen); CompressFile(sInDir, sRelativePath, str); } } public static System.IO.Stream DecompressFileToStream(string fileName) { using FileStream originalFileStream = File.Open(fileName, System.IO.FileMode.Open, System.IO.FileAccess.Read) ?? throw new Exception($"Failed to open file \"{fileName}\""); System.IO.MemoryStream streamToReturn = new System.IO.MemoryStream(); using GZipStream gzipStream = new GZipStream(originalFileStream, CompressionMode.Decompress); gzipStream.CopyTo(streamToReturn); streamToReturn.Position = 0; return streamToReturn; } private static bool IsExtractionPathValid(string rootDir, string fileDir) { string getFullPath(string dir) => (string.IsNullOrEmpty(dir) ? Directory.GetCurrentDirectory() : Path.GetFullPath(dir)) .CleanUpPathCrossPlatform(correctFilenameCase: false); string rootDirFull = getFullPath(rootDir); string fileDirFull = getFullPath(fileDir); return fileDirFull.StartsWith(rootDirFull, StringComparison.OrdinalIgnoreCase); } private static bool DecompressFile(System.IO.BinaryReader reader, [NotNullWhen(returnValue: true)]out string? fileName, [NotNullWhen(returnValue: true)]out byte[]? fileContent) { fileName = null; fileContent = null; if (reader.PeekChar() < 0) { return false; } //Decompress file name int nameLen = reader.ReadInt32(); if (nameLen > 255) { throw new Exception( $"Failed to decompress (file name length > 255). The file may be corrupted."); } byte[] strBytes = reader.ReadBytes(nameLen * sizeof(char)); string sFileName = Encoding.Unicode.GetString(strBytes) .Replace('\\', '/'); fileName = sFileName; //Decompress file content int contentLen = reader.ReadInt32(); fileContent = reader.ReadBytes(contentLen); return true; } public static void DecompressToDirectory(string sCompressedFile, string sDir) { DebugConsole.Log("Decompressing " + sCompressedFile + " to " + sDir + "..."); const int maxRetries = 4; for (int i = 0; i <= maxRetries; i++) { try { using var memStream = DecompressFileToStream(sCompressedFile); using var reader = new System.IO.BinaryReader(memStream); while (DecompressFile(reader, out var fileName, out var contentBytes)) { string sFilePath = Path.Combine(sDir, fileName); string sFinalDir = Path.GetDirectoryName(sFilePath) ?? ""; if (!IsExtractionPathValid(sDir, sFinalDir)) { throw new InvalidOperationException( $"Error extracting \"{fileName}\": cannot be extracted to parent directory"); } Directory.CreateDirectory(sFinalDir); using var outFile = File.Open(sFilePath, System.IO.FileMode.Create, System.IO.FileAccess.Write) ?? throw new Exception($"Failed to create file \"{sFilePath}\""); outFile.Write(contentBytes, 0, contentBytes.Length); } break; } catch (System.IO.IOException e) { if (i >= maxRetries || !File.Exists(sCompressedFile)) { throw; } DebugConsole.NewMessage("Failed decompress file \"" + sCompressedFile + "\" {" + e.Message + "}, retrying in 250 ms...", Color.Red); Thread.Sleep(250); } } } public static IEnumerable EnumerateContainedFiles(string sCompressedFile) { const int maxRetries = 4; HashSet paths = new HashSet(); for (int i = 0; i <= maxRetries; i++) { try { paths.Clear(); using var memStream = DecompressFileToStream(sCompressedFile); using var reader = new System.IO.BinaryReader(memStream); while (DecompressFile(reader, out var fileName, out _)) { paths.Add(fileName); } break; } catch (System.IO.IOException e) { if (i >= maxRetries || !File.Exists(sCompressedFile)) { throw; } DebugConsole.NewMessage( $"Failed to decompress file \"{sCompressedFile}\" for enumeration {{{e.Message}}}, retrying in 250 ms...", Color.Red); Thread.Sleep(250); } } return paths; } /// /// Extracts the save file (including all the subs in it) to a temporary folder and returns the game session document. /// If you only need the gamesession doc, use instead. /// /// /// public static XDocument? DecompressSaveAndLoadGameSessionDoc(string savePath) { DebugConsole.Log("Loading game session doc: " + savePath); try { DecompressToDirectory(savePath, TempPath); } catch (Exception e) { DebugConsole.ThrowError("Error decompressing " + savePath, e); return null; } return XMLExtensions.TryLoadXml(Path.Combine(TempPath, "gamesession.xml")); } /// /// Extract *only* the root element of the gamesession.xml file in the given save. /// For performance reasons, none of its child elements are returned. /// public static XElement? ExtractGameSessionRootElementFromSaveFile(string savePath, bool logLoadErrors = true) { const int maxRetries = 4; for (int i = 0; i <= maxRetries; i++) { try { using var memStream = DecompressFileToStream(savePath); using var reader = new System.IO.BinaryReader(memStream); while (DecompressFile(reader, out var fileName, out var fileContent)) { if (fileName != GameSessionFileName) { continue; } // Found the file! Here's a quick byte-wise parser to find the root element int tagOpenerStartIndex = -1; for (int j = 0; j < fileContent.Length; j++) { if (fileContent[j] == '<') { // Found a tag opener: return null if we had already found one if (tagOpenerStartIndex >= 0) { return null; } tagOpenerStartIndex = j; } else if (j > 0 && fileContent[j] == '?' && fileContent[j - 1] == '<') { // Found the XML version element, skip this tagOpenerStartIndex = -1; } else if (fileContent[j] == '>') { // Found a tag closer, if we know where the tag opener is then we've found the root element if (tagOpenerStartIndex < 0) { continue; } string elemStr = Encoding.UTF8.GetString(fileContent.AsSpan()[tagOpenerStartIndex..j]) + "/>"; try { return XElement.Parse(elemStr); } catch (Exception e) { DebugConsole.NewMessage( $"Failed to parse gamesession root in \"{savePath}\": {{{e.Message}}}.", Color.Red); // Parsing the element failed! Return null instead of crashing here return null; } } } } break; } catch (System.IO.IOException e) { if (i >= maxRetries || !File.Exists(savePath)) { throw; } DebugConsole.NewMessage( $"Failed to decompress file \"{savePath}\" for root extraction ({e.Message}), retrying in 250 ms...", Color.Red); Thread.Sleep(250); } catch (System.IO.InvalidDataException e) { if (logLoadErrors) { DebugConsole.ThrowError($"Failed to decompress file \"{savePath}\" for root extraction.", e); } return null; } } return null; } public static void DeleteDownloadedSubs() { if (Directory.Exists(SubmarineDownloadFolder)) { ClearFolder(SubmarineDownloadFolder); } } public static void CleanUnnecessarySaveFiles() { if (Directory.Exists(CampaignDownloadFolder)) { ClearFolder(CampaignDownloadFolder); Directory.Delete(CampaignDownloadFolder); } if (Directory.Exists(TempPath)) { ClearFolder(TempPath); Directory.Delete(TempPath); } } public static void ClearFolder(string folderName, string[]? ignoredFileNames = null) { DirectoryInfo dir = new DirectoryInfo(folderName); foreach (FileInfo fi in dir.GetFiles()) { if (ignoredFileNames != null) { bool ignore = false; foreach (string ignoredFile in ignoredFileNames) { if (Path.GetFileName(fi.FullName).Equals(Path.GetFileName(ignoredFile))) { ignore = true; break; } } if (ignore) continue; } fi.IsReadOnly = false; fi.Delete(); } foreach (DirectoryInfo di in dir.GetDirectories()) { ClearFolder(di.FullName, ignoredFileNames); const int maxRetries = 4; for (int i = 0; i <= maxRetries; i++) { try { di.Delete(); break; } catch (System.IO.IOException) { if (i >= maxRetries) { throw; } Thread.Sleep(250); } } } } #region Backup saves [NetworkSerialize] public readonly record struct BackupIndexData(uint Index, Identifier LocationNameIdentifier, int LocationNameFormatIndex, Identifier LocationType, LevelData.LevelType LevelType, SerializableDateTime SaveTime) : INetSerializableStruct; public static string FormatBackupExtension(uint index) => string.Format(BackupExtensionFormat, index); public static string FormatBackupCharacterDataExtension(uint index) => string.Format(BackupCharacterDataFormat, index); public static void BackupSave(string savePath) { string path = Path.GetDirectoryName(savePath) ?? ""; string fileName = Path.GetFileNameWithoutExtension(savePath); string characterDataSavePath = MultiPlayerCampaign.GetCharacterDataSavePath(savePath); string characterDataFileName = Path.GetFileNameWithoutExtension(characterDataSavePath); ImmutableArray indexData = GetIndexData(path, fileName); uint freeIndex = GetFreeIndex(indexData); string newBackupPath = Path.Combine(path, $".{fileName}{FormatBackupExtension(freeIndex)}"); string newCharacterDataBackupPath = Path.Combine(path, $".{characterDataFileName}{FormatBackupCharacterDataExtension(freeIndex)}"); try { BackupFile(savePath, newBackupPath); if (File.Exists(characterDataSavePath)) { BackupFile(characterDataSavePath, newCharacterDataBackupPath); } } catch (Exception e) { DebugConsole.ThrowError("Failed to create a backup of the save file.", e); } static uint GetFreeIndex(IEnumerable indexData) { if (!indexData.Any()) { return 0; } if (indexData.Count() < MaxBackupCount) { uint highestIndex = indexData.Max(static b => b.Index); uint nextIndex = highestIndex + 1; if (indexData.Any(b => b.Index == nextIndex)) { for (uint i = 0; i < MaxBackupCount; i++) { if (indexData.All(b => b.Index != i)) { return i; } } // this should theoretically never happen throw new InvalidOperationException("Failed to find a free index for the backup."); } return nextIndex; } BackupIndexData oldestBackup = indexData.OrderBy(static b => b.SaveTime).First(); return oldestBackup.Index; } static void BackupFile(string sourcePath, string destPath) { // Overwriting a file that is marked as hidden will cause an exception. DeleteIfExists(destPath); System.IO.File.Copy(sourcePath, destPath, overwrite: true); SetHidden(destPath); } } public static void DeleteIfExists(string filePath) { if (System.IO.File.Exists(filePath)) { System.IO.File.Delete(filePath); } } public static ImmutableArray GetIndexData(string fullPath) { string path = Path.GetDirectoryName(fullPath) ?? ""; string fileName = Path.GetFileNameWithoutExtension(fullPath); return GetIndexData(path, fileName); } private static readonly System.IO.EnumerationOptions BackupEnumerationOptions = new System.IO.EnumerationOptions { MatchType = System.IO.MatchType.Win32, AttributesToSkip = System.IO.FileAttributes.System, IgnoreInaccessible = true }; private static string[] GetBackupPaths(string path, string baseName) { try { return System.IO.Directory.GetFiles(path, $".{baseName}{FullBackupExtension}*", BackupEnumerationOptions); } catch (Exception e) { DebugConsole.ThrowError("Failed to get backup paths.", e); } return Array.Empty(); } public static bool TryGetBackupIndexFromFileName(string filePath, out uint index) { string extension = Path.GetExtension(filePath); if (extension.Length < BackupExtension.Length) { DebugConsole.ThrowError($"The file name \"{filePath}\" does not have a valid backup extension."); index = 0; return false; } string indexStr = extension[BackupExtension.Length..]; bool result = uint.TryParse(indexStr, out index); if (!result) { DebugConsole.ThrowError($"Failed to parse the backup index from the file name \"{filePath}\"."); } return result; } private static ImmutableArray GetIndexData(string path, string baseName) { var builder = ImmutableArray.CreateBuilder(); string[] foundBackups = GetBackupPaths(path, baseName); foreach (string backupPath in foundBackups) { if (!TryGetBackupIndexFromFileName(backupPath, out uint index)) { continue; } var gameSession = ExtractGameSessionRootElementFromSaveFile(backupPath, logLoadErrors: false); if (gameSession is null) { DebugConsole.AddWarning($"Failed to load gamesession root from \"{backupPath}\". Skipping this backup."); continue; } SerializableDateTime saveTime = gameSession.GetAttributeDateTime("savetime") .Fallback(SerializableDateTime.FromUtcUnixTime(0L)); Identifier locationNameIdentifier = gameSession.GetAttributeIdentifier("currentlocation", Identifier.Empty); int locationNameFormatIndex = gameSession.GetAttributeInt("currentlocationnameformatindex", -1); Identifier locationType = gameSession.GetAttributeIdentifier("locationtype", Identifier.Empty); LevelData.LevelType levelType = gameSession.GetAttributeEnum("nextleveltype", LevelData.LevelType.LocationConnection); builder.Add(new BackupIndexData(index, locationNameIdentifier, locationNameFormatIndex, locationType, levelType, saveTime)); } return builder.ToImmutable(); } public static string GetBackupPath(string savePath, uint index) { string path = Path.GetDirectoryName(savePath) ?? ""; string fileName = Path.GetFileNameWithoutExtension(savePath); return Path.Combine(path, $".{fileName}{FormatBackupExtension(index)}"); } private static void SetHidden(string filePath) { try { System.IO.File.SetAttributes(filePath, System.IO.File.GetAttributes(filePath) | System.IO.FileAttributes.Hidden); } catch (Exception e) { DebugConsole.ThrowError("Failed to set the backup file as hidden.", e); } } #endregion } }