mirror of
https://github.com/wabbajack-tools/wabbajack.git
synced 2024-08-30 18:42:17 +00:00
443 lines
18 KiB
C#
443 lines
18 KiB
C#
using System;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using System.Threading;
|
|
using System.Windows;
|
|
using Alphaleonis.Win32.Filesystem;
|
|
using IniParser;
|
|
using IniParser.Model;
|
|
using IniParser.Model.Configuration;
|
|
using IniParser.Parser;
|
|
using Wabbajack.Common;
|
|
using Wabbajack.Lib.CompilationSteps.CompilationErrors;
|
|
using Wabbajack.Lib.Downloaders;
|
|
using Wabbajack.Lib.NexusApi;
|
|
using Wabbajack.Lib.Validation;
|
|
using Directory = Alphaleonis.Win32.Filesystem.Directory;
|
|
using File = Alphaleonis.Win32.Filesystem.File;
|
|
using Path = Alphaleonis.Win32.Filesystem.Path;
|
|
using SectionData = Wabbajack.Common.SectionData;
|
|
|
|
namespace Wabbajack.Lib
|
|
{
|
|
public class MO2Installer : AInstaller
|
|
{
|
|
public bool WarnOnOverwrite { get; set; } = true;
|
|
|
|
public override ModManager ModManager => ModManager.MO2;
|
|
|
|
public AbsolutePath? GameFolder { get; set; }
|
|
|
|
public MO2Installer(AbsolutePath archive, ModList modList, AbsolutePath outputFolder, AbsolutePath downloadFolder, SystemParameters parameters)
|
|
: base(
|
|
archive: archive,
|
|
modList: modList,
|
|
outputFolder: outputFolder,
|
|
downloadFolder: downloadFolder,
|
|
parameters: parameters,
|
|
steps: 21,
|
|
game: modList.GameType)
|
|
{
|
|
var gameExe = Consts.GameFolderFilesDir.Combine(modList.GameType.MetaData().MainExecutable!);
|
|
RedirectGamePath = modList.Directives.Any(d => d.To == gameExe);
|
|
}
|
|
|
|
public bool RedirectGamePath { get; }
|
|
|
|
protected override async Task<bool> _Begin(CancellationToken cancel)
|
|
{
|
|
if (cancel.IsCancellationRequested) return false;
|
|
var metric = Metrics.Send(Metrics.BeginInstall, ModList.Name);
|
|
Utils.Log("Configuring Processor");
|
|
|
|
Queue.SetActiveThreadsObservable(ConstructDynamicNumThreads(await RecommendQueueSize()));
|
|
|
|
if (GameFolder == null)
|
|
GameFolder = Game.TryGetGameLocation();
|
|
|
|
if (GameFolder == null)
|
|
{
|
|
var otherGame = Game.CommonlyConfusedWith.Where(g => g.MetaData().IsInstalled).Select(g => g.MetaData()).FirstOrDefault();
|
|
if (otherGame != null)
|
|
{
|
|
await Utils.Log(new CriticalFailureIntervention(
|
|
$"In order to do a proper install Wabbajack needs to know where your {Game.HumanFriendlyGameName} folder resides. However this game doesn't seem to be installed, we did however find a installed " +
|
|
$"copy of {otherGame.HumanFriendlyGameName}, did you install the wrong game?",
|
|
$"Could not locate {Game.HumanFriendlyGameName}"))
|
|
.Task;
|
|
}
|
|
else
|
|
{
|
|
await Utils.Log(new CriticalFailureIntervention(
|
|
$"In order to do a proper install Wabbajack needs to know where your {Game.HumanFriendlyGameName} folder resides. However this game doesn't seem to be installed",
|
|
$"Could not locate {Game.HumanFriendlyGameName}"))
|
|
.Task;
|
|
}
|
|
|
|
Utils.Log("Exiting because we couldn't find the game folder.");
|
|
return false;
|
|
}
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Validating Game ESMs");
|
|
await ValidateGameESMs();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Validating Modlist");
|
|
await ValidateModlist.RunValidation(ModList);
|
|
|
|
OutputFolder.CreateDirectory();
|
|
DownloadFolder.CreateDirectory();
|
|
|
|
if (OutputFolder.Combine(Consts.MO2ModFolderName).IsDirectory && WarnOnOverwrite)
|
|
{
|
|
if ((await Utils.Log(new ConfirmUpdateOfExistingInstall { ModListName = ModList.Name, OutputFolder = OutputFolder }).Task) == ConfirmUpdateOfExistingInstall.Choice.Abort)
|
|
{
|
|
Utils.Log("Exiting installation at the request of the user, existing mods folder found.");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Optimizing ModList");
|
|
await OptimizeModlist();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Hashing Archives");
|
|
await HashArchives();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Downloading Missing Archives");
|
|
await DownloadArchives();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Hashing Remaining Archives");
|
|
await HashArchives();
|
|
|
|
var missing = ModList.Archives.Where(a => !HashedArchives.ContainsKey(a.Hash)).ToList();
|
|
if (missing.Count > 0)
|
|
{
|
|
foreach (var a in missing)
|
|
Info($"Unable to download {a.Name}");
|
|
if (IgnoreMissingFiles)
|
|
Info("Missing some archives, but continuing anyways at the request of the user");
|
|
else
|
|
Error("Cannot continue, was unable to download one or more archives");
|
|
}
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Extracting Modlist contents");
|
|
await ExtractModlist();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Priming VFS");
|
|
await PrimeVFS();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Building Folder Structure");
|
|
BuildFolderStructure();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Installing Archives");
|
|
await InstallArchives();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Installing Included files");
|
|
await InstallIncludedFiles();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Installing Archive Metas");
|
|
await InstallIncludedDownloadMetas();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Building BSAs");
|
|
await BuildBSAs();
|
|
|
|
if (cancel.IsCancellationRequested) return false;
|
|
UpdateTracker.NextStep("Generating Merges");
|
|
await zEditIntegration.GenerateMerges(this);
|
|
|
|
UpdateTracker.NextStep("Set MO2 into portable");
|
|
await ForcePortable();
|
|
|
|
UpdateTracker.NextStep("Create Empty Output Mods");
|
|
CreateOutputMods();
|
|
|
|
UpdateTracker.NextStep("Updating System-specific ini settings");
|
|
SetScreenSizeInPrefs();
|
|
|
|
UpdateTracker.NextStep("Installation complete! You may exit the program.");
|
|
var metric2 = Metrics.Send(Metrics.FinishInstall, ModList.Name);
|
|
|
|
return true;
|
|
}
|
|
|
|
private void CreateOutputMods()
|
|
{
|
|
OutputFolder.Combine("profiles")
|
|
.EnumerateFiles(true)
|
|
.Where(f => f.FileName == Consts.SettingsIni)
|
|
.Do(f =>
|
|
{
|
|
var ini = f.LoadIniFile();
|
|
if (ini == null)
|
|
{
|
|
Utils.Log($"settings.ini is null for {f}, skipping");
|
|
return;
|
|
}
|
|
|
|
var overwrites = ini.custom_overwrites;
|
|
if (overwrites == null)
|
|
{
|
|
Utils.Log("No custom overwrites found, skipping");
|
|
return;
|
|
}
|
|
|
|
if (overwrites is SectionData data)
|
|
{
|
|
data.Coll.Do(keyData =>
|
|
{
|
|
var v = keyData.Value;
|
|
var mod = OutputFolder.Combine(Consts.MO2ModFolderName, (RelativePath)v);
|
|
|
|
mod.CreateDirectory();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task ForcePortable()
|
|
{
|
|
var path = OutputFolder.Combine("portable.txt");
|
|
if (path.Exists) return;
|
|
|
|
try
|
|
{
|
|
await path.WriteAllTextAsync("Created by Wabbajack");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Utils.Error(e, $"Could not create portable.txt in {OutputFolder}");
|
|
}
|
|
}
|
|
|
|
private async Task InstallIncludedDownloadMetas()
|
|
{
|
|
await ModList.Directives
|
|
.OfType<ArchiveMeta>()
|
|
.PMap(Queue, async directive =>
|
|
{
|
|
Status($"Writing included .meta file {directive.To}");
|
|
var outPath = DownloadFolder.Combine(directive.To);
|
|
if (outPath.IsFile) await outPath.DeleteAsync();
|
|
await outPath.WriteAllBytesAsync(await LoadBytesFromPath(directive.SourceDataID));
|
|
});
|
|
}
|
|
|
|
private async ValueTask ValidateGameESMs()
|
|
{
|
|
foreach (var esm in ModList.Directives.OfType<CleanedESM>().ToList())
|
|
{
|
|
var filename = esm.To.FileName;
|
|
var gameFile = GameFolder!.Value.Combine((RelativePath)"Data", filename);
|
|
Utils.Log($"Validating {filename}");
|
|
var hash = await gameFile.FileHashAsync();
|
|
if (hash != esm.SourceESMHash)
|
|
{
|
|
Utils.ErrorThrow(new InvalidGameESMError(esm, hash, gameFile));
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task BuildBSAs()
|
|
{
|
|
var bsas = ModList.Directives.OfType<CreateBSA>().ToList();
|
|
Info($"Building {bsas.Count} bsa files");
|
|
|
|
foreach (var bsa in bsas)
|
|
{
|
|
Status($"Building {bsa.To}");
|
|
var sourceDir = OutputFolder.Combine(Consts.BSACreationDir, bsa.TempID);
|
|
|
|
var bsaSize = bsa.FileStates.Select(state => sourceDir.Combine(state.Path).Size).Sum();
|
|
|
|
await using var a = bsa.State.MakeBuilder(bsaSize);
|
|
var streams = await bsa.FileStates.PMap(Queue, async state =>
|
|
{
|
|
Status($"Adding {state.Path} to BSA");
|
|
var fs = await sourceDir.Combine(state.Path).OpenRead();
|
|
await a.AddFile(state, fs);
|
|
return fs;
|
|
});
|
|
|
|
Info($"Writing {bsa.To}");
|
|
await a.Build(OutputFolder.Combine(bsa.To));
|
|
streams.Do(s => s.Dispose());
|
|
}
|
|
|
|
var bsaDir = OutputFolder.Combine(Consts.BSACreationDir);
|
|
if (bsaDir.Exists)
|
|
{
|
|
Info($"Removing temp folder {Consts.BSACreationDir}");
|
|
await Utils.DeleteDirectory(bsaDir);
|
|
}
|
|
}
|
|
|
|
private async Task InstallIncludedFiles()
|
|
{
|
|
Info("Writing inline files");
|
|
await ModList.Directives
|
|
.OfType<InlineFile>()
|
|
.PMap(Queue, async directive =>
|
|
{
|
|
Status($"Writing included file {directive.To}");
|
|
var outPath = OutputFolder.Combine(directive.To);
|
|
await outPath.DeleteAsync();
|
|
|
|
switch (directive)
|
|
{
|
|
case RemappedInlineFile file:
|
|
await WriteRemappedFile(file);
|
|
break;
|
|
case CleanedESM esm:
|
|
await GenerateCleanedESM(esm);
|
|
break;
|
|
default:
|
|
await outPath.WriteAllBytesAsync(await LoadBytesFromPath(directive.SourceDataID));
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task GenerateCleanedESM(CleanedESM directive)
|
|
{
|
|
var filename = directive.To.FileName;
|
|
var gameFile = GameFolder!.Value.Combine((RelativePath)"Data", filename);
|
|
Info($"Generating cleaned ESM for {filename}");
|
|
if (!gameFile.Exists) throw new InvalidDataException($"Missing {filename} at {gameFile}");
|
|
Status($"Hashing game version of {filename}");
|
|
var sha = await gameFile.FileHashCachedAsync();
|
|
if (sha != directive.SourceESMHash)
|
|
throw new InvalidDataException(
|
|
$"Cannot patch {filename} from the game folder because the hashes do not match. Have you already cleaned the file?");
|
|
|
|
var patchData = await LoadBytesFromPath(directive.SourceDataID);
|
|
var toFile = OutputFolder.Combine(directive.To);
|
|
Status($"Patching {filename}");
|
|
await using var output = await toFile.Create();
|
|
await using var input = await gameFile.OpenRead();
|
|
Utils.ApplyPatch(input, () => new MemoryStream(patchData), output);
|
|
}
|
|
|
|
private void SetScreenSizeInPrefs()
|
|
{
|
|
if (SystemParameters == null)
|
|
{
|
|
throw new ArgumentNullException("System Parameters was null. Cannot set screen size prefs");
|
|
}
|
|
var config = new IniParserConfiguration {AllowDuplicateKeys = true, AllowDuplicateSections = true};
|
|
foreach (var file in OutputFolder.Combine("profiles").EnumerateFiles()
|
|
.Where(f => ((string)f.FileName).EndsWith("refs.ini")))
|
|
{
|
|
try
|
|
{
|
|
var parser = new FileIniDataParser(new IniDataParser(config));
|
|
var data = parser.ReadFile((string)file);
|
|
bool modified = false;
|
|
if (data.Sections["Display"] != null)
|
|
{
|
|
|
|
if (data.Sections["Display"]["iSize W"] != null && data.Sections["Display"]["iSize H"] != null)
|
|
{
|
|
data.Sections["Display"]["iSize W"] =
|
|
SystemParameters.ScreenWidth.ToString(CultureInfo.CurrentCulture);
|
|
data.Sections["Display"]["iSize H"] =
|
|
SystemParameters.ScreenHeight.ToString(CultureInfo.CurrentCulture);
|
|
modified = true;
|
|
}
|
|
|
|
}
|
|
if (data.Sections["MEMORY"] != null)
|
|
{
|
|
if (data.Sections["MEMORY"]["VideoMemorySizeMb"] != null)
|
|
{
|
|
data.Sections["MEMORY"]["VideoMemorySizeMb"] =
|
|
SystemParameters.EnbLEVRAMSize.ToString(CultureInfo.CurrentCulture);
|
|
modified = true;
|
|
}
|
|
}
|
|
|
|
if (modified)
|
|
parser.WriteFile((string)file, data);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
Utils.Log($"Skipping screen size remap for {file} due to parse error.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task WriteRemappedFile(RemappedInlineFile directive)
|
|
{
|
|
var data = Encoding.UTF8.GetString(await LoadBytesFromPath(directive.SourceDataID));
|
|
|
|
var gameFolder = (string)(RedirectGamePath ? Consts.GameFolderFilesDir.RelativeTo(OutputFolder) : GameFolder!);
|
|
|
|
|
|
data = data.Replace(Consts.GAME_PATH_MAGIC_BACK, gameFolder);
|
|
data = data.Replace(Consts.GAME_PATH_MAGIC_DOUBLE_BACK, gameFolder.Replace("\\", "\\\\"));
|
|
data = data.Replace(Consts.GAME_PATH_MAGIC_FORWARD, gameFolder.Replace("\\", "/"));
|
|
|
|
data = data.Replace(Consts.MO2_PATH_MAGIC_BACK, (string)OutputFolder);
|
|
data = data.Replace(Consts.MO2_PATH_MAGIC_DOUBLE_BACK, ((string)OutputFolder).Replace("\\", "\\\\"));
|
|
data = data.Replace(Consts.MO2_PATH_MAGIC_FORWARD, ((string)OutputFolder).Replace("\\", "/"));
|
|
|
|
data = data.Replace(Consts.DOWNLOAD_PATH_MAGIC_BACK, (string)DownloadFolder);
|
|
data = data.Replace(Consts.DOWNLOAD_PATH_MAGIC_DOUBLE_BACK, ((string)DownloadFolder).Replace("\\", "\\\\"));
|
|
data = data.Replace(Consts.DOWNLOAD_PATH_MAGIC_FORWARD, ((string)DownloadFolder).Replace("\\", "/"));
|
|
|
|
await OutputFolder.Combine(directive.To).WriteAllTextAsync(data);
|
|
}
|
|
|
|
public static IErrorResponse CheckValidInstallPath(AbsolutePath path, AbsolutePath? downloadFolder)
|
|
{
|
|
if (!path.Exists) return ErrorResponse.Success;
|
|
|
|
// Check folder does not have a Wabbajack ModList
|
|
if (path.EnumerateFiles(false).Where(file => file.Exists).Any(file => file.Extension == Consts.ModListExtension))
|
|
{
|
|
return ErrorResponse.Fail($"Cannot install into a folder with a Wabbajack ModList inside of it");
|
|
}
|
|
|
|
// Check folder is either empty, or a likely valid previous install
|
|
if (path.IsEmptyDirectory)
|
|
{
|
|
return ErrorResponse.Success;
|
|
}
|
|
|
|
// If we have a MO2 install, assume good to go
|
|
if (path.EnumerateFiles(false).Any(file =>
|
|
{
|
|
if (file.FileName == Consts.ModOrganizer2Exe) return true;
|
|
if (file.FileName == Consts.ModOrganizer2Ini) return true;
|
|
return false;
|
|
}))
|
|
{
|
|
return ErrorResponse.Success;
|
|
}
|
|
|
|
// If we don't have a MO2 install, and there's any file that's not in the downloads folder, mark failure
|
|
if (downloadFolder.HasValue && path.EnumerateFiles(true).All(file => file.InFolder(downloadFolder.Value)))
|
|
{
|
|
return ErrorResponse.Success;
|
|
}
|
|
|
|
return ErrorResponse.Fail($"Cannot install to this folder as it has unknown files that could be deleted");
|
|
}
|
|
}
|
|
}
|