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