#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; namespace Barotrauma { 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 //"/*user*/Library/Application Support/Daedalic Entertainment GmbH/" on Mac public static readonly string DefaultSaveFolder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.Personal), "Library", "Application Support", "Daedalic Entertainment GmbH", "Barotrauma"); #else //"C:/Users/*user*/AppData/Local/Daedalic Entertainment GmbH/" on Windows //"/home/*user*/.local/share/Daedalic Entertainment GmbH/" on Linux public static readonly string DefaultSaveFolder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Daedalic Entertainment GmbH", "Barotrauma"); #endif 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 static string TempPath { #if SERVER get { return Path.Combine(GetSaveFolder(SaveType.Singleplayer), "temp_server"); } #else get { return Path.Combine(GetSaveFolder(SaveType.Singleplayer), "temp"); } #endif } public enum SaveType { Singleplayer, Multiplayer } public static void SaveGame(string filePath) { DebugConsole.Log("Saving the game to: " + filePath); Directory.CreateDirectory(TempPath); try { ClearFolder(TempPath, new string[] { GameMain.GameSession.SubmarineInfo.FilePath }); } catch (Exception e) { DebugConsole.ThrowError("Failed to clear folder", e); return; } try { GameMain.GameSession.Save(Path.Combine(TempPath, GameSessionFileName)); } catch (Exception e) { DebugConsole.ThrowError("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) { DebugConsole.ThrowError("Error saving submarine", e); return; } try { CompressDirectory(TempPath, filePath); } catch (Exception e) { DebugConsole.ThrowError("Error compressing save file", e); } } public static void LoadGame(string filePath) { //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: " + filePath); DecompressToDirectory(filePath, TempPath); XDocument doc = XMLExtensions.TryLoadXml(Path.Combine(TempPath, GameSessionFileName)); if (doc == null) { return; } if (!IsSaveFileCompatible(doc)) { throw new Exception($"The save file \"{filePath}\" is not compatible with this version of Barotrauma."); } var ownedSubmarines = LoadOwnedSubmarines(doc, out SubmarineInfo selectedSub); GameMain.GameSession = new GameSession(selectedSub, ownedSubmarines, doc, filePath); } 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); } 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); } 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); } 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); } 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)); } 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: "", 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); } } } } } }