Client LuaCsForBarotrauma
ContentPackage.cs
1 #nullable enable
3 using Barotrauma.Steam;
4 using System;
5 using System.Collections.Generic;
6 using System.Collections.Immutable;
7 using System.Linq;
8 using System.Security.Cryptography;
9 using System.Text;
10 using System.Threading.Tasks;
11 using System.Xml.Linq;
12 using System.Xml;
13 using Barotrauma.IO;
14 
15 namespace Barotrauma
16 {
17  public abstract class ContentPackage
18  {
19  public readonly record struct LoadError(string Message, Exception? Exception)
20  {
21  public override string ToString()
22  => Message
23  + (Exception is { StackTrace: var stackTrace }
24  ? '\n' + stackTrace.CleanupStackTrace()
25  : string.Empty);
26  }
27 
28  public static readonly Version MinimumHashCompatibleVersion = new Version(1, 1, 0, 0);
29 
30  public const string LocalModsDir = "LocalMods";
31  public static readonly string WorkshopModsDir = Barotrauma.IO.Path.Combine(
32  SaveUtil.DefaultSaveFolder,
33  "WorkshopMods",
34  "Installed");
35 
36  public const string FileListFileName = "filelist.xml";
37  public const string DefaultModVersion = "1.0.0";
38 
39  public string Name { get; private set; }
40  public readonly ImmutableArray<string> AltNames;
41  public string Path { get; private set; }
42  public string Dir => Barotrauma.IO.Path.GetDirectoryName(Path) ?? "";
43  public readonly Option<ContentPackageId> UgcId;
44 
45  public readonly Version GameVersion;
46  public readonly string ModVersion;
47  public Md5Hash Hash { get; private set; }
48  public readonly Option<SerializableDateTime> InstallTime;
49 
50  public ImmutableArray<ContentFile> Files { get; private set; }
51 
57  public ImmutableArray<LoadError> FatalLoadErrors { get; private set; }
58 
66  public Option<ContentPackageManager.LoadProgress.Error> EnableError { get; private set; }
67  = Option.None;
68 
69  public bool HasAnyErrors => FatalLoadErrors.Length > 0 || EnableError.IsSome();
70 
71  public async Task<bool> IsUpToDate()
72  {
73  if (!UgcId.TryUnwrap(out var ugcId)) { return true; }
74  if (ugcId is not SteamWorkshopId steamWorkshopId) { return true; }
75  if (!InstallTime.TryUnwrap(out var installTime)) { return true; }
76 
77  Option<Steamworks.Ugc.Item> itemOption = await SteamManager.Workshop.GetItem(steamWorkshopId.Value);
78  if (!itemOption.TryUnwrap(out var item)) { return true; }
79  return item.LatestUpdateTime <= installTime.ToUtcValue();
80  }
81 
82  public int Index => ContentPackageManager.EnabledPackages.IndexOf(this);
83 
87  public bool HasMultiplayerSyncedContent { get; }
88 
89  protected ContentPackage(XDocument doc, string path)
90  {
91  using var errorCatcher = DebugConsole.ErrorCatcher.Create();
92 
93  Path = path.CleanUpPathCrossPlatform();
94  XElement rootElement = doc.Root ?? throw new NullReferenceException("XML document is invalid: root element is null.");
95 
96  Name = rootElement.GetAttributeString("name", "").Trim();
97  AltNames = rootElement.GetAttributeStringArray("altnames", Array.Empty<string>())
98  .Select(n => n.Trim()).ToImmutableArray();
99  UInt64 steamWorkshopId = rootElement.GetAttributeUInt64("steamworkshopid", 0);
100 
101  if (Name.IsNullOrWhiteSpace() && AltNames.Any())
102  {
103  Name = AltNames.First();
104  }
105 
106  UgcId = steamWorkshopId != 0
107  ? Option<ContentPackageId>.Some(new SteamWorkshopId(steamWorkshopId))
108  : Option<ContentPackageId>.None();
109 
110  GameVersion = rootElement.GetAttributeVersion("gameversion", GameMain.Version);
111  ModVersion = rootElement.GetAttributeString("modversion", DefaultModVersion);
112  InstallTime = rootElement.GetAttributeDateTime("installtime");
113 
114  var fileResults = rootElement.Elements()
115  .Where(e => !ContentFile.IsLegacyContentType(e, this, logWarning: true))
116  .Select(e => ContentFile.CreateFromXElement(this, e))
117  .ToArray();
118 
119  Files = fileResults
120  .Successes()
121  .ToImmutableArray();
122 
123  FatalLoadErrors = fileResults
124  .Failures()
125  .ToImmutableArray();
126 
127  AssertCondition(!string.IsNullOrEmpty(Name), $"{nameof(Name)} is null or empty");
128 
129  HasMultiplayerSyncedContent = Files.Any(f => !f.NotSyncedInMultiplayer);
130 
131  Hash = CalculateHash();
132  var expectedHash = rootElement.GetAttributeString("expectedhash", "");
133  if (HashMismatches(expectedHash))
134  {
136  new LoadError(
137  Message: $"Hash calculation returned {Hash.StringRepresentation}, expected {expectedHash}",
138  Exception: null
139  ));
140  }
141 
143  .Concat(errorCatcher.Errors.Select(err => new LoadError(err.Text, null)))
144  .ToImmutableArray();
145  }
146 
147  public bool HashMismatches(string expectedHash)
149  !expectedHash.IsNullOrWhiteSpace() &&
150  !expectedHash.Equals(Hash.StringRepresentation, StringComparison.OrdinalIgnoreCase);
151 
152  public IEnumerable<T> GetFiles<T>() where T : ContentFile => Files.OfType<T>();
153 
154  public IEnumerable<ContentFile> GetFiles(Type type)
155  => !type.IsSubclassOf(typeof(ContentFile))
156  ? throw new ArgumentException($"Type must be subclass of ContentFile, got {type.Name}")
157  : Files.Where(f => f.GetType() == type || f.GetType().IsSubclassOf(type));
158 
159  public bool NameMatches(Identifier name)
160  => Name == name || AltNames.Any(n => n == name);
161 
162  public bool NameMatches(string name)
163  => NameMatches(name.ToIdentifier());
164 
165  public static Result<ContentPackage, Exception> TryLoad(string path)
166  {
167  var (success, failure) = Result<ContentPackage, Exception>.GetFactoryMethods();
168 
169  XDocument doc = XMLExtensions.TryLoadXml(path);
170 
171  try
172  {
173  return success(doc.Root.GetAttributeBool("corepackage", false)
174  ? new CorePackage(doc, path)
175  : new RegularPackage(doc, path));
176  }
177  catch (Exception e)
178  {
179  return failure(e.GetInnermost());
180  }
181  }
182 
183  public Md5Hash CalculateHash(bool logging = false, string? name = null, string? modVersion = null)
184  {
185  using IncrementalHash incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.MD5);
186 
187  if (logging)
188  {
189  DebugConsole.NewMessage("****************************** Calculating content package hash " + Name);
190  }
191 
192  foreach (ContentFile file in Files)
193  {
194  try
195  {
196  var hash = file.Hash;
197  if (logging)
198  {
199  DebugConsole.NewMessage(" " + file.Path + ": " + hash.StringRepresentation);
200  }
201  incrementalHash.AppendData(hash.ByteRepresentation);
202  }
203 
204  catch (Exception e)
205  {
206  DebugConsole.ThrowError($"Error while calculating the MD5 hash of the content package \"{Name}\" (file path: {Path}). The content package may be corrupted. You may want to delete or reinstall the package.", e);
207  break;
208  }
209  }
210 
211  string selectedName = name ?? Name;
212  if (!selectedName.IsNullOrEmpty())
213  {
214  incrementalHash.AppendData(Encoding.UTF8.GetBytes(selectedName));
215  }
216  incrementalHash.AppendData(Encoding.UTF8.GetBytes(modVersion ?? ModVersion));
217 
218  var md5Hash = Md5Hash.BytesAsHash(incrementalHash.GetHashAndReset());
219  if (logging)
220  {
221  DebugConsole.NewMessage("****************************** Package hash: " + md5Hash.StringRepresentation);
222  }
223 
224  return md5Hash;
225  }
226 
227  protected void AssertCondition(bool condition, string errorMsg)
228  {
229  if (!condition)
230  {
231  FatalLoadErrors = FatalLoadErrors.Add(new LoadError(errorMsg, null));
232  }
233  }
234 
235  public void LoadFilesOfType<T>() where T : ContentFile
236  {
237  Files.Where(f => f is T).ForEach(f => f.LoadFile());
238  }
239 
240  public void UnloadFilesOfType<T>() where T : ContentFile
241  {
242  Files.Where(f => f is T).ForEach(f => f.UnloadFile());
243  }
244 
245  public enum LoadResult
246  {
247  Success,
248  Failure
249  }
250 
252  {
253  foreach (var p in LoadContentEnumerable())
254  {
255  if (p.Result.IsFailure) { return LoadResult.Failure; }
256  }
257  return LoadResult.Success;
258  }
259 
260  public IEnumerable<ContentPackageManager.LoadProgress> LoadContentEnumerable()
261  {
262  using var errorCatcher = DebugConsole.ErrorCatcher.Create();
263 
264  ContentFile[] getFilesToLoad(Predicate<ContentFile> predicate)
265  => Files.Where(predicate.Invoke).ToArray()
266 #if DEBUG
267  //The game should be able to work just fine with a completely arbitrary file load order.
268  //To make sure we don't mess this up, debug builds randomize it so it has a higher chance
269  //of breaking anything that's not implemented correctly.
270  .Randomize(Rand.RandSync.Unsynced)
271 #endif
272  ;
273 
274  IEnumerable<ContentPackageManager.LoadProgress> loadFiles(ContentFile[] filesToLoad, int indexOffset)
275  {
276  for (int i = 0; i < filesToLoad.Length; i++)
277  {
278  Exception? exception = null;
279 
280  try
281  {
282  //do not allow exceptions thrown here to crash the game
283  filesToLoad[i].LoadFile();
284  }
285  catch (Exception e)
286  {
287  var innermost = e.GetInnermost();
288  DebugConsole.LogError($"Failed to load \"{filesToLoad[i].Path}\": {innermost.Message}\n{innermost.StackTrace}", contentPackage: this);
289  exception = e;
290  }
291  if (exception != null)
292  {
293  yield return ContentPackageManager.LoadProgress.Failure(exception);
294  yield break;
295  }
296 
297  if (errorCatcher.Errors.Any())
298  {
299  yield return ContentPackageManager.LoadProgress.Failure(errorCatcher.Errors.Select(e => e.Text));
300  yield break;
301  }
302  yield return ContentPackageManager.LoadProgress.Progress((i + indexOffset) / (float)Files.Length);
303  }
304  }
305 
306  //Load the UI and text files first. This is to allow the game
307  //to render the text in the loading screen as soon as possible.
308  var priorityFiles = getFilesToLoad(f => f is UIStyleFile or TextFile);
309 
310  var remainder = getFilesToLoad(f => !priorityFiles.Contains(f));
311 
312  var loadEnumerable =
313  loadFiles(priorityFiles, 0)
314  .Concat(loadFiles(remainder, priorityFiles.Length));
315 
316  foreach (var p in loadEnumerable)
317  {
318  if (p.Result.TryUnwrapFailure(out var failure))
319  {
320  errorCatcher.Dispose();
321  UnloadContent();
322  EnableError = Option.Some(failure);
323  yield return p;
324  yield break;
325  }
326  yield return p;
327  }
328  errorCatcher.Dispose();
329  }
330 
331  public void UnloadContent()
332  {
333  Files.ForEach(f => f.UnloadFile());
334  }
335 
337  {
338  XDocument doc = XMLExtensions.TryLoadXml(Path);
339  List<ContentFile> newFileList = new List<ContentFile>();
340  XElement rootElement = doc.Root ?? throw new NullReferenceException("XML document is invalid: root element is null.");
341 
342  var fileResults = rootElement.Elements()
343  .Where(e => !ContentFile.IsLegacyContentType(e, this, logWarning: true))
344  .Select(e => ContentFile.CreateFromXElement(this, e))
345  .ToArray();
346 
347  foreach (var file in fileResults.Successes())
348  {
349  if (file is BaseSubFile or ItemAssemblyFile)
350  {
351  newFileList.Add(file);
352  }
353  else
354  {
355  var existingFile = Files.FirstOrDefault(f => f.Path == file.Path);
356  newFileList.Add(existingFile ?? file);
357  }
358  }
359 
360  UnloadFilesOfType<BaseSubFile>();
361  UnloadFilesOfType<ItemAssemblyFile>();
362  Files = newFileList.ToImmutableArray();
363  Hash = CalculateHash();
364  LoadFilesOfType<BaseSubFile>();
365  LoadFilesOfType<ItemAssemblyFile>();
366  }
367 
368  public static bool PathAllowedAsLocalModFile(string path)
369  {
370 #if DEBUG
371  if (GameMain.VanillaContent.Files.Any(f => f.Path == path))
372  {
373  //file is in vanilla package, this is allowed
374  return true;
375  }
376 #endif
377 
378  while (true)
379  {
380  string temp = Barotrauma.IO.Path.GetDirectoryName(path) ?? "";
381  if (string.IsNullOrEmpty(temp)) { break; }
382  path = temp;
383  }
384  return path == LocalModsDir;
385  }
386 
387  public void LogErrors()
388  {
389  if (!FatalLoadErrors.Any())
390  {
391  return;
392  }
393 
394  DebugConsole.AddWarning(
395  $"The following errors occurred while loading the content package \"{Name}\". The package might not work correctly.\n" +
396  string.Join('\n', FatalLoadErrors.Select(errorToStr)),
397  this);
398 
399  static string errorToStr(LoadError error)
400  => error.ToString();
401  }
402 
403  public bool TryRenameLocal(string newName)
404  {
405  if (!ContentPackageManager.LocalPackages.Contains(this)) { return false; }
406 
407  if (newName.IsNullOrWhiteSpace())
408  {
409  DebugConsole.ThrowError($"New name is blank!");
410  return false;
411  }
412 
413  string newDir = IO.Path.Combine(IO.Path.GetFullPath(LocalModsDir), File.SanitizeName(newName));
414  if (ContentPackageManager.LocalPackages.Any(lp => lp.NameMatches(newName)) || Directory.Exists(newDir))
415  {
416  DebugConsole.ThrowError($"A local package with the name or directory \"{newName}\" already exists!");
417  return false;
418  }
419 
420  XDocument doc = XMLExtensions.TryLoadXml(Path);
421  doc.Root!.SetAttributeValue("name", newName);
422  using (IO.XmlWriter writer = IO.XmlWriter.Create(Path, new XmlWriterSettings { Indent = true }))
423  {
424  doc.WriteTo(writer);
425  writer.Flush();
426  }
427 
428  Directory.Move(Dir, newDir);
429  return true;
430  }
431 
432  public bool TryDeleteLocal() => ContentPackageManager.LocalPackages.Contains(this) && Directory.TryDelete(Dir);
433 
435  {
436  if (!ContentPackageManager.WorkshopPackages.Contains(this)) { return false; }
437 
438  string newDir = IO.Path.Combine(IO.Path.GetFullPath(LocalModsDir), File.SanitizeName(Name));
439  if (ContentPackageManager.LocalPackages.Any(lp => lp.NameMatches(Name)) || Directory.Exists(newDir))
440  {
441  DebugConsole.ThrowError($"A local package with the name or directory \"{Name}\" already exists!");
442  return false;
443  }
444 
445  Directory.Copy(Dir, newDir);
446  return true;
447  }
448  }
449 }
Base class for content file types, which are loaded from filelist.xml via reflection....
Definition: ContentFile.cs:23
static Result< ContentFile, ContentPackage.LoadError > CreateFromXElement(ContentPackage contentPackage, XElement element)
Definition: ContentFile.cs:87
static bool IsLegacyContentType(XElement contentFileElement, ContentPackage package, bool logWarning)
Definition: ContentFile.cs:70
readonly ContentPath Path
Definition: ContentFile.cs:137
readonly Md5Hash Hash
Definition: ContentFile.cs:138
abstract void LoadFile()
bool TryRenameLocal(string newName)
readonly record struct LoadError(string Message, Exception? Exception)
ImmutableArray< LoadError > FatalLoadErrors
Errors that occurred when loading this content package. Currently, all errors are considered fatal an...
ImmutableArray< ContentFile > Files
IEnumerable< ContentPackageManager.LoadProgress > LoadContentEnumerable()
static bool PathAllowedAsLocalModFile(string path)
static readonly Version MinimumHashCompatibleVersion
Md5Hash CalculateHash(bool logging=false, string? name=null, string? modVersion=null)
readonly Version GameVersion
const string DefaultModVersion
Option< ContentPackageManager.LoadProgress.Error > EnableError
An error that occurred when trying to enable this mod. This field doesn't directly affect whether or ...
ContentPackage(XDocument doc, string path)
readonly ImmutableArray< string > AltNames
void AssertCondition(bool condition, string errorMsg)
readonly Option< ContentPackageId > UgcId
bool HashMismatches(string expectedHash)
readonly Option< SerializableDateTime > InstallTime
static readonly string WorkshopModsDir
readonly string ModVersion
async Task< bool > IsUpToDate()
bool HasMultiplayerSyncedContent
Does the content package include some content that needs to match between all players in multiplayer.
IEnumerable< ContentFile > GetFiles(Type type)
IEnumerable< T > GetFiles< T >()
static ContentPackage VanillaContent
Definition: GameMain.cs:84
static readonly Version Version
Definition: GameMain.cs:46
readonly string StringRepresentation
Definition: Md5Hash.cs:32