#nullable enable using Barotrauma.Extensions; using Barotrauma.Steam; using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using System.Xml.Linq; namespace Barotrauma { public abstract class ContentPackage { public readonly record struct LoadError(string Message, Exception? Exception) { public override string ToString() => Message + (Exception is { StackTrace: var stackTrace } ? '\n' + stackTrace.CleanupStackTrace() : string.Empty); } public static readonly Version MinimumHashCompatibleVersion = new Version(1, 1, 0, 0); public const string LocalModsDir = "LocalMods"; public static readonly string WorkshopModsDir = Barotrauma.IO.Path.Combine( SaveUtil.DefaultSaveFolder, "WorkshopMods", "Installed"); public const string FileListFileName = "filelist.xml"; public const string DefaultModVersion = "1.0.0"; public readonly string Name; public readonly ImmutableArray AltNames; public readonly string Path; public string Dir => Barotrauma.IO.Path.GetDirectoryName(Path) ?? ""; public readonly Option UgcId; public readonly Version GameVersion; public readonly string ModVersion; public Md5Hash Hash { get; private set; } public readonly Option InstallTime; public ImmutableArray Files { get; private set; } /// /// Errors that occurred when loading this content package. /// Currently, all errors are considered fatal and the game /// will refuse to load a content package that has any errors. /// public ImmutableArray FatalLoadErrors { get; private set; } /// /// An error that occurred when trying to enable this mod. /// This field doesn't directly affect whether or not this mod /// can be enabled, but if it's been set to anything other than /// Option.None then the game has already refused to enable it /// at least once. /// public Option EnableError { get; private set; } = Option.None; public bool HasAnyErrors => FatalLoadErrors.Length > 0 || EnableError.IsSome(); public async Task IsUpToDate() { if (!UgcId.TryUnwrap(out var ugcId)) { return true; } if (ugcId is not SteamWorkshopId steamWorkshopId) { return true; } if (!InstallTime.TryUnwrap(out var installTime)) { return true; } Option itemOption = await SteamManager.Workshop.GetItem(steamWorkshopId.Value); if (!itemOption.TryUnwrap(out var item)) { return true; } return item.LatestUpdateTime <= installTime.ToUtcValue(); } public int Index => ContentPackageManager.EnabledPackages.IndexOf(this); /// /// Does the content package include some content that needs to match between all players in multiplayer. /// public bool HasMultiplayerSyncedContent { get; } protected ContentPackage(XDocument doc, string path) { using var errorCatcher = DebugConsole.ErrorCatcher.Create(); Path = path.CleanUpPathCrossPlatform(); XElement rootElement = doc.Root ?? throw new NullReferenceException("XML document is invalid: root element is null."); Name = rootElement.GetAttributeString("name", "").Trim(); AltNames = rootElement.GetAttributeStringArray("altnames", Array.Empty()) .Select(n => n.Trim()).ToImmutableArray(); UInt64 steamWorkshopId = rootElement.GetAttributeUInt64("steamworkshopid", 0); if (Name.IsNullOrWhiteSpace() && AltNames.Any()) { Name = AltNames.First(); } UgcId = steamWorkshopId != 0 ? Option.Some(new SteamWorkshopId(steamWorkshopId)) : Option.None(); GameVersion = rootElement.GetAttributeVersion("gameversion", GameMain.Version); ModVersion = rootElement.GetAttributeString("modversion", DefaultModVersion); InstallTime = rootElement.GetAttributeDateTime("installtime"); var fileResults = rootElement.Elements() .Where(e => !ContentFile.IsLegacyContentType(e, this, logWarning: true)) .Select(e => ContentFile.CreateFromXElement(this, e)) .ToArray(); Files = fileResults .Successes() .ToImmutableArray(); FatalLoadErrors = fileResults .Failures() .ToImmutableArray(); AssertCondition(!string.IsNullOrEmpty(Name), $"{nameof(Name)} is null or empty"); HasMultiplayerSyncedContent = Files.Any(f => !f.NotSyncedInMultiplayer); Hash = CalculateHash(); var expectedHash = rootElement.GetAttributeString("expectedhash", ""); if (HashMismatches(expectedHash)) { FatalLoadErrors = FatalLoadErrors.Add( new LoadError( Message: $"Hash calculation returned {Hash.StringRepresentation}, expected {expectedHash}", Exception: null )); } FatalLoadErrors = FatalLoadErrors .Concat(errorCatcher.Errors.Select(err => new LoadError(err.Text, null))) .ToImmutableArray(); } public bool HashMismatches(string expectedHash) => GameVersion >= MinimumHashCompatibleVersion && !expectedHash.IsNullOrWhiteSpace() && !expectedHash.Equals(Hash.StringRepresentation, StringComparison.OrdinalIgnoreCase); public IEnumerable GetFiles() where T : ContentFile => Files.OfType(); public IEnumerable GetFiles(Type type) => !type.IsSubclassOf(typeof(ContentFile)) ? throw new ArgumentException($"Type must be subclass of ContentFile, got {type.Name}") : Files.Where(f => f.GetType() == type || f.GetType().IsSubclassOf(type)); public bool NameMatches(Identifier name) => Name == name || AltNames.Any(n => n == name); public bool NameMatches(string name) => NameMatches(name.ToIdentifier()); public static Result TryLoad(string path) { var (success, failure) = Result.GetFactoryMethods(); XDocument doc = XMLExtensions.TryLoadXml(path); try { return success(doc.Root.GetAttributeBool("corepackage", false) ? new CorePackage(doc, path) : new RegularPackage(doc, path)); } catch (Exception e) { return failure(e.GetInnermost()); } } public Md5Hash CalculateHash(bool logging = false, string? name = null, string? modVersion = null) { using IncrementalHash incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.MD5); if (logging) { DebugConsole.NewMessage("****************************** Calculating content package hash " + Name); } foreach (ContentFile file in Files) { try { var hash = file.Hash; if (logging) { DebugConsole.NewMessage(" " + file.Path + ": " + hash.StringRepresentation); } incrementalHash.AppendData(hash.ByteRepresentation); } catch (Exception e) { 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); break; } } string selectedName = name ?? Name; if (!selectedName.IsNullOrEmpty()) { incrementalHash.AppendData(Encoding.UTF8.GetBytes(selectedName)); } incrementalHash.AppendData(Encoding.UTF8.GetBytes(modVersion ?? ModVersion)); var md5Hash = Md5Hash.BytesAsHash(incrementalHash.GetHashAndReset()); if (logging) { DebugConsole.NewMessage("****************************** Package hash: " + md5Hash.StringRepresentation); } return md5Hash; } protected void AssertCondition(bool condition, string errorMsg) { if (!condition) { FatalLoadErrors = FatalLoadErrors.Add(new LoadError(errorMsg, null)); } } public void LoadFilesOfType() where T : ContentFile { Files.Where(f => f is T).ForEach(f => f.LoadFile()); } public void UnloadFilesOfType() where T : ContentFile { Files.Where(f => f is T).ForEach(f => f.UnloadFile()); } public enum LoadResult { Success, Failure } public LoadResult LoadContent() { foreach (var p in LoadContentEnumerable()) { if (p.Result.IsFailure) { return LoadResult.Failure; } } return LoadResult.Success; } public IEnumerable LoadContentEnumerable() { using var errorCatcher = DebugConsole.ErrorCatcher.Create(); ContentFile[] getFilesToLoad(Predicate predicate) => Files.Where(predicate.Invoke).ToArray() #if DEBUG //The game should be able to work just fine with a completely arbitrary file load order. //To make sure we don't mess this up, debug builds randomize it so it has a higher chance //of breaking anything that's not implemented correctly. .Randomize() #endif ; IEnumerable loadFiles(ContentFile[] filesToLoad, int indexOffset) { for (int i = 0; i < filesToLoad.Length; i++) { Exception? exception = null; try { //do not allow exceptions thrown here to crash the game filesToLoad[i].LoadFile(); } catch (Exception e) { var innermost = e.GetInnermost(); DebugConsole.LogError($"Failed to load \"{filesToLoad[i].Path}\": {innermost.Message}\n{innermost.StackTrace}", contentPackage: this); exception = e; } if (exception != null) { yield return ContentPackageManager.LoadProgress.Failure(exception); yield break; } if (errorCatcher.Errors.Any()) { yield return ContentPackageManager.LoadProgress.Failure(errorCatcher.Errors.Select(e => e.Text)); yield break; } yield return ContentPackageManager.LoadProgress.Progress((i + indexOffset) / (float)Files.Length); } } //Load the UI and text files first. This is to allow the game //to render the text in the loading screen as soon as possible. var priorityFiles = getFilesToLoad(f => f is UIStyleFile or TextFile); var remainder = getFilesToLoad(f => !priorityFiles.Contains(f)); var loadEnumerable = loadFiles(priorityFiles, 0) .Concat(loadFiles(remainder, priorityFiles.Length)); foreach (var p in loadEnumerable) { if (p.Result.TryUnwrapFailure(out var failure)) { errorCatcher.Dispose(); UnloadContent(); EnableError = Option.Some(failure); yield return p; yield break; } yield return p; } errorCatcher.Dispose(); } public void UnloadContent() { Files.ForEach(f => f.UnloadFile()); } public void ReloadSubsAndItemAssemblies() { XDocument doc = XMLExtensions.TryLoadXml(Path); List newFileList = new List(); XElement rootElement = doc.Root ?? throw new NullReferenceException("XML document is invalid: root element is null."); var fileResults = rootElement.Elements() .Where(e => !ContentFile.IsLegacyContentType(e, this, logWarning: true)) .Select(e => ContentFile.CreateFromXElement(this, e)) .ToArray(); foreach (var file in fileResults.Successes()) { if (file is BaseSubFile or ItemAssemblyFile) { newFileList.Add(file); } else { var existingFile = Files.FirstOrDefault(f => f.Path == file.Path); newFileList.Add(existingFile ?? file); } } UnloadFilesOfType(); UnloadFilesOfType(); Files = newFileList.ToImmutableArray(); Hash = CalculateHash(); LoadFilesOfType(); LoadFilesOfType(); } public static bool PathAllowedAsLocalModFile(string path) { #if DEBUG if (GameMain.VanillaContent.Files.Any(f => f.Path == path)) { //file is in vanilla package, this is allowed return true; } #endif while (true) { string temp = Barotrauma.IO.Path.GetDirectoryName(path) ?? ""; if (string.IsNullOrEmpty(temp)) { break; } path = temp; } return path == LocalModsDir; } public void LogErrors() { if (!FatalLoadErrors.Any()) { return; } DebugConsole.AddWarning( $"The following errors occurred while loading the content package \"{Name}\". The package might not work correctly.\n" + string.Join('\n', FatalLoadErrors.Select(errorToStr)), this); static string errorToStr(LoadError error) => error.ToString(); } } }