using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Linq; using System.Reactive.Subjects; using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; using Org.BouncyCastle.Asn1.Cms; using Wabbajack.Common; using Wabbajack.Lib.CompilationSteps; using Wabbajack.Lib.Downloaders; using Wabbajack.Lib.FileUploader; using Wabbajack.Lib.GitHub; using Wabbajack.Lib.ModListRegistry; using Wabbajack.VirtualFileSystem; namespace Wabbajack.Lib { public abstract class ACompiler : ABatchProcessor { protected readonly Subject<(string, float)> _progressUpdates = new(); public List IndexedArchives = new(); public Dictionary> IndexedFiles = new(); public ModList ModList = new(); public AbsolutePath ModListImage; public bool ModlistIsNSFW; public string? ModListName, ModListAuthor, ModListDescription, ModListWebsite, ModlistReadme; public Version? ModlistVersion; protected Version? WabbajackVersion; public UpdateRequest? PublishData; public ACompiler(int steps, string modlistName, AbsolutePath sourcePath, AbsolutePath downloadsPath, AbsolutePath outputModListName, UpdateRequest? publishData) : base(steps) { SourcePath = sourcePath; DownloadsPath = downloadsPath; ModListName = modlistName; ModListOutputFile = outputModListName; //set in MainWindowVM WabbajackVersion = Consts.CurrentMinimumWabbajackVersion; Settings = new CompilerSettings(); ModListOutputFolder = AbsolutePath.EntryPoint.Combine("output_folder", Guid.NewGuid().ToString()); CompilingGame = new GameMetaData(); PublishData = publishData; } /// /// Set to true to include game files during compilation, only ever disabled /// in testing (to speed up tests) /// public bool UseGamePaths { get; set; } = true; public CompilerSettings Settings { get; set; } public AbsolutePath VFSCacheName => Consts.LocalAppDataPath.Combine( $"vfs_compile_cache-2-{Path.Combine((string)SourcePath ?? "Unknown", "ModOrganizer.exe").StringSha256Hex()}.bin"); //protected string VFSCacheName => Path.Combine(Consts.LocalAppDataPath, $"vfs_compile_cache.bin"); /// /// A stream of tuples of ("Update Title", 0.25) which represent the name of the current task /// and the current progress. /// public IObservable<(string, float)> ProgressUpdates => _progressUpdates; public abstract AbsolutePath GamePath { get; } public Dictionary> GameHashes { get; set; } = new Dictionary>(); public Dictionary GamesWithHashes { get; set; } = new Dictionary(); public AbsolutePath SourcePath { get; } public AbsolutePath DownloadsPath { get; } public GameMetaData CompilingGame { get; set; } public AbsolutePath ModListOutputFolder { get; } public AbsolutePath ModListOutputFile { get; } public bool IgnoreMissingFiles { get; set; } public List SelectedArchives { get; protected set; } = new List(); public List InstallDirectives { get; protected set; } = new List(); public List AllFiles { get; protected set; } = new List(); public Dictionary ArchivesByFullPath { get; set; } = new Dictionary(); public static void Info(string msg) { Utils.Log(msg); } public void Status(string msg) { Queue.Report(msg, Percent.Zero); } public static void Error(string msg) { Utils.Log(msg); throw new Exception(msg); } internal RelativePath IncludeId() { return RelativePath.RandomFileName(); } internal async Task IncludeFile(byte[] data) { var id = IncludeId(); await ModListOutputFolder.Combine(id).WriteAllBytesAsync(data); return id; } internal AbsolutePath IncludeFile(out RelativePath id) { id = IncludeId(); return ModListOutputFolder.Combine(id); } internal async Task IncludeFile(string data) { var id = IncludeId(); await ModListOutputFolder.Combine(id).WriteAllTextAsync(data); return id; } internal async Task IncludeFile(Stream data) { var id = IncludeId(); await ModListOutputFolder.Combine(id).WriteAllAsync(data); return id; } internal async Task IncludeFile(AbsolutePath data) { await using var stream = await data.OpenRead(); return await IncludeFile(stream); } internal async Task<(RelativePath, AbsolutePath)> IncludeString(string str) { var id = IncludeId(); var fullPath = ModListOutputFolder.Combine(id); await fullPath.WriteAllTextAsync(str); return (id, fullPath); } public async Task GatherMetaData() { Utils.Log($"Getting meta data for {SelectedArchives.Count} archives"); await SelectedArchives.PMap(Queue, async a => { if (a.State is IMetaState metaState) { if (metaState.URL == null || metaState.URL == Consts.WabbajackOrg) return; var b = await metaState.LoadMetaData(); if (b) Utils.Log($"Getting meta data for {a.Name} was successful!"); } }); return true; } public async Task PreflightChecks() { Utils.Log($"Running preflight checks"); if (PublishData == null) return; var ourLists = await (await AuthorApi.Client.Create()).GetMyModlists(); if (ourLists.All(l => l.MachineURL != PublishData.MachineUrl)) { Utils.ErrorThrow(new CriticalFailureIntervention( $"Cannot publish to {PublishData.MachineUrl}, you are not listed as a maintainer", "Cannot publish")); } } public async Task PublishModlist() { if (PublishData == null) return; var api = await AuthorApi.Client.Create(); Utils.Log($"Uploading modlist {PublishData!.MachineUrl}"); var metadata = ((AbsolutePath)(ModListOutputFile + ".meta.json")).FromJson(); var uri = await api.UploadFile(Queue, ModListOutputFile, (s, percent) => Utils.Status(s, percent)); PublishData.DownloadUrl = uri; PublishData.DownloadMetadata = metadata; Utils.Log($"Publishing modlist {PublishData!.MachineUrl}"); await api.UpdateModListInformation(PublishData); } protected async Task IndexGameFileHashes() { if (UseGamePaths) { //taking the games in Settings.IncludedGames + currently compiling game so you can eg //include the stock game files if you are compiling for a VR game (ex: Skyrim + SkyrimVR) foreach (var ag in Settings.IncludedGames.Cons(CompilingGame.Game).Distinct()) { try { var files = await ClientAPI.GetExistingGameFiles(Queue, ag); Utils.Log($"Including {files.Length} stock game files from {ag} as download sources"); GameHashes[ag] = files.Select(f => f.Hash).ToHashSet(); IndexedArchives.AddRange(files.Select(f => { var meta = f.State.GetMetaIniString(); var ini = meta.LoadIniString(); var state = (GameFileSourceDownloader.State)f.State; return new IndexedArchive( VFS.Index.ByRootPath[ag.MetaData().GameLocation().Combine(state.GameFile)]) { IniData = ini, Meta = meta, Name = state.GameFile.Munge().ToString() }; })); } catch (Exception e) { Utils.Error(e, "Unable to find existing game files, skipping."); } } GamesWithHashes = GameHashes.SelectMany(g => g.Value.Select(h => (g, h))) .GroupBy(gh => gh.h) .ToDictionary(gh => gh.Key, gh => gh.Select(p => p.g.Key).ToArray()); } } protected async Task CleanInvalidArchivesAndFillState() { var remove = (await IndexedArchives.PMap(Queue, async a => { try { a.State = (await ResolveArchive(a)).State; return null; } catch (Exception ex) { Utils.Log(ex.ToString()); return a; } })) .NotNull().ToHashSet(); if (remove.Count == 0) { return; } Utils.Log( $"Removing {remove.Count} archives from the compilation state, this is probably not an issue but reference this if you have compilation failures"); remove.Do(r => Utils.Log($"Resolution failed for: ({r.File.Size} {r.File.Hash}) {r.File.FullPath}")); IndexedArchives.RemoveAll(a => remove.Contains(a)); } protected async Task InferMetas() { async Task HasInvalidMeta(AbsolutePath filename) { var metaname = filename.WithExtension(Consts.MetaFileExtension); if (!metaname.Exists) { return true; } try { return await DownloadDispatcher.ResolveArchive(metaname.LoadIniFile()) == null; } catch (Exception e) { Utils.ErrorThrow(e, $"Exception while checking meta {filename}"); return false; } } var to_find = (await DownloadsPath.EnumerateFiles() .Where(f => f.Extension != Consts.MetaFileExtension && f.Extension != Consts.HashFileExtension) .PMap(Queue, async f => await HasInvalidMeta(f) ? f : default)) .Where(f => f.Exists) .ToList(); if (to_find.Count == 0) { return; } Utils.Log($"Attempting to infer {to_find.Count} metas from the server."); await to_find.PMap(Queue, async f => { var vf = VFS.Index.ByRootPath[f]; var meta = await ClientAPI.InferDownloadState(vf.Hash); if (meta == null) { await vf.AbsoluteName.WithExtension(Consts.MetaFileExtension).WriteAllLinesAsync( "[General]", "unknownArchive=true"); return; } Utils.Log($"Inferred .meta for {vf.FullPath.FileName}, writing to disk"); await vf.AbsoluteName.WithExtension(Consts.MetaFileExtension) .WriteAllTextAsync(meta.GetMetaIniString()); }); } protected async Task ExportModList() { Utils.Log($"Exporting ModList to {ModListOutputFile}"); // Modify readme and ModList image to relative paths if they exist if (ModListImage.Exists) { ModList.Image = (RelativePath)"modlist-image.png"; } await using (var of = await ModListOutputFolder.Combine("modlist").Create()) ModList.ToJson(of); await ModListOutputFolder.Combine("sig") .WriteAllBytesAsync(((await ModListOutputFolder.Combine("modlist").FileHashAsync()) ?? Hash.Empty).ToArray()); //await ClientAPI.SendModListDefinition(ModList); await ModListOutputFile.DeleteAsync(); await using (var fs = await ModListOutputFile.Create()) { using var za = new ZipArchive(fs, ZipArchiveMode.Create); await ModListOutputFolder.EnumerateFiles() .DoProgress("Compressing ModList", async f => { var ze = za.CreateEntry((string)f.FileName); await using var os = ze.Open(); await using var ins = await f.OpenRead(); await ins.CopyToAsync(os); }); // Copy in modimage if (ModListImage.Exists) { var ze = za.CreateEntry((string)ModList.Image); await using var os = ze.Open(); await using var ins = await ModListImage.OpenRead(); await ins.CopyToAsync(os); } } Utils.Log("Exporting Modlist metadata"); var outputFileHash = await ModListOutputFile.FileHashAsync(); if (outputFileHash == null) { Utils.Error("Unable to hash Modlist Output File"); return; } var metadata = new DownloadMetadata { Size = ModListOutputFile.Size, Hash = outputFileHash.Value, NumberOfArchives = ModList.Archives.Count, SizeOfArchives = ModList.Archives.Sum(a => a.Size), NumberOfInstalledFiles = ModList.Directives.Count, SizeOfInstalledFiles = ModList.Directives.Sum(a => a.Size) }; metadata.ToJson(ModListOutputFile + ".meta.json"); Utils.Log("Removing ModList staging folder"); await Utils.DeleteDirectory(ModListOutputFolder); } /// /// Fills in the Patch fields in files that require them /// protected async Task BuildPatches() { Info("Gathering patch files"); var toBuild = InstallDirectives.OfType() .Where(p => p.Choices.Length > 0) .SelectMany(p => p.Choices.Select(c => new PatchedFromArchive { To = p.To, Hash = p.Hash, ArchiveHashPath = c.MakeRelativePaths(), FromFile = c, Size = p.Size })) .ToArray(); if (toBuild.Length == 0) { return; } // Extract all the source files var indexed = toBuild.GroupBy(f => VFS.Index.FileForArchiveHashPath(f.ArchiveHashPath)) .ToDictionary(f => f.Key); await VFS.Extract(Queue, indexed.Keys.ToHashSet(), async (vf, sf) => { // For each, extract the destination var matches = indexed[vf]; using var iqueue = new WorkQueue(1); foreach (var match in matches) { var destFile = FindDestFile(match.To); // Build the patch await VFS.Extract(iqueue, new[] {destFile}.ToHashSet(), async (destvf, destsfn) => { Info($"Patching {match.To}"); Status($"Patching {match.To}"); await using var srcStream = await sf.GetStream(); await using var destStream = await destsfn.GetStream(); var patchSize = await Utils.CreatePatchCached(srcStream, vf.Hash, destStream, destvf.Hash); Info($"Patch size {patchSize} for {match.To}"); }); } }); // Load in the patches await InstallDirectives.OfType() .Where(p => p.PatchID == default) .PMap(Queue, async pfa => { var patches = pfa.Choices .Select(c => (Utils.TryGetPatch(c.Hash, pfa.Hash, out var data), data, c)) .ToArray(); // Pick the best patch if (patches.All(p => p.Item1)) { var (_, bytes, file) = IncludePatches.PickPatch(this, patches); pfa.FromFile = file; pfa.FromHash = file.Hash; pfa.ArchiveHashPath = file.MakeRelativePaths(); pfa.PatchID = await IncludeFile(await bytes!.GetData()); } }); var firstFailedPatch = InstallDirectives.OfType().FirstOrDefault(f => f.PatchID == default); if (firstFailedPatch != null) { Utils.Log("Missing data from failed patch, starting data dump"); Utils.Log($"Dest File: {firstFailedPatch.To}"); Utils.Log($"Options ({firstFailedPatch.Choices.Length}:"); foreach (var choice in firstFailedPatch.Choices) { Utils.Log($" {choice.FullPath}"); } Error( $"Missing patches after generation, this should not happen. First failure: {firstFailedPatch.FullPath}"); } } private VirtualFile FindDestFile(RelativePath to) { var abs = to.RelativeTo(SourcePath); if (abs.Exists) { return VFS.Index.ByRootPath[abs]; } if (to.StartsWith(Consts.BSACreationDir)) { var bsaId = (RelativePath)((string)to).Split('\\')[1]; var bsa = InstallDirectives.OfType().First(b => b.TempID == bsaId); var find = (RelativePath)Path.Combine(((string)to).Split('\\').Skip(2).ToArray()); return VFS.Index.ByRootPath[SourcePath.Combine(bsa.To)].Children.First(c => c.RelativeName == find); } throw new ArgumentException($"Couldn't load data for {to}"); } public void GenerateManifest() { var manifest = new Manifest(ModList); manifest.ToJson(ModListOutputFile + ".manifest.json"); } public async Task GatherArchives() { Info("Building a list of archives based on the files required"); var hashes = InstallDirectives.OfType() .Select(a => a.ArchiveHashPath.BaseHash) .Distinct(); var archives = IndexedArchives.OrderByDescending(f => f.File.LastModified) .GroupBy(f => f.File.Hash) .ToDictionary(f => f.Key, f => f.First()); SelectedArchives.SetTo(await hashes.PMap(Queue, hash => ResolveArchive(hash, archives))); } public async Task ResolveArchive(Hash hash, IDictionary archives) { if (archives.TryGetValue(hash, out var found)) { return await ResolveArchive(found); } throw new ArgumentException($"No match found for Archive sha: {hash.ToBase64()} this shouldn't happen"); } public async Task ResolveArchive([NotNull] IndexedArchive archive) { if (!string.IsNullOrWhiteSpace(archive.Name)) Utils.Status($"Checking link for {archive.Name}", alsoLog: true); if (archive.IniData == null) Error( $"No download metadata found for {archive.Name}, please use MO2 to query info or add a .meta file and try again."); var state = (AbstractDownloadState?)await DownloadDispatcher.ResolveArchive(archive.IniData); if (state == null) Error($"{archive.Name} could not be handled by any of the downloaders"); var result = new Archive(state!) { Name = archive.Name ?? "", Hash = archive.File.Hash, Size = archive.File.Size }; await result.State!.GetDownloader().Prepare(); var token = new CancellationTokenSource(); token.CancelAfter(Consts.MaxVerifyTime); if (!await result.State.Verify(result, token.Token)) Error( $"Unable to resolve link for {archive.Name}. If this is hosted on the Nexus the file may have been removed."); result.Meta = string.Join("\n", result.State!.GetMetaIni()); return result; } public async Task RunStack(IEnumerable stack, RawSourceFile source) { Utils.Status($"Compiling {source.Path}"); foreach (var step in stack) { var result = await step.Run(source); if (result != null) return result; } throw new InvalidDataException("Data fell out of the compilation stack"); } public abstract IEnumerable GetStack(); public abstract IEnumerable MakeStack(); public static void PrintNoMatches(ICollection noMatches) { const int max = 10; Info($"No match for {noMatches.Count} files"); if (noMatches.Count > 0) { int count = 0; foreach (var file in noMatches) { if (count++ < max) { Utils.Log($" {file.To} - {file.Reason}"); } else { Utils.LogStraightToFile($" {file.To} - {file.Reason}"); } if (count == max && noMatches.Count > max) { Utils.Log($" ..."); } } } } protected async Task InlineFiles() { var grouped = ModList.Directives.OfType() .Where(f => f.SourceDataID == default) .GroupBy(f => f.SourceDataFile) .Where(x => x.Key != null) .ToDictionary(f => f.Key!); if (grouped.Count == 0) return; await VFS.Extract(Queue, grouped.Keys.ToHashSet(), async (vf, sfn) => { await using var stream = await sfn.GetStream(); var id = await IncludeFile(stream); foreach (var file in grouped[vf]) { file.SourceDataID = id; file.SourceDataFile = null; } }); } public bool CheckForNoMatchExit(ICollection noMatches) { if (noMatches.Count > 0) { if (IgnoreMissingFiles) { Info("Continuing even though files were missing at the request of the user."); } else { Info("Exiting due to no way to compile these files"); return true; } } return false; } } }