5 using System.Collections.Generic;
6 using System.Collections.Immutable;
8 using System.Security.Cryptography;
10 using System.Threading.Tasks;
11 using System.Xml.Linq;
19 public readonly record
struct LoadError(string Message, Exception? Exception)
21 public override string ToString()
23 + (Exception is { StackTrace: var stackTrace }
24 ?
'\n' + stackTrace.CleanupStackTrace()
32 SaveUtil.DefaultSaveFolder,
39 public string Name {
get;
private set; }
40 public readonly ImmutableArray<string>
AltNames;
41 public string Path {
get;
private set; }
43 public readonly Option<ContentPackageId>
UgcId;
50 public ImmutableArray<ContentFile>
Files {
get;
private set; }
66 public Option<ContentPackageManager.LoadProgress.Error>
EnableError {
get;
private set; }
73 if (!
UgcId.TryUnwrap(out var ugcId)) {
return true; }
75 if (!
InstallTime.TryUnwrap(out var installTime)) {
return true; }
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();
82 public int Index => ContentPackageManager.EnabledPackages.IndexOf(
this);
91 using var errorCatcher = DebugConsole.ErrorCatcher.Create();
93 Path = path.CleanUpPathCrossPlatform();
94 XElement rootElement = doc.Root ??
throw new NullReferenceException(
"XML document is invalid: root element is null.");
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);
106 UgcId = steamWorkshopId != 0
108 : Option<ContentPackageId>.None();
112 InstallTime = rootElement.GetAttributeDateTime(
"installtime");
114 var fileResults = rootElement.Elements()
132 var expectedHash = rootElement.GetAttributeString(
"expectedhash",
"");
137 Message: $
"Hash calculation returned {Hash.StringRepresentation}, expected {expectedHash}",
143 .Concat(errorCatcher.Errors.Select(err =>
new LoadError(err.Text,
null)))
149 !expectedHash.IsNullOrWhiteSpace() &&
156 ? throw new ArgumentException($"Type must be subclass of
ContentFile, got {type.Name}
")
157 : Files.Where(f => f.GetType() == type || f.GetType().IsSubclassOf(type));
159 public bool NameMatches(Identifier name)
160 => Name == name || AltNames.Any(n => n == name);
162 public bool NameMatches(string name)
163 => NameMatches(name.ToIdentifier());
165 public static Result<ContentPackage, Exception> TryLoad(string path)
167 var (success, failure) = Result<ContentPackage, Exception>.GetFactoryMethods();
169 XDocument doc = XMLExtensions.TryLoadXml(path);
173 return success(doc.Root.GetAttributeBool("corepackage
", false)
174 ? new CorePackage(doc, path)
175 : new RegularPackage(doc, path));
179 return failure(e.GetInnermost());
183 public Md5Hash CalculateHash(bool logging = false, string? name = null, string? modVersion = null)
185 using IncrementalHash incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.MD5);
189 DebugConsole.NewMessage("****************************** Calculating content
package hash " + Name);
196 var hash = file.
Hash;
199 DebugConsole.NewMessage(
" " + file.
Path +
": " + hash.StringRepresentation);
201 incrementalHash.AppendData(hash.ByteRepresentation);
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);
211 string selectedName = name ??
Name;
212 if (!selectedName.IsNullOrEmpty())
214 incrementalHash.AppendData(Encoding.UTF8.GetBytes(selectedName));
216 incrementalHash.AppendData(Encoding.UTF8.GetBytes(modVersion ??
ModVersion));
218 var md5Hash = Md5Hash.BytesAsHash(incrementalHash.GetHashAndReset());
221 DebugConsole.NewMessage(
"****************************** Package hash: " + md5Hash.StringRepresentation);
231 FatalLoadErrors = FatalLoadErrors.Add(
new LoadError(errorMsg,
null));
237 Files.Where(f => f is T).ForEach(f => f.LoadFile());
242 Files.Where(f => f is T).ForEach(f => f.UnloadFile());
253 foreach (var p
in LoadContentEnumerable())
255 if (p.Result.IsFailure) {
return LoadResult.Failure; }
262 using var errorCatcher = DebugConsole.ErrorCatcher.Create();
264 ContentFile[] getFilesToLoad(Predicate<ContentFile> predicate)
265 => Files.Where(predicate.Invoke).ToArray()
270 .Randomize(Rand.RandSync.Unsynced)
274 IEnumerable<ContentPackageManager.LoadProgress> loadFiles(
ContentFile[] filesToLoad,
int indexOffset)
276 for (
int i = 0; i < filesToLoad.Length; i++)
278 Exception? exception =
null;
287 var innermost = e.GetInnermost();
288 DebugConsole.LogError($
"Failed to load \"{filesToLoad[i].Path}\": {innermost.Message}\n{innermost.StackTrace}", contentPackage:
this);
291 if (exception !=
null)
293 yield
return ContentPackageManager.LoadProgress.Failure(exception);
297 if (errorCatcher.Errors.Any())
299 yield
return ContentPackageManager.LoadProgress.Failure(errorCatcher.Errors.Select(e => e.Text));
302 yield
return ContentPackageManager.LoadProgress.Progress((i + indexOffset) / (
float)Files.Length);
310 var remainder = getFilesToLoad(f => !priorityFiles.Contains(f));
313 loadFiles(priorityFiles, 0)
314 .Concat(loadFiles(remainder, priorityFiles.Length));
316 foreach (var p
in loadEnumerable)
318 if (p.Result.TryUnwrapFailure(out var failure))
320 errorCatcher.Dispose();
322 EnableError = Option.Some(failure);
328 errorCatcher.Dispose();
333 Files.ForEach(f => f.UnloadFile());
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.");
342 var fileResults = rootElement.Elements()
347 foreach (var file
in fileResults.Successes())
351 newFileList.Add(file);
355 var existingFile = Files.FirstOrDefault(f => f.Path == file.Path);
356 newFileList.Add(existingFile ?? file);
360 UnloadFilesOfType<BaseSubFile>();
361 UnloadFilesOfType<ItemAssemblyFile>();
362 Files = newFileList.ToImmutableArray();
363 Hash = CalculateHash();
364 LoadFilesOfType<BaseSubFile>();
365 LoadFilesOfType<ItemAssemblyFile>();
381 if (
string.IsNullOrEmpty(temp)) {
break; }
384 return path == LocalModsDir;
389 if (!FatalLoadErrors.Any())
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)),
399 static string errorToStr(LoadError error)
405 if (!ContentPackageManager.LocalPackages.Contains(
this)) {
return false; }
407 if (newName.IsNullOrWhiteSpace())
409 DebugConsole.ThrowError($
"New name is blank!");
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))
416 DebugConsole.ThrowError($
"A local package with the name or directory \"{newName}\" already exists!");
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 }))
428 Directory.Move(Dir, newDir);
432 public bool TryDeleteLocal() => ContentPackageManager.LocalPackages.Contains(
this) && Directory.TryDelete(Dir);
436 if (!ContentPackageManager.WorkshopPackages.Contains(
this)) {
return false; }
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))
441 DebugConsole.ThrowError($
"A local package with the name or directory \"{Name}\" already exists!");
445 Directory.Copy(Dir, newDir);
Base class for content file types, which are loaded from filelist.xml via reflection....
static Result< ContentFile, ContentPackage.LoadError > CreateFromXElement(ContentPackage contentPackage, XElement element)
static bool IsLegacyContentType(XElement contentFileElement, ContentPackage package, bool logWarning)
readonly ContentPath Path
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
const string FileListFileName
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
void ReloadSubsAndItemAssemblies()
bool HashMismatches(string expectedHash)
readonly Option< SerializableDateTime > InstallTime
static readonly string WorkshopModsDir
readonly string ModVersion
const string LocalModsDir
async Task< bool > IsUpToDate()
bool HasMultiplayerSyncedContent
Does the content package include some content that needs to match between all players in multiplayer.
bool TryCreateLocalFromWorkshop()
IEnumerable< ContentFile > GetFiles(Type type)
IEnumerable< T > GetFiles< T >()
static ContentPackage VanillaContent
static readonly Version Version
readonly string StringRepresentation