using Compression.BSA; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using Wabbajack.Common; using Wabbajack.Lib.CompilationSteps; using Wabbajack.Lib.NexusApi; using Wabbajack.Lib.Validation; using Wabbajack.VirtualFileSystem; using Directory = Alphaleonis.Win32.Filesystem.Directory; using File = Alphaleonis.Win32.Filesystem.File; using Path = Alphaleonis.Win32.Filesystem.Path; namespace Wabbajack.Lib { public class MO2Compiler : ACompiler { private string _mo2DownloadsFolder; public string MO2Folder; public string MO2Profile; public MO2Compiler(string mo2_folder) { ModManager = ModManager.MO2; MO2Folder = mo2_folder; MO2Ini = Path.Combine(MO2Folder, "ModOrganizer.ini").LoadIniFile(); GamePath = ((string)MO2Ini.General.gamePath).Replace("\\\\", "\\"); ModListOutputFolder = "output_folder"; ModListOutputFile = MO2Profile + ExtensionManager.Extension; } public dynamic MO2Ini { get; } public bool IgnoreMissingFiles { get; set; } public string MO2DownloadsFolder { get { if (_mo2DownloadsFolder != null) return _mo2DownloadsFolder; if (MO2Ini != null) if (MO2Ini.Settings != null) if (MO2Ini.Settings.download_directory != null) return MO2Ini.Settings.download_directory.Replace("/", "\\"); return Path.Combine(MO2Folder, "downloads"); } set => _mo2DownloadsFolder = value; } public string MO2ProfileDir => Path.Combine(MO2Folder, "profiles", MO2Profile); internal UserStatus User { get; private set; } public ConcurrentBag ExtraFiles { get; private set; } public Dictionary ModInis { get; private set; } public HashSet SelectedProfiles { get; set; } = new HashSet(); protected override bool _Begin() { ConfigureProcessor(10); UpdateTracker.Reset(); UpdateTracker.NextStep("Gathering information"); Info("Looking for other profiles"); var other_profiles_path = Path.Combine(MO2ProfileDir, "otherprofiles.txt"); SelectedProfiles = new HashSet(); if (File.Exists(other_profiles_path)) SelectedProfiles = File.ReadAllLines(other_profiles_path).ToHashSet(); SelectedProfiles.Add(MO2Profile); Info("Using Profiles: " + string.Join(", ", SelectedProfiles.OrderBy(p => p))); VFS.IntegrateFromFile(_vfsCacheName); UpdateTracker.NextStep($"Indexing {MO2Folder}"); VFS.AddRoot(MO2Folder); UpdateTracker.NextStep("Writing VFS Cache"); VFS.WriteToFile(_vfsCacheName); UpdateTracker.NextStep($"Indexing {GamePath}"); VFS.AddRoot(GamePath); UpdateTracker.NextStep("Writing VFS Cache"); VFS.WriteToFile(_vfsCacheName); UpdateTracker.NextStep($"Indexing {MO2DownloadsFolder}"); VFS.AddRoot(MO2DownloadsFolder); UpdateTracker.NextStep("Writing VFS Cache"); VFS.WriteToFile(_vfsCacheName); UpdateTracker.NextStep("Cleaning output folder"); if (Directory.Exists(ModListOutputFolder)) Directory.Delete(ModListOutputFolder, true, true); UpdateTracker.NextStep("Finding Install Files"); Directory.CreateDirectory(ModListOutputFolder); var mo2_files = Directory.EnumerateFiles(MO2Folder, "*", SearchOption.AllDirectories) .Where(p => p.FileExists()) .Select(p => new RawSourceFile(VFS.Index.ByRootPath[p]) { Path = p.RelativeTo(MO2Folder) }); var game_files = Directory.EnumerateFiles(GamePath, "*", SearchOption.AllDirectories) .Where(p => p.FileExists()) .Select(p => new RawSourceFile(VFS.Index.ByRootPath[p]) { Path = Path.Combine(Consts.GameFolderFilesDir, p.RelativeTo(GamePath)) }); var loot_path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LOOT"); // TODO: make this generic so we can add more paths IEnumerable loot_files = new List(); if (Directory.Exists(loot_path)) { Info($"Indexing {loot_path}"); VFS.AddRoot(loot_path); VFS.WriteToFile(_vfsCacheName); loot_files = Directory.EnumerateFiles(loot_path, "userlist.yaml", SearchOption.AllDirectories) .Where(p => p.FileExists()) .Select(p => new RawSourceFile(VFS.Index.ByRootPath[p]) { Path = Path.Combine(Consts.LOOTFolderFilesDir, p.RelativeTo(loot_path)) }); } IndexedArchives = Directory.EnumerateFiles(MO2DownloadsFolder) .Where(f => File.Exists(f + ".meta")) .Select(f => new IndexedArchive { File = VFS.Index.ByRootPath[f], Name = Path.GetFileName(f), IniData = (f + ".meta").LoadIniFile(), Meta = File.ReadAllText(f + ".meta") }) .ToList(); IndexedFiles = IndexedArchives.SelectMany(f => f.File.ThisAndAllChildren) .OrderBy(f => f.NestingFactor) .GroupBy(f => f.Hash) .ToDictionary(f => f.Key, f => f.AsEnumerable()); AllFiles = mo2_files.Concat(game_files) .Concat(loot_files) .DistinctBy(f => f.Path) .ToList(); Info($"Found {AllFiles.Count} files to build into mod list"); Info("Verifying destinations"); var dups = AllFiles.GroupBy(f => f.Path) .Where(fs => fs.Count() > 1) .Select(fs => { Utils.Log($"Duplicate files installed to {fs.Key} from : {String.Join(", ", fs.Select(f => f.AbsolutePath))}"); return fs; }).ToList(); if (dups.Count > 0) { Error($"Found {dups.Count} duplicates, exiting"); } ExtraFiles = new ConcurrentBag(); ModInis = Directory.EnumerateDirectories(Path.Combine(MO2Folder, "mods")) .Select(f => { var mod_name = Path.GetFileName(f); var meta_path = Path.Combine(f, "meta.ini"); if (File.Exists(meta_path)) return (mod_name, meta_path.LoadIniFile()); return (null, null); }) .Where(f => f.Item2 != null) .ToDictionary(f => f.Item1, f => f.Item2); var stack = MakeStack(); UpdateTracker.NextStep("Running Compilation Stack"); var results = AllFiles.PMap(Queue, UpdateTracker, f => RunStack(stack, f)).ToList(); // Add the extra files that were generated by the stack Info($"Adding {ExtraFiles.Count} that were generated by the stack"); results = results.Concat(ExtraFiles).ToList(); var nomatch = results.OfType(); Info($"No match for {nomatch.Count()} files"); foreach (var file in nomatch) Info($" {file.To}"); if (nomatch.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 false; } } InstallDirectives = results.Where(i => !(i is IgnoredDirectly)).ToList(); Info("Getting Nexus api_key, please click authorize if a browser window appears"); if (IndexedArchives.Any(a => a.IniData?.General?.gameName != null)) { var nexusClient = new NexusApiClient(); if (!nexusClient.IsPremium) Error($"User {nexusClient.Username} is not a premium Nexus user, so we cannot access the necessary API calls, cannot continue"); } zEditIntegration.VerifyMerges(this); GatherArchives(); IncludeArchiveMetadata(); BuildPatches(); ModList = new ModList { GameType = GameRegistry.Games.Values.First(f => f.MO2Name == MO2Ini.General.gameName).Game, WabbajackVersion = WabbajackVersion, Archives = SelectedArchives, ModManager = ModManager.MO2, Directives = InstallDirectives, Name = ModListName ?? MO2Profile, Author = ModListAuthor ?? "", Description = ModListDescription ?? "", Readme = ModListReadme ?? "", Image = ModListImage ?? "", Website = ModListWebsite ?? "" }; ValidateModlist.RunValidation(ModList); GenerateReport(); ExportModList(); ResetMembers(); ShowReport(); Info("Done Building Modlist"); return true; } private void IncludeArchiveMetadata() { Utils.Log($"Including {SelectedArchives.Count} .meta files for downloads"); SelectedArchives.Do(a => { var source = Path.Combine(MO2DownloadsFolder, a.Name + ".meta"); InstallDirectives.Add(new ArchiveMeta() { SourceDataID = IncludeFile(File.ReadAllText(source)), Size = File.GetSize(source), Hash = source.FileHash(), To = Path.GetFileName(source) }); }); } /// /// Clear references to lists that hold a lot of data. /// private void ResetMembers() { AllFiles = null; InstallDirectives = null; SelectedArchives = null; ExtraFiles = null; } /// /// Fills in the Patch fields in files that require them /// private void BuildPatches() { Info("Gathering patch files"); var groups = InstallDirectives.OfType() .Where(p => p.PatchID == null) .GroupBy(p => p.ArchiveHashPath[0]) .ToList(); Info($"Patching building patches from {groups.Count} archives"); var absolute_paths = AllFiles.ToDictionary(e => e.Path, e => e.AbsolutePath); groups.PMap(Queue, group => BuildArchivePatches(group.Key, group, absolute_paths)); if (InstallDirectives.OfType().FirstOrDefault(f => f.PatchID == null) != null) Error("Missing patches after generation, this should not happen"); } private void BuildArchivePatches(string archive_sha, IEnumerable group, Dictionary absolute_paths) { using (var files = VFS.StageWith(group.Select(g => VFS.Index.FileForArchiveHashPath(g.ArchiveHashPath)))) { var by_path = files.GroupBy(f => string.Join("|", f.FilesInFullPath.Skip(1).Select(i => i.Name))) .ToDictionary(f => f.Key, f => f.First()); // Now Create the patches group.PMap(Queue, entry => { Info($"Patching {entry.To}"); Status($"Patching {entry.To}"); using (var origin = by_path[string.Join("|", entry.ArchiveHashPath.Skip(1))].OpenRead()) using (var output = new MemoryStream()) { var a = origin.ReadAll(); var b = LoadDataForTo(entry.To, absolute_paths).Result; Utils.CreatePatch(a, b, output); entry.PatchID = IncludeFile(output.ToArray()); var file_size = File.GetSize(Path.Combine(ModListOutputFolder, entry.PatchID)); Info($"Patch size {file_size} for {entry.To}"); } }); } } private async Task LoadDataForTo(string to, Dictionary absolute_paths) { if (absolute_paths.TryGetValue(to, out var absolute)) return File.ReadAllBytes(absolute); if (to.StartsWith(Consts.BSACreationDir)) { var bsa_id = to.Split('\\')[1]; var bsa = InstallDirectives.OfType().First(b => b.TempID == bsa_id); using (var a = BSADispatch.OpenRead(Path.Combine(MO2Folder, bsa.To))) { var find = Path.Combine(to.Split('\\').Skip(2).ToArray()); var file = a.Files.First(e => e.Path.Replace('/', '\\') == find); using (var ms = new MemoryStream()) { file.CopyDataTo(ms); return ms.ToArray(); } } } Error($"Couldn't load data for {to}"); return null; } public override IEnumerable GetStack() { var user_config = Path.Combine(MO2ProfileDir, "compilation_stack.yml"); if (File.Exists(user_config)) return Serialization.Deserialize(File.ReadAllText(user_config), this); var stack = MakeStack(); File.WriteAllText(Path.Combine(MO2ProfileDir, "_current_compilation_stack.yml"), Serialization.Serialize(stack)); return stack; } /// /// Creates a execution stack. The stack should be passed into Run stack. Each function /// in this stack will be run in-order and the first to return a non-null result will have its /// result included into the pack /// /// public override IEnumerable MakeStack() { Utils.Log("Generating compilation stack"); return new List { new IncludePropertyFiles(this), new IgnoreStartsWith(this,"logs\\"), new IgnoreStartsWith(this, "downloads\\"), new IgnoreStartsWith(this,"webcache\\"), new IgnoreStartsWith(this, "overwrite\\"), new IgnorePathContains(this,"temporary_logs"), new IgnorePathContains(this, "GPUCache"), new IgnorePathContains(this, "SSEEdit Cache"), new IgnoreEndsWith(this, ".pyc"), new IgnoreEndsWith(this, ".log"), new IgnoreOtherProfiles(this), new IgnoreDisabledMods(this), new IncludeThisProfile(this), // Ignore the ModOrganizer.ini file it contains info created by MO2 on startup new IncludeStubbedConfigFiles(this), new IncludeLootFiles(this), new IgnoreStartsWith(this, Path.Combine(Consts.GameFolderFilesDir, "Data")), new IgnoreStartsWith(this, Path.Combine(Consts.GameFolderFilesDir, "Papyrus Compiler")), new IgnoreStartsWith(this, Path.Combine(Consts.GameFolderFilesDir, "Skyrim")), new IgnoreRegex(this, Consts.GameFolderFilesDir + "\\\\.*\\.bsa"), new IncludeModIniData(this), new DirectMatch(this), new IncludeTaggedMods(this, Consts.WABBAJACK_INCLUDE), new DeconstructBSAs(this), // Deconstruct BSAs before building patches so we don't generate massive patch files new IncludePatches(this), new IncludeDummyESPs(this), // If we have no match at this point for a game folder file, skip them, we can't do anything about them new IgnoreGameFiles(this), // There are some types of files that will error the compilation, because they're created on-the-fly via tools // so if we don't have a match by this point, just drop them. new IgnoreEndsWith(this, ".ini"), new IgnoreEndsWith(this, ".html"), new IgnoreEndsWith(this, ".txt"), // Don't know why, but this seems to get copied around a bit new IgnoreEndsWith(this, "HavokBehaviorPostProcess.exe"), // Theme file MO2 downloads somehow new IgnoreEndsWith(this, "splash.png"), new IgnoreEndsWith(this, ".bin"), new IgnoreEndsWith(this, ".refcache"), new IgnoreWabbajackInstallCruft(this), new PatchStockESMs(this), new IncludeAllConfigs(this), new zEditIntegration.IncludeZEditPatches(this), new IncludeTaggedMods(this, Consts.WABBAJACK_NOMATCH_INCLUDE), new DropAll(this) }; } public class IndexedFileMatch { public IndexedArchive Archive; public IndexedArchiveEntry Entry; public DateTime LastModified; } } }