Client LuaCsForBarotrauma
SaveUtil.cs
1 #nullable enable
2 using System;
3 using System.Collections.Generic;
4 using System.IO.Compression;
5 using System.Linq;
6 using System.Text;
7 using System.Threading;
8 using System.Xml.Linq;
9 using System.Text.RegularExpressions;
10 using Barotrauma.IO;
11 using Microsoft.Xna.Framework;
12 using System.Collections.Immutable;
13 using System.Diagnostics.CodeAnalysis;
14 
15 namespace Barotrauma
16 {
17  public readonly struct CampaignDataPath
18  {
19  public readonly string LoadPath;
20  public readonly string SavePath;
21 
22  public CampaignDataPath(string loadPath, string savePath)
23  {
24  if (IsBackupPath(savePath, out _))
25  {
26  throw new ArgumentException("Save path cannot be a backup path.", nameof(savePath));
27  }
28 
29  LoadPath = loadPath;
30  SavePath = savePath;
31  }
32 
36  public static readonly CampaignDataPath Empty = new CampaignDataPath(loadPath: string.Empty, savePath: string.Empty);
37 
41  public static CampaignDataPath CreateRegular(string savePath)
42  => new CampaignDataPath(savePath, savePath);
43 
44  public static bool IsBackupPath(string path, out uint foundIndex)
45  {
46  string extension = Path.GetExtension(path);
47  bool startsWith = extension.StartsWith(SaveUtil.BackupExtension, StringComparison.OrdinalIgnoreCase);
48 
49  if (!startsWith)
50  {
51  foundIndex = 0;
52  return false;
53  }
54 
55  bool hasIndex = SaveUtil.TryGetBackupIndexFromFileName(path, out foundIndex);
56  return hasIndex;
57  }
58  }
59 
60  static class SaveUtil
61  {
62  public const string GameSessionFileName = "gamesession.xml";
63 
64  private static readonly string LegacySaveFolder = Path.Combine("Data", "Saves");
65  private static readonly string LegacyMultiplayerSaveFolder = Path.Combine(LegacySaveFolder, "Multiplayer");
66 
67 #if OSX
68  //"/*user*/Library/Application Support/Daedalic Entertainment GmbH/" on Mac
69  public static readonly string DefaultSaveFolder = Path.Combine(
70  Environment.GetFolderPath(Environment.SpecialFolder.Personal),
71  "Library",
72  "Application Support",
73  "Daedalic Entertainment GmbH",
74  "Barotrauma");
75 #else
76  //"C:/Users/*user*/AppData/Local/Daedalic Entertainment GmbH/" on Windows
77  //"/home/*user*/.local/share/Daedalic Entertainment GmbH/" on Linux
78  public static readonly string DefaultSaveFolder = Path.Combine(
79  Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
80  "Daedalic Entertainment GmbH",
81  "Barotrauma");
82 #endif
83 
84  public static string DefaultMultiplayerSaveFolder = Path.Combine(DefaultSaveFolder, "Multiplayer");
85 
86  public static readonly string SubmarineDownloadFolder = Path.Combine("Submarines", "Downloaded");
87  public static readonly string CampaignDownloadFolder = Path.Combine("Data", "Saves", "Multiplayer_Downloaded");
88 
89  public const string BackupExtension = ".bk";
90 
94  public const string FullBackupExtension = $".save{BackupExtension}";
95 
99  public const string BackupExtensionFormat = $"{FullBackupExtension}{{0}}";
100 
104  public const string BackupCharacterDataExtensionStart = $".xml{BackupExtension}";
105 
109  public const string BackupCharacterDataFormat = $"{BackupCharacterDataExtensionStart}{{0}}";
110 
111  public static int MaxBackupCount = 3;
112 
113  public static string TempPath
114  {
115 #if SERVER
116  get { return Path.Combine(GetSaveFolder(SaveType.Singleplayer), "temp_server"); }
117 #else
118  get { return Path.Combine(GetSaveFolder(SaveType.Singleplayer), "temp"); }
119 #endif
120  }
121 
122  public enum SaveType
123  {
124  Singleplayer,
125  Multiplayer
126  }
127 
137  public static void SaveGame(CampaignDataPath filePath, bool isSavingOnLoading = false)
138  {
139  if (!isSavingOnLoading && File.Exists(filePath.SavePath))
140  {
141  BackupSave(filePath.SavePath);
142  }
143 
144  DebugConsole.Log("Saving the game to: " + filePath);
145  Directory.CreateDirectory(TempPath, catchUnauthorizedAccessExceptions: true);
146  try
147  {
148  ClearFolder(TempPath, new string[] { GameMain.GameSession.SubmarineInfo.FilePath });
149  }
150  catch (Exception e)
151  {
152  DebugConsole.ThrowError("Failed to clear folder", e);
153  return;
154  }
155 
156  try
157  {
158  GameMain.GameSession.Save(Path.Combine(TempPath, GameSessionFileName), isSavingOnLoading);
159  }
160  catch (Exception e)
161  {
162  DebugConsole.ThrowError("Error saving gamesession", e);
163  return;
164  }
165 
166  try
167  {
168  string? mainSubPath = null;
169  if (GameMain.GameSession.SubmarineInfo != null)
170  {
171  mainSubPath = Path.Combine(TempPath, GameMain.GameSession.SubmarineInfo.Name + ".sub");
172  GameMain.GameSession.SubmarineInfo.SaveAs(mainSubPath);
173  for (int i = 0; i < GameMain.GameSession.OwnedSubmarines.Count; i++)
174  {
175  if (GameMain.GameSession.OwnedSubmarines[i].Name == GameMain.GameSession.SubmarineInfo.Name)
176  {
177  GameMain.GameSession.OwnedSubmarines[i] = GameMain.GameSession.SubmarineInfo;
178  }
179  }
180  }
181 
182  if (GameMain.GameSession.OwnedSubmarines != null)
183  {
184  for (int i = 0; i < GameMain.GameSession.OwnedSubmarines.Count; i++)
185  {
186  SubmarineInfo storedInfo = GameMain.GameSession.OwnedSubmarines[i];
187  string subPath = Path.Combine(TempPath, storedInfo.Name + ".sub");
188  if (mainSubPath == subPath) { continue; }
189  storedInfo.SaveAs(subPath);
190  }
191  }
192  }
193  catch (Exception e)
194  {
195  DebugConsole.ThrowError("Error saving submarine", e);
196  return;
197  }
198 
199  try
200  {
201  CompressDirectory(TempPath, filePath.SavePath);
202  }
203  catch (Exception e)
204  {
205  DebugConsole.ThrowError("Error compressing save file", e);
206  }
207  }
208 
209  public static void LoadGame(CampaignDataPath path)
210  {
211  //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)
212  //can happen if a gamesession is interrupted ungracefully (exception during loading)
213  Submarine.Unload();
214  GameMain.GameSession = null;
215  DebugConsole.Log("Loading save file: " + path.LoadPath);
216  DecompressToDirectory(path.LoadPath, TempPath);
217 
218  XDocument doc = XMLExtensions.TryLoadXml(Path.Combine(TempPath, GameSessionFileName));
219  if (doc == null) { return; }
220 
221  if (!IsSaveFileCompatible(doc))
222  {
223  throw new Exception($"The save file \"{path.LoadPath}\" is not compatible with this version of Barotrauma.");
224  }
225 
226  var ownedSubmarines = LoadOwnedSubmarines(doc, out SubmarineInfo selectedSub);
227  GameMain.GameSession = new GameSession(selectedSub, ownedSubmarines, doc, path);
228  }
229 
230  public static List<SubmarineInfo> LoadOwnedSubmarines(XDocument saveDoc, out SubmarineInfo selectedSub)
231  {
232  string subPath = Path.Combine(TempPath, saveDoc.Root.GetAttributeString("submarine", "")) + ".sub";
233  selectedSub = new SubmarineInfo(subPath);
234 
235  List<SubmarineInfo> ownedSubmarines = new List<SubmarineInfo>();
236 
237  var ownedSubsElement = saveDoc.Root?.Element("ownedsubmarines");
238  if (ownedSubsElement == null) { return ownedSubmarines; }
239 
240  foreach (var subElement in ownedSubsElement.Elements())
241  {
242  string subName = subElement.GetAttributeString("name", "");
243  string ownedSubPath = Path.Combine(TempPath, subName + ".sub");
244  if (!File.Exists(ownedSubPath))
245  {
246  DebugConsole.ThrowError($"Could not find the submarine \"{subName}\" ({ownedSubPath})! The save file may be corrupted. Removing the submarine from owned submarines...");
247  }
248  else
249  {
250  ownedSubmarines.Add(new SubmarineInfo(ownedSubPath));
251  }
252  }
253  return ownedSubmarines;
254  }
255 
256  public static bool IsSaveFileCompatible(XDocument? saveDoc)
257  => IsSaveFileCompatible(saveDoc?.Root);
258 
259  public static bool IsSaveFileCompatible(XElement? saveDocRoot)
260  {
261  if (saveDocRoot?.Attribute("version") == null) { return false; }
262  return true;
263  }
264 
265  public static void DeleteSave(string filePath)
266  {
267  try
268  {
269  File.Delete(filePath, catchUnauthorizedAccessExceptions: false);
270 
271  string[] backups = GetBackupPaths(Path.GetDirectoryName(filePath) ?? "", Path.GetFileNameWithoutExtension(filePath));
272  foreach (string backup in backups)
273  {
274  File.Delete(backup, catchUnauthorizedAccessExceptions: false);
275  }
276  }
277  catch (Exception e)
278  {
279  DebugConsole.ThrowError("ERROR: deleting save file \"" + filePath + "\" failed.", e);
280  }
281 
282  //deleting a multiplayer save file -> also delete character data
283  var fullPath = Path.GetFullPath(Path.GetDirectoryName(filePath) ?? "");
284 
285  if (fullPath.Equals(Path.GetFullPath(DefaultMultiplayerSaveFolder)) ||
286  fullPath == Path.GetFullPath(GetSaveFolder(SaveType.Multiplayer)))
287  {
288  string characterDataSavePath = MultiPlayerCampaign.GetCharacterDataSavePath(filePath);
289  if (File.Exists(characterDataSavePath))
290  {
291  try
292  {
293  File.Delete(characterDataSavePath, catchUnauthorizedAccessExceptions: false);
294  }
295  catch (Exception e)
296  {
297  DebugConsole.ThrowError("ERROR: deleting character data file \"" + characterDataSavePath + "\" failed.", e);
298  }
299  }
300  }
301  }
302 
303  public static string GetSaveFolder(SaveType saveType)
304  {
305  string folder = string.Empty;
306 
307  if (!string.IsNullOrEmpty(GameSettings.CurrentConfig.SavePath))
308  {
309  folder = GameSettings.CurrentConfig.SavePath;
310  if (saveType == SaveType.Multiplayer)
311  {
312  folder = Path.Combine(folder, "Multiplayer");
313  }
314  if (!Directory.Exists(folder))
315  {
316  DebugConsole.AddWarning($"Could not find the custom save folder \"{folder}\", creating the folder...");
317  try
318  {
319  Directory.CreateDirectory(folder, catchUnauthorizedAccessExceptions: false);
320  }
321  catch (Exception e)
322  {
323  DebugConsole.ThrowError($"Could not find the custom save folder \"{folder}\". Using the default save path instead.", e);
324  folder = string.Empty;
325  }
326  }
327  }
328  if (string.IsNullOrEmpty(folder))
329  {
330  folder = saveType == SaveType.Singleplayer ? DefaultSaveFolder : DefaultMultiplayerSaveFolder;
331  }
332  return folder;
333  }
334 
335  public static IReadOnlyList<CampaignMode.SaveInfo> GetSaveFiles(SaveType saveType, bool includeInCompatible = true, bool logLoadErrors = true)
336  {
337  string defaultFolder = saveType == SaveType.Singleplayer ? DefaultSaveFolder : DefaultMultiplayerSaveFolder;
338  if (!Directory.Exists(defaultFolder))
339  {
340  DebugConsole.Log("Save folder \"" + defaultFolder + " not found! Attempting to create a new folder...");
341  try
342  {
343  Directory.CreateDirectory(defaultFolder, catchUnauthorizedAccessExceptions: false);
344  }
345  catch (Exception e)
346  {
347  DebugConsole.ThrowError("Failed to create the folder \"" + defaultFolder + "\"!", e);
348  }
349  }
350 
351  List<string> files = Directory.GetFiles(defaultFolder, "*.save", System.IO.SearchOption.TopDirectoryOnly).ToList();
352 
353  var folder = GetSaveFolder(saveType);
354  if (!string.IsNullOrEmpty(folder) && Directory.Exists(folder))
355  {
356  files.AddRange(Directory.GetFiles(folder, "*.save", System.IO.SearchOption.TopDirectoryOnly));
357  }
358 
359  string legacyFolder = saveType == SaveType.Singleplayer ? LegacySaveFolder : LegacyMultiplayerSaveFolder;
360  if (Directory.Exists(legacyFolder))
361  {
362  files.AddRange(Directory.GetFiles(legacyFolder, "*.save", System.IO.SearchOption.TopDirectoryOnly));
363  }
364 
365  files = files.Distinct().ToList();
366 
367  List<CampaignMode.SaveInfo> saveInfos = new List<CampaignMode.SaveInfo>();
368  foreach (string file in files)
369  {
370  var docRoot = ExtractGameSessionRootElementFromSaveFile(file, logLoadErrors);
371  if (!includeInCompatible && !IsSaveFileCompatible(docRoot))
372  {
373  continue;
374  }
375  if (docRoot == null)
376  {
377  saveInfos.Add(new CampaignMode.SaveInfo(
378  FilePath: file,
379  SaveTime: Option.None,
380  SubmarineName: "",
381  EnabledContentPackageNames: ImmutableArray<string>.Empty));
382  }
383  else
384  {
385  List<string> enabledContentPackageNames = new List<string>();
386 
387  //backwards compatibility
388  string enabledContentPackagePathsStr = docRoot.GetAttributeStringUnrestricted("selectedcontentpackages", string.Empty);
389  foreach (string packagePath in enabledContentPackagePathsStr.Split('|'))
390  {
391  if (string.IsNullOrEmpty(packagePath)) { continue; }
392  //change paths to names
393  string fileName = Path.GetFileNameWithoutExtension(packagePath);
394  if (fileName == "filelist")
395  {
396  enabledContentPackageNames.Add(Path.GetFileName(Path.GetDirectoryName(packagePath) ?? ""));
397  }
398  else
399  {
400  enabledContentPackageNames.Add(fileName);
401  }
402  }
403 
404  string enabledContentPackageNamesStr = docRoot.GetAttributeStringUnrestricted("selectedcontentpackagenames", string.Empty);
405  //split on pipes, excluding pipes preceded by \
406  foreach (string packageName in Regex.Split(enabledContentPackageNamesStr, @"(?<!(?<!\\‍)*\\‍)\|"))
407  {
408  if (string.IsNullOrEmpty(packageName)) { continue; }
409  enabledContentPackageNames.Add(packageName.Replace(@"\|", "|"));
410  }
411 
412  saveInfos.Add(new CampaignMode.SaveInfo(
413  FilePath: file,
414  SaveTime: docRoot.GetAttributeDateTime("savetime"),
415  SubmarineName: docRoot.GetAttributeStringUnrestricted("submarine", ""),
416  EnabledContentPackageNames: enabledContentPackageNames.ToImmutableArray()));
417  }
418  }
419 
420  return saveInfos;
421  }
422 
423  public static string CreateSavePath(SaveType saveType, string fileName = "Save_Default")
424  {
425  fileName = ToolBox.RemoveInvalidFileNameChars(fileName);
426 
427  string folder = GetSaveFolder(saveType);
428  if (fileName == "Save_Default")
429  {
430  fileName = TextManager.Get("SaveFile.DefaultName").Value;
431  if (fileName.Length == 0) fileName = "Save";
432  }
433 
434  if (!Directory.Exists(folder))
435  {
436  DebugConsole.Log("Save folder \"" + folder + "\" not found. Created new folder");
437  Directory.CreateDirectory(folder, catchUnauthorizedAccessExceptions: true);
438  }
439 
440  string extension = ".save";
441  string pathWithoutExtension = Path.Combine(folder, fileName);
442 
443  if (!File.Exists(pathWithoutExtension + extension))
444  {
445  return pathWithoutExtension + extension;
446  }
447 
448  int i = 0;
449  while (File.Exists(pathWithoutExtension + " " + i + extension))
450  {
451  i++;
452  }
453 
454  return pathWithoutExtension + " " + i + extension;
455  }
456 
457  public static void CompressStringToFile(string fileName, string value)
458  {
459  // A.
460  // Convert the string to its byte representation.
461  byte[] b = Encoding.UTF8.GetBytes(value);
462 
463  // B.
464  // Use GZipStream to write compressed bytes to target file.
465  using FileStream f2 = File.Open(fileName, System.IO.FileMode.Create)
466  ?? throw new Exception($"Failed to create file \"{fileName}\"");;
467  using GZipStream gz = new GZipStream(f2, CompressionMode.Compress, false);
468  gz.Write(b, 0, b.Length);
469  }
470 
471  private static void CompressFile(string sDir, string sRelativePath, GZipStream zipStream)
472  {
473  //Compress file name
474  if (sRelativePath.Length > 255)
475  {
476  throw new Exception(
477  $"Failed to compress \"{sDir}\" (file name length > 255).");
478  }
479  // File name length is encoded as a 32-bit little endian integer here
480  zipStream.WriteByte((byte)sRelativePath.Length);
481  zipStream.WriteByte(0);
482  zipStream.WriteByte(0);
483  zipStream.WriteByte(0);
484  // File name content is encoded as little-endian UTF-16
485  var strBytes = Encoding.Unicode.GetBytes(sRelativePath.CleanUpPathCrossPlatform(correctFilenameCase: false));
486  zipStream.Write(strBytes, 0, strBytes.Length);
487 
488  //Compress file content
489  byte[] bytes = File.ReadAllBytes(Path.Combine(sDir, sRelativePath));
490  zipStream.Write(BitConverter.GetBytes(bytes.Length), 0, sizeof(int));
491  zipStream.Write(bytes, 0, bytes.Length);
492  }
493 
494  public static void CompressDirectory(string sInDir, string sOutFile)
495  {
496  IEnumerable<string> sFiles = Directory.GetFiles(sInDir, "*.*", System.IO.SearchOption.AllDirectories);
497  int iDirLen = sInDir[^1] == Path.DirectorySeparatorChar ? sInDir.Length : sInDir.Length + 1;
498 
499  using var outFile = File.Open(sOutFile, System.IO.FileMode.Create, System.IO.FileAccess.Write)
500  ?? throw new Exception($"Failed to create file \"{sOutFile}\"");
501  using GZipStream str = new GZipStream(outFile, CompressionMode.Compress);
502  foreach (string sFilePath in sFiles)
503  {
504  string sRelativePath = sFilePath.Substring(iDirLen);
505  CompressFile(sInDir, sRelativePath, str);
506  }
507  }
508 
509 
510  public static System.IO.Stream DecompressFileToStream(string fileName)
511  {
512  using FileStream originalFileStream = File.Open(fileName, System.IO.FileMode.Open, System.IO.FileAccess.Read)
513  ?? throw new Exception($"Failed to open file \"{fileName}\"");
514  System.IO.MemoryStream streamToReturn = new System.IO.MemoryStream();
515 
516  using GZipStream gzipStream = new GZipStream(originalFileStream, CompressionMode.Decompress);
517  gzipStream.CopyTo(streamToReturn);
518 
519  streamToReturn.Position = 0;
520  return streamToReturn;
521  }
522 
523  private static bool IsExtractionPathValid(string rootDir, string fileDir)
524  {
525  string getFullPath(string dir)
526  => (string.IsNullOrEmpty(dir)
527  ? Directory.GetCurrentDirectory()
528  : Path.GetFullPath(dir))
529  .CleanUpPathCrossPlatform(correctFilenameCase: false);
530 
531  string rootDirFull = getFullPath(rootDir);
532  string fileDirFull = getFullPath(fileDir);
533 
534  return fileDirFull.StartsWith(rootDirFull, StringComparison.OrdinalIgnoreCase);
535  }
536 
537  private static bool DecompressFile(System.IO.BinaryReader reader, [NotNullWhen(returnValue: true)]out string? fileName, [NotNullWhen(returnValue: true)]out byte[]? fileContent)
538  {
539  fileName = null;
540  fileContent = null;
541 
542  if (reader.PeekChar() < 0) { return false; }
543 
544  //Decompress file name
545  int nameLen = reader.ReadInt32();
546  if (nameLen > 255)
547  {
548  throw new Exception(
549  $"Failed to decompress (file name length > 255). The file may be corrupted.");
550  }
551 
552  byte[] strBytes = reader.ReadBytes(nameLen * sizeof(char));
553  string sFileName = Encoding.Unicode.GetString(strBytes)
554  .Replace('\\', '/');
555 
556  fileName = sFileName;
557 
558  //Decompress file content
559  int contentLen = reader.ReadInt32();
560  fileContent = reader.ReadBytes(contentLen);
561 
562  return true;
563  }
564 
565  public static void DecompressToDirectory(string sCompressedFile, string sDir)
566  {
567  DebugConsole.Log("Decompressing " + sCompressedFile + " to " + sDir + "...");
568  const int maxRetries = 4;
569  for (int i = 0; i <= maxRetries; i++)
570  {
571  try
572  {
573  using var memStream = DecompressFileToStream(sCompressedFile);
574  using var reader = new System.IO.BinaryReader(memStream);
575  while (DecompressFile(reader, out var fileName, out var contentBytes))
576  {
577  string sFilePath = Path.Combine(sDir, fileName);
578  string sFinalDir = Path.GetDirectoryName(sFilePath) ?? "";
579 
580  if (!IsExtractionPathValid(sDir, sFinalDir))
581  {
582  throw new InvalidOperationException(
583  $"Error extracting \"{fileName}\": cannot be extracted to parent directory");
584  }
585 
586  Directory.CreateDirectory(sFinalDir);
587  using var outFile = File.Open(sFilePath, System.IO.FileMode.Create, System.IO.FileAccess.Write)
588  ?? throw new Exception($"Failed to create file \"{sFilePath}\"");
589  outFile.Write(contentBytes, 0, contentBytes.Length);
590  }
591  break;
592  }
593  catch (System.IO.IOException e)
594  {
595  if (i >= maxRetries || !File.Exists(sCompressedFile)) { throw; }
596  DebugConsole.NewMessage("Failed decompress file \"" + sCompressedFile + "\" {" + e.Message + "}, retrying in 250 ms...", Color.Red);
597  Thread.Sleep(250);
598  }
599  }
600  }
601 
602  public static IEnumerable<string> EnumerateContainedFiles(string sCompressedFile)
603  {
604  const int maxRetries = 4;
605  HashSet<string> paths = new HashSet<string>();
606  for (int i = 0; i <= maxRetries; i++)
607  {
608  try
609  {
610  paths.Clear();
611  using var memStream = DecompressFileToStream(sCompressedFile);
612  using var reader = new System.IO.BinaryReader(memStream);
613  while (DecompressFile(reader, out var fileName, out _))
614  {
615  paths.Add(fileName);
616  }
617  break;
618  }
619  catch (System.IO.IOException e)
620  {
621  if (i >= maxRetries || !File.Exists(sCompressedFile)) { throw; }
622 
623  DebugConsole.NewMessage(
624  $"Failed to decompress file \"{sCompressedFile}\" for enumeration {{{e.Message}}}, retrying in 250 ms...",
625  Color.Red);
626  Thread.Sleep(250);
627  }
628  }
629 
630  return paths;
631  }
632 
639  public static XDocument? DecompressSaveAndLoadGameSessionDoc(string savePath)
640  {
641  DebugConsole.Log("Loading game session doc: " + savePath);
642  try
643  {
644  DecompressToDirectory(savePath, TempPath);
645  }
646  catch (Exception e)
647  {
648  DebugConsole.ThrowError("Error decompressing " + savePath, e);
649  return null;
650  }
651  return XMLExtensions.TryLoadXml(Path.Combine(TempPath, "gamesession.xml"));
652  }
653 
658  public static XElement? ExtractGameSessionRootElementFromSaveFile(string savePath, bool logLoadErrors = true)
659  {
660  const int maxRetries = 4;
661  for (int i = 0; i <= maxRetries; i++)
662  {
663  try
664  {
665  using var memStream = DecompressFileToStream(savePath);
666  using var reader = new System.IO.BinaryReader(memStream);
667  while (DecompressFile(reader, out var fileName, out var fileContent))
668  {
669  if (fileName != GameSessionFileName) { continue; }
670 
671  // Found the file! Here's a quick byte-wise parser to find the root element
672  int tagOpenerStartIndex = -1;
673  for (int j = 0; j < fileContent.Length; j++)
674  {
675  if (fileContent[j] == '<')
676  {
677  // Found a tag opener: return null if we had already found one
678  if (tagOpenerStartIndex >= 0) { return null; }
679  tagOpenerStartIndex = j;
680  }
681  else if (j > 0 && fileContent[j] == '?' && fileContent[j - 1] == '<')
682  {
683  // Found the XML version element, skip this
684  tagOpenerStartIndex = -1;
685  }
686  else if (fileContent[j] == '>')
687  {
688  // Found a tag closer, if we know where the tag opener is then we've found the root element
689  if (tagOpenerStartIndex < 0) { continue; }
690 
691  string elemStr = Encoding.UTF8.GetString(fileContent.AsSpan()[tagOpenerStartIndex..j]) + "/>";
692  try
693  {
694  return XElement.Parse(elemStr);
695  }
696  catch (Exception e)
697  {
698  DebugConsole.NewMessage(
699  $"Failed to parse gamesession root in \"{savePath}\": {{{e.Message}}}.",
700  Color.Red);
701  // Parsing the element failed! Return null instead of crashing here
702  return null;
703  }
704  }
705  }
706  }
707  break;
708  }
709  catch (System.IO.IOException e)
710  {
711  if (i >= maxRetries || !File.Exists(savePath)) { throw; }
712 
713  DebugConsole.NewMessage(
714  $"Failed to decompress file \"{savePath}\" for root extraction ({e.Message}), retrying in 250 ms...",
715  Color.Red);
716  Thread.Sleep(250);
717  }
718  catch (System.IO.InvalidDataException e)
719  {
720  if (logLoadErrors)
721  {
722  DebugConsole.ThrowError($"Failed to decompress file \"{savePath}\" for root extraction.", e);
723  }
724  return null;
725  }
726  }
727  return null;
728  }
729 
730  public static void DeleteDownloadedSubs()
731  {
732  if (Directory.Exists(SubmarineDownloadFolder))
733  {
734  ClearFolder(SubmarineDownloadFolder);
735  }
736  }
737 
738  public static void CleanUnnecessarySaveFiles()
739  {
740  if (Directory.Exists(CampaignDownloadFolder))
741  {
742  ClearFolder(CampaignDownloadFolder);
743  Directory.Delete(CampaignDownloadFolder);
744  }
745  if (Directory.Exists(TempPath))
746  {
747  ClearFolder(TempPath);
748  Directory.Delete(TempPath);
749  }
750  }
751 
752  public static void ClearFolder(string folderName, string[]? ignoredFileNames = null)
753  {
754  DirectoryInfo dir = new DirectoryInfo(folderName);
755 
756  foreach (FileInfo fi in dir.GetFiles())
757  {
758  if (ignoredFileNames != null)
759  {
760  bool ignore = false;
761  foreach (string ignoredFile in ignoredFileNames)
762  {
763  if (Path.GetFileName(fi.FullName).Equals(Path.GetFileName(ignoredFile)))
764  {
765  ignore = true;
766  break;
767  }
768  }
769  if (ignore) continue;
770  }
771  fi.IsReadOnly = false;
772  fi.Delete();
773  }
774 
775  foreach (DirectoryInfo di in dir.GetDirectories())
776  {
777  ClearFolder(di.FullName, ignoredFileNames);
778  const int maxRetries = 4;
779  for (int i = 0; i <= maxRetries; i++)
780  {
781  try
782  {
783  di.Delete();
784  break;
785  }
786  catch (System.IO.IOException)
787  {
788  if (i >= maxRetries) { throw; }
789  Thread.Sleep(250);
790  }
791  }
792  }
793  }
794 
795 #region Backup saves
796 
797  [NetworkSerialize]
798  public readonly record struct BackupIndexData(uint Index,
799  Identifier LocationNameIdentifier,
800  int LocationNameFormatIndex,
801  Identifier LocationType,
802  LevelData.LevelType LevelType,
803  SerializableDateTime SaveTime) : INetSerializableStruct;
804 
805  public static string FormatBackupExtension(uint index) => string.Format(BackupExtensionFormat, index);
806  public static string FormatBackupCharacterDataExtension(uint index) => string.Format(BackupCharacterDataFormat, index);
807 
808  public static void BackupSave(string savePath)
809  {
810  string path = Path.GetDirectoryName(savePath) ?? "";
811  string fileName = Path.GetFileNameWithoutExtension(savePath);
812  string characterDataSavePath = MultiPlayerCampaign.GetCharacterDataSavePath(savePath);
813  string characterDataFileName = Path.GetFileNameWithoutExtension(characterDataSavePath);
814 
815  ImmutableArray<BackupIndexData> indexData = GetIndexData(path, fileName);
816 
817  uint freeIndex = GetFreeIndex(indexData);
818 
819  string newBackupPath = Path.Combine(path, $".{fileName}{FormatBackupExtension(freeIndex)}");
820  string newCharacterDataBackupPath = Path.Combine(path, $".{characterDataFileName}{FormatBackupCharacterDataExtension(freeIndex)}");
821 
822  try
823  {
824  BackupFile(savePath, newBackupPath);
825  if (File.Exists(characterDataSavePath))
826  {
827  BackupFile(characterDataSavePath, newCharacterDataBackupPath);
828  }
829  }
830  catch (Exception e)
831  {
832  DebugConsole.ThrowError("Failed to create a backup of the save file.", e);
833  }
834 
835  static uint GetFreeIndex(IEnumerable<BackupIndexData> indexData)
836  {
837  if (!indexData.Any()) { return 0; }
838 
839  if (indexData.Count() < MaxBackupCount)
840  {
841  uint highestIndex = indexData.Max(static b => b.Index);
842  uint nextIndex = highestIndex + 1;
843 
844  if (indexData.Any(b => b.Index == nextIndex))
845  {
846  for (uint i = 0; i < MaxBackupCount; i++)
847  {
848  if (indexData.All(b => b.Index != i)) { return i; }
849  }
850 
851  // this should theoretically never happen
852  throw new InvalidOperationException("Failed to find a free index for the backup.");
853  }
854 
855  return nextIndex;
856  }
857 
858  BackupIndexData oldestBackup = indexData.OrderBy(static b => b.SaveTime).First();
859  return oldestBackup.Index;
860  }
861 
862  static void BackupFile(string sourcePath, string destPath)
863  {
864  // Overwriting a file that is marked as hidden will cause an exception.
865  DeleteIfExists(destPath);
866  System.IO.File.Copy(sourcePath, destPath, overwrite: true);
867  SetHidden(destPath);
868  }
869  }
870 
871  public static void DeleteIfExists(string filePath)
872  {
873  if (System.IO.File.Exists(filePath))
874  {
875  System.IO.File.Delete(filePath);
876  }
877  }
878 
879  public static ImmutableArray<BackupIndexData> GetIndexData(string fullPath)
880  {
881  string path = Path.GetDirectoryName(fullPath) ?? "";
882  string fileName = Path.GetFileNameWithoutExtension(fullPath);
883  return GetIndexData(path, fileName);
884  }
885 
886  private static readonly System.IO.EnumerationOptions BackupEnumerationOptions = new System.IO.EnumerationOptions
887  {
888  MatchType = System.IO.MatchType.Win32,
889  AttributesToSkip = System.IO.FileAttributes.System,
890  IgnoreInaccessible = true
891  };
892 
893  private static string[] GetBackupPaths(string path, string baseName)
894  {
895  try
896  {
897  return System.IO.Directory.GetFiles(path, $".{baseName}{FullBackupExtension}*", BackupEnumerationOptions);
898  }
899  catch (Exception e)
900  {
901  DebugConsole.ThrowError("Failed to get backup paths.", e);
902  }
903  return Array.Empty<string>();
904  }
905 
906  public static bool TryGetBackupIndexFromFileName(string filePath, out uint index)
907  {
908  string extension = Path.GetExtension(filePath);
909  if (extension.Length < BackupExtension.Length)
910  {
911  DebugConsole.ThrowError($"The file name \"{filePath}\" does not have a valid backup extension.");
912  index = 0;
913  return false;
914  }
915 
916  string indexStr = extension[BackupExtension.Length..];
917  bool result = uint.TryParse(indexStr, out index);
918  if (!result)
919  {
920  DebugConsole.ThrowError($"Failed to parse the backup index from the file name \"{filePath}\".");
921  }
922 
923  return result;
924  }
925 
926  private static ImmutableArray<BackupIndexData> GetIndexData(string path, string baseName)
927  {
928  var builder = ImmutableArray.CreateBuilder<BackupIndexData>();
929 
930  string[] foundBackups = GetBackupPaths(path, baseName);
931 
932  foreach (string backupPath in foundBackups)
933  {
934  if (!TryGetBackupIndexFromFileName(backupPath, out uint index)) { continue; }
935 
936  var gameSession = ExtractGameSessionRootElementFromSaveFile(backupPath, logLoadErrors: false);
937 
938  if (gameSession is null)
939  {
940  DebugConsole.AddWarning($"Failed to load gamesession root from \"{backupPath}\". Skipping this backup.");
941  continue;
942  }
943 
944  SerializableDateTime saveTime =
945  gameSession.GetAttributeDateTime("savetime")
946  .Fallback(SerializableDateTime.FromUtcUnixTime(0L));
947 
948  Identifier locationNameIdentifier = gameSession.GetAttributeIdentifier("currentlocation", Identifier.Empty);
949  int locationNameFormatIndex = gameSession.GetAttributeInt("currentlocationnameformatindex", -1);
950  Identifier locationType = gameSession.GetAttributeIdentifier("locationtype", Identifier.Empty);
951 
952  LevelData.LevelType levelType = gameSession.GetAttributeEnum("nextleveltype", LevelData.LevelType.LocationConnection);
953 
954  builder.Add(new BackupIndexData(index, locationNameIdentifier, locationNameFormatIndex, locationType, levelType, saveTime));
955  }
956 
957  return builder.ToImmutable();
958  }
959 
960  public static string GetBackupPath(string savePath, uint index)
961  {
962  string path = Path.GetDirectoryName(savePath) ?? "";
963  string fileName = Path.GetFileNameWithoutExtension(savePath);
964  return Path.Combine(path, $".{fileName}{FormatBackupExtension(index)}");
965  }
966 
967  private static void SetHidden(string filePath)
968  {
969  try
970  {
971  System.IO.File.SetAttributes(filePath, System.IO.File.GetAttributes(filePath) | System.IO.FileAttributes.Hidden);
972  }
973  catch (Exception e)
974  {
975  DebugConsole.ThrowError("Failed to set the backup file as hidden.", e);
976  }
977  }
978 #endregion
979  }
980 }
IEnumerable< FileInfo > GetFiles()
Definition: SafeIO.cs:768
IEnumerable< DirectoryInfo > GetDirectories()
Definition: SafeIO.cs:759
static bool IsBackupPath(string path, out uint foundIndex)
Definition: SaveUtil.cs:44
static CampaignDataPath CreateRegular(string savePath)
Creates a CampaignDataPath with the same load and save path.
static readonly CampaignDataPath Empty
Empty path used for non-campaign game sessions.
Definition: SaveUtil.cs:36
readonly string SavePath
Definition: SaveUtil.cs:20
CampaignDataPath(string loadPath, string savePath)
Definition: SaveUtil.cs:22
readonly string LoadPath
Definition: SaveUtil.cs:19