UI fixes and optimizations

This commit is contained in:
Timothy Baldridge 2021-11-02 07:40:59 -06:00
parent a4de95889a
commit 5d1bc5ff3b
10 changed files with 110 additions and 54 deletions

View File

@ -1,8 +1,10 @@
using System; using System;
using System.Threading;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -24,6 +26,7 @@ public class App : Application
public override void Initialize() public override void Initialize()
{ {
Dispatcher.UIThread.Post(() => Thread.CurrentThread.Name = "UIThread");
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }

View File

@ -1,7 +1,11 @@
using System; using System;
using System.Linq.Expressions;
using System.Reactive.Disposables; using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Reactive.Subjects; using System.Reactive.Subjects;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using ReactiveUI;
namespace Wabbajack.App.Extensions; namespace Wabbajack.App.Extensions;
@ -21,4 +25,34 @@ public static class IObservableExtensions
return returnObs; return returnObs;
} }
public static IDisposable SimpleOneWayBind<TView, TViewModel, TProp, TOut>(
this TView view,
TViewModel? viewModel,
Expression<Func<TViewModel, TProp?>> vmProperty,
Expression<Func<TView, TOut?>> viewProperty)
where TView : class
{
var d = viewModel.WhenAny(vmProperty, change => change.Value)
.ObserveOn(RxApp.MainThreadScheduler)
.BindTo(view, viewProperty);
return Disposable.Create(() => d.Dispose());
}
public static IDisposable SimpleOneWayBind<TView, TViewModel, TProp, TOut>(
this TView view,
TViewModel? viewModel,
Expression<Func<TViewModel, TProp?>> vmProperty,
Expression<Func<TView, TOut?>> viewProperty,
Func<TProp?, TOut> selector)
where TView : class
{
var d = viewModel.WhenAnyValue(vmProperty)
.Select(change => selector(change))
.ObserveOn(RxApp.MainThreadScheduler)
.BindTo(view, viewProperty);
return Disposable.Create(() => d.Dispose());
}
} }

View File

@ -48,7 +48,7 @@ public class LauncherViewModel : ViewModelBase
.DisposeWith(disposables); .DisposeWith(disposables);
this.WhenAnyValue(v => v.Setting) this.WhenAnyValue(v => v.Setting)
.Where(v => v != default && v!.Image != default) .Where(v => v != default && v!.Image != default && v!.Image.FileExists())
.Select(v => new Bitmap(v!.Image.ToString())) .Select(v => new Bitmap(v!.Image.ToString()))
.BindTo(this, vm => vm.Image) .BindTo(this, vm => vm.Image)
.DisposeWith(disposables); .DisposeWith(disposables);

View File

@ -7,8 +7,8 @@
x:Class="Wabbajack.App.Views.StandardInstallationView"> x:Class="Wabbajack.App.Views.StandardInstallationView">
<Grid RowDefinitions="40, 5, 5, *, 40"> <Grid RowDefinitions="40, 5, 5, *, 40">
<TextBlock Grid.Row="0" x:Name="StatusText" FontSize="20" FontWeight="Bold">[20/30] Installing Files</TextBlock> <TextBlock Grid.Row="0" x:Name="StatusText" FontSize="20" FontWeight="Bold">[20/30] Installing Files</TextBlock>
<ProgressBar Grid.Row="1" x:Name="StepsProgress" Maximum="1000" Value="40" /> <ProgressBar Grid.Row="1" x:Name="StepsProgress" Maximum="1000" Value="0" />
<ProgressBar Grid.Row="2" x:Name="StepProgress" Maximum="10000" Value="30" /> <ProgressBar Grid.Row="2" x:Name="StepProgress" Maximum="10000" Value="0" />
<Viewbox Grid.Row="3" HorizontalAlignment="Center" <Viewbox Grid.Row="3" HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Stretch="Uniform"> Stretch="Uniform">

View File

@ -1,5 +1,9 @@
using System;
using System.Reactive.Disposables; using System.Reactive.Disposables;
using System.Reactive.Linq;
using Avalonia.Threading;
using ReactiveUI; using ReactiveUI;
using Wabbajack.App.Extensions;
using Wabbajack.App.ViewModels; using Wabbajack.App.ViewModels;
namespace Wabbajack.App.Views; namespace Wabbajack.App.Views;
@ -27,14 +31,15 @@ public partial class StandardInstallationView : ScreenBase<StandardInstallationV
this.BindCommand(ViewModel, vm => vm.PlayCommand, view => view.PlaySlides) this.BindCommand(ViewModel, vm => vm.PlayCommand, view => view.PlaySlides)
.DisposeWith(disposables); .DisposeWith(disposables);
this.OneWayBind(ViewModel, vm => vm.StatusText, view => view.StatusText.Text) this.SimpleOneWayBind(ViewModel, vm => vm.StatusText, view => view.StatusText.Text)
.DisposeWith(disposables); .DisposeWith(disposables);
this.OneWayBind(ViewModel, vm => vm.StepsProgress, view => view.StepsProgress.Value, p => p.Value * 1000) this.SimpleOneWayBind(ViewModel, vm => vm.StepsProgress, view => view.StepsProgress.Value, p => p.Value * 1000)
.DisposeWith(disposables); .DisposeWith(disposables);
this.OneWayBind(ViewModel, vm => vm.StepProgress, view => view.StepProgress.Value, p => p.Value * 10000) this.SimpleOneWayBind(ViewModel, vm => vm.StepProgress, p => p.StepProgress.Value, p => p.Value * 10000)
.DisposeWith(disposables); .DisposeWith(disposables);
}); });
} }
} }

View File

@ -44,6 +44,7 @@ public class StandardInstallationViewModel : ViewModelBase
private IServiceScope _scope; private IServiceScope _scope;
private SlideViewModel[] _slides = Array.Empty<SlideViewModel>(); private SlideViewModel[] _slides = Array.Empty<SlideViewModel>();
private Timer _slideTimer; private Timer _slideTimer;
private Timer _updateTimer;
public StandardInstallationViewModel(ILogger<StandardInstallationViewModel> logger, IServiceProvider provider, public StandardInstallationViewModel(ILogger<StandardInstallationViewModel> logger, IServiceProvider provider,
GameLocator locator, DTOSerializer dtos, GameLocator locator, DTOSerializer dtos,
@ -63,6 +64,9 @@ public class StandardInstallationViewModel : ViewModelBase
this.WhenActivated(disposables => this.WhenActivated(disposables =>
{ {
_updateTimer = new Timer(UpdateStatus, null, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(250));
_updateTimer.DisposeWith(disposables);
_slideTimer = new Timer(_ => _slideTimer = new Timer(_ =>
{ {
if (IsPlaying) NextSlide(1); if (IsPlaying) NextSlide(1);
@ -103,11 +107,22 @@ public class StandardInstallationViewModel : ViewModelBase
[Reactive] public Percent StepsProgress { get; set; } = Percent.Zero; [Reactive] public Percent StepsProgress { get; set; } = Percent.Zero;
[Reactive] public Percent StepProgress { get; set; } = Percent.Zero; [Reactive] public Percent StepProgress { get; set; } = Percent.Zero;
// Not Reactive, so we don't end up spamming the UI threads with events
public StatusUpdate _latestStatus { get; set; } = new("", Percent.Zero, Percent.Zero);
public void Receive(StartInstallation msg) public void Receive(StartInstallation msg)
{ {
Install(msg).FireAndForget(); Install(msg).FireAndForget();
} }
private void UpdateStatus(object? state)
{
Dispatcher.UIThread.Post(() => {
StepsProgress = _latestStatus.StepsProgress;
StepProgress = _latestStatus.StepProgress;
StatusText = _latestStatus.StatusText;
});
}
private void NextSlide(int direction) private void NextSlide(int direction)
{ {
@ -175,21 +190,12 @@ public class StandardInstallationViewModel : ViewModelBase
_installer = _provider.GetService<StandardInstaller>()!; _installer = _provider.GetService<StandardInstaller>()!;
_installer.OnStatusUpdate = async update => _installer.OnStatusUpdate = update => _latestStatus = update;
{
Trace.TraceInformation("Update....");
await Dispatcher.UIThread.InvokeAsync(() =>
{
StatusText = update.StatusText;
StepsProgress = update.StepsProgress;
StepProgress = update.StepProgress;
}, DispatcherPriority.Background);
};
_logger.LogInformation("Installer created, starting the installation process"); _logger.LogInformation("Installer created, starting the installation process");
try try
{ {
var result = await _installer.Begin(CancellationToken.None); var result = await Task.Run(async () => await _installer.Begin(CancellationToken.None));
if (!result) throw new Exception("Installation failed"); if (!result) throw new Exception("Installation failed");
if (result) await SaveConfigAndContinue(_config); if (result) await SaveConfigAndContinue(_config);
@ -200,6 +206,7 @@ public class StandardInstallationViewModel : ViewModelBase
} }
} }
private async Task SaveConfigAndContinue(InstallerConfiguration config) private async Task SaveConfigAndContinue(InstallerConfiguration config)
{ {
var path = config.Install.Combine("modlist-image.png"); var path = config.Install.Combine("modlist-image.png");

View File

@ -67,7 +67,8 @@ public class FileExtractor
Predicate<RelativePath> shouldExtract, Predicate<RelativePath> shouldExtract,
Func<RelativePath, IExtractedFile, ValueTask<T>> mapfn, Func<RelativePath, IExtractedFile, ValueTask<T>> mapfn,
CancellationToken token, CancellationToken token,
HashSet<RelativePath>? onlyFiles = null) HashSet<RelativePath>? onlyFiles = null,
Action<Percent>? progressFunction = null)
{ {
if (sFn is NativeFileStreamFactory) _logger.LogInformation("Extracting {file}", sFn.Name); if (sFn is NativeFileStreamFactory) _logger.LogInformation("Extracting {file}", sFn.Name);
await using var archive = await sFn.GetStream(); await using var archive = await sFn.GetStream();
@ -92,7 +93,7 @@ public class FileExtractor
{ {
await using var tempFolder = _manager.CreateFolder(); await using var tempFolder = _manager.CreateFolder();
results = await GatheringExtractWith7Zip(sFn, shouldExtract, results = await GatheringExtractWith7Zip(sFn, shouldExtract,
mapfn, onlyFiles, token); mapfn, onlyFiles, token, progressFunction);
} }
break; break;
@ -179,7 +180,8 @@ public class FileExtractor
Predicate<RelativePath> shouldExtract, Predicate<RelativePath> shouldExtract,
Func<RelativePath, IExtractedFile, ValueTask<T>> mapfn, Func<RelativePath, IExtractedFile, ValueTask<T>> mapfn,
IReadOnlyCollection<RelativePath>? onlyFiles, IReadOnlyCollection<RelativePath>? onlyFiles,
CancellationToken token) CancellationToken token,
Action<Percent>? progressFunction = null)
{ {
TemporaryPath? tmpFile = null; TemporaryPath? tmpFile = null;
await using var dest = _manager.CreateFolder(); await using var dest = _manager.CreateFolder();
@ -262,6 +264,9 @@ public class FileExtractor
var newPosition = percentInt == 0 ? 0 : totalSize / percentInt; var newPosition = percentInt == 0 ? 0 : totalSize / percentInt;
var throughput = newPosition - oldPosition; var throughput = newPosition - oldPosition;
job.ReportNoWait((int) throughput); job.ReportNoWait((int) throughput);
progressFunction?.Invoke(Percent.FactoryPutInRange(lastPercent, 100));
lastPercent = percentInt; lastPercent = percentInt;
}, token); }, token);
@ -306,7 +311,7 @@ public class FileExtractor
} }
public async Task ExtractAll(AbsolutePath src, AbsolutePath dest, CancellationToken token, public async Task ExtractAll(AbsolutePath src, AbsolutePath dest, CancellationToken token,
Predicate<RelativePath>? filterFn = null) Predicate<RelativePath>? filterFn = null, Action<Percent>? updateProgress = null)
{ {
filterFn ??= _ => true; filterFn ??= _ => true;
await GatheringExtract(new NativeFileStreamFactory(src), filterFn, async (path, factory) => await GatheringExtract(new NativeFileStreamFactory(src), filterFn, async (path, factory) =>

View File

@ -51,11 +51,11 @@ public abstract class AInstaller<T>
private long _currentStepProgress; private long _currentStepProgress;
private long _maxStepProgress; protected long MaxStepProgress { get; set; }
private string _statusText; private string _statusText;
private readonly Stopwatch _updateStopWatch = new(); private readonly Stopwatch _updateStopWatch = new();
public Func<StatusUpdate, Task>? OnStatusUpdate; public Action<StatusUpdate>? OnStatusUpdate;
public AInstaller(ILogger<T> logger, InstallerConfiguration config, IGameLocator gameLocator, public AInstaller(ILogger<T> logger, InstallerConfiguration config, IGameLocator gameLocator,
@ -90,38 +90,30 @@ public abstract class AInstaller<T>
public async Task NextStep(string statusText, long maxStepProgress) public async Task NextStep(string statusText, long maxStepProgress)
{ {
_updateStopWatch.Restart(); _updateStopWatch.Restart();
_maxStepProgress = maxStepProgress; MaxStepProgress = maxStepProgress;
_currentStep += 1; _currentStep += 1;
_statusText = statusText; _statusText = statusText;
_logger.LogInformation("Next Step: {Step}", statusText); _logger.LogInformation("Next Step: {Step}", statusText);
if (OnStatusUpdate != null) OnStatusUpdate?.Invoke(new StatusUpdate($"[{_currentStep}/{MaxSteps}] " + statusText,
await OnStatusUpdate!(new StatusUpdate($"[{_currentStep}/{MaxSteps}] " + statusText, Percent.FactoryPutInRange(_currentStep, MaxSteps), Percent.Zero));
Percent.FactoryPutInRange(_currentStep, MaxSteps), Percent.Zero));
} }
public async ValueTask UpdateProgress(long stepProgress) public void UpdateProgress(long stepProgress)
{ {
Interlocked.Add(ref _currentStepProgress, stepProgress); Interlocked.Add(ref _currentStepProgress, stepProgress);
if (_updateStopWatch.ElapsedMilliseconds < _limitMS) return; OnStatusUpdate?.Invoke(new StatusUpdate($"[{_currentStep}/{MaxSteps}] " + _statusText, Percent.FactoryPutInRange(_currentStep, MaxSteps),
lock (_updateStopWatch) Percent.FactoryPutInRange(_currentStepProgress, MaxStepProgress)));
{
if (_updateStopWatch.ElapsedMilliseconds < _limitMS) return;
_updateStopWatch.Restart();
}
if (OnStatusUpdate != null)
await OnStatusUpdate!(new StatusUpdate(_statusText, Percent.FactoryPutInRange(_currentStep, MaxSteps),
Percent.FactoryPutInRange(_currentStepProgress, _maxStepProgress)));
} }
public abstract Task<bool> Begin(CancellationToken token); public abstract Task<bool> Begin(CancellationToken token);
public async Task ExtractModlist(CancellationToken token) public async Task ExtractModlist(CancellationToken token)
{ {
await NextStep("Extracting Modlist", 100);
ExtractedModlistFolder = _manager.CreateFolder(); ExtractedModlistFolder = _manager.CreateFolder();
await _extractor.ExtractAll(_configuration.ModlistArchive, ExtractedModlistFolder, token); await _extractor.ExtractAll(_configuration.ModlistArchive, ExtractedModlistFolder, token, updateProgress: p => UpdateProgress((long)(p.Value * 100)));
} }
public async Task<byte[]> LoadBytesFromPath(RelativePath path) public async Task<byte[]> LoadBytesFromPath(RelativePath path)
@ -165,13 +157,15 @@ public abstract class AInstaller<T>
/// </summary> /// </summary>
protected async Task PrimeVFS() protected async Task PrimeVFS()
{ {
await NextStep("Priming VFS", 0);
_vfs.AddKnown(_configuration.ModList.Directives.OfType<FromArchive>().Select(d => d.ArchiveHashPath), _vfs.AddKnown(_configuration.ModList.Directives.OfType<FromArchive>().Select(d => d.ArchiveHashPath),
HashedArchives); HashedArchives);
await _vfs.BackfillMissing(); await _vfs.BackfillMissing();
} }
public void BuildFolderStructure() public async Task BuildFolderStructure()
{ {
await NextStep("Building Folder Structure", 0);
_logger.LogInformation("Building Folder Structure"); _logger.LogInformation("Building Folder Structure");
ModList.Directives ModList.Directives
.Where(d => d.To.Depth > 1) .Where(d => d.To.Depth > 1)
@ -196,7 +190,7 @@ public abstract class AInstaller<T>
foreach (var directive in grouped[vf]) foreach (var directive in grouped[vf])
{ {
var file = directive.Directive; var file = directive.Directive;
await UpdateProgress(file.Size); UpdateProgress(file.Size);
switch (file) switch (file)
{ {
@ -305,7 +299,7 @@ public abstract class AInstaller<T>
} }
await DownloadArchive(archive, download, token, outputPath); await DownloadArchive(archive, download, token, outputPath);
await UpdateProgress(1); UpdateProgress(1);
}); });
} }
@ -345,6 +339,7 @@ public abstract class AInstaller<T>
public async Task HashArchives(CancellationToken token) public async Task HashArchives(CancellationToken token)
{ {
await NextStep("Hashing Archives", 0);
_logger.LogInformation("Looking for files to hash"); _logger.LogInformation("Looking for files to hash");
var allFiles = _configuration.Downloads.EnumerateFiles() var allFiles = _configuration.Downloads.EnumerateFiles()
@ -356,12 +351,18 @@ public abstract class AInstaller<T>
var toHash = ModList.Archives.Where(a => hashDict.ContainsKey(a.Size)) var toHash = ModList.Archives.Where(a => hashDict.ContainsKey(a.Size))
.SelectMany(a => hashDict[a.Size]).ToList(); .SelectMany(a => hashDict[a.Size]).ToList();
MaxStepProgress = toHash.Count;
_logger.LogInformation("Found {count} total files, {hashedCount} matching filesize", allFiles.Count, _logger.LogInformation("Found {count} total files, {hashedCount} matching filesize", allFiles.Count,
toHash.Count); toHash.Count);
var hashResults = await var hashResults = await
toHash toHash
.PMapAll(async e => (await _fileHashCache.FileHashCachedAsync(e, token), e)) .PMapAll(async e =>
{
UpdateProgress(1);
return (await _fileHashCache.FileHashCachedAsync(e, token), e);
})
.ToList(); .ToList();
HashedArchives = hashResults HashedArchives = hashResults
@ -393,7 +394,7 @@ public abstract class AInstaller<T>
await existingFiles await existingFiles
.PDoAll(async f => .PDoAll(async f =>
{ {
await UpdateProgress(1); UpdateProgress(1);
var relativeTo = f.RelativeTo(_configuration.Install); var relativeTo = f.RelativeTo(_configuration.Install);
if (indexed.ContainsKey(relativeTo) || f.InFolder(_configuration.Downloads)) if (indexed.ContainsKey(relativeTo) || f.InFolder(_configuration.Downloads))
return; return;
@ -409,11 +410,11 @@ public abstract class AInstaller<T>
_logger.LogInformation("Cleaning empty folders"); _logger.LogInformation("Cleaning empty folders");
await NextStep("Optimizing Modlist: Cleaning empty folders", indexed.Keys.Count); await NextStep("Optimizing Modlist: Cleaning empty folders", indexed.Keys.Count);
var expectedFolders = (await indexed.Keys var expectedFolders = (indexed.Keys
.Select(f => f.RelativeTo(_configuration.Install)) .Select(f => f.RelativeTo(_configuration.Install))
// We ignore the last part of the path, so we need a dummy file name // We ignore the last part of the path, so we need a dummy file name
.Append(_configuration.Downloads.Combine("_")) .Append(_configuration.Downloads.Combine("_"))
.OnEach(async _ => await UpdateProgress(1)) .OnEach(_ => UpdateProgress(1))
.Where(f => f.InFolder(_configuration.Install)) .Where(f => f.InFolder(_configuration.Install))
.SelectMany(path => .SelectMany(path =>
{ {
@ -446,7 +447,7 @@ public abstract class AInstaller<T>
await NextStep("Optimizing Modlist: Removing redundant directives", indexed.Count); await NextStep("Optimizing Modlist: Removing redundant directives", indexed.Count);
await indexed.Values.PMapAll<Directive, Directive?>(async d => await indexed.Values.PMapAll<Directive, Directive?>(async d =>
{ {
await UpdateProgress(1); UpdateProgress(1);
// Bit backwards, but we want to return null for // Bit backwards, but we want to return null for
// all files we *want* installed. We return the files // all files we *want* installed. We return the files
// to remove from the install list. // to remove from the install list.
@ -462,6 +463,7 @@ public abstract class AInstaller<T>
_logger.LogInformation("Optimized {optimized} directives to {indexed} required", ModList.Directives.Length, _logger.LogInformation("Optimized {optimized} directives to {indexed} required", ModList.Directives.Length,
indexed.Count); indexed.Count);
await NextStep("Finalizing modlist optimization", 0);
var requiredArchives = indexed.Values.OfType<FromArchive>() var requiredArchives = indexed.Values.OfType<FromArchive>()
.GroupBy(d => d.ArchiveHashPath.Hash) .GroupBy(d => d.ArchiveHashPath.Hash)
.Select(d => d.Key) .Select(d => d.Key)

View File

@ -38,13 +38,14 @@ public class StandardInstaller : AInstaller<StandardInstaller>
base(logger, config, gameLocator, extractor, jsonSerializer, vfs, fileHashCache, downloadDispatcher, base(logger, config, gameLocator, extractor, jsonSerializer, vfs, fileHashCache, downloadDispatcher,
parallelOptions, wjClient) parallelOptions, wjClient)
{ {
MaxSteps = 7; MaxSteps = 13;
} }
public override async Task<bool> Begin(CancellationToken token) public override async Task<bool> Begin(CancellationToken token)
{ {
if (token.IsCancellationRequested) return false; if (token.IsCancellationRequested) return false;
await _wjClient.SendMetric(MetricNames.BeginInstall, ModList.Name); await _wjClient.SendMetric(MetricNames.BeginInstall, ModList.Name);
await NextStep("Configuring Installer", 0);
_logger.LogInformation("Configuring Processor"); _logger.LogInformation("Configuring Processor");
if (_configuration.GameFolder == default) if (_configuration.GameFolder == default)
@ -85,7 +86,6 @@ public class StandardInstaller : AInstaller<StandardInstaller>
await OptimizeModlist(token); await OptimizeModlist(token);
await HashArchives(token); await HashArchives(token);
await DownloadArchives(token); await DownloadArchives(token);
@ -264,7 +264,7 @@ public class StandardInstaller : AInstaller<StandardInstaller>
.OfType<InlineFile>() .OfType<InlineFile>()
.PDoAll(async directive => .PDoAll(async directive =>
{ {
await UpdateProgress(1); UpdateProgress(1);
var outPath = _configuration.Install.Combine(directive.To); var outPath = _configuration.Install.Combine(directive.To);
outPath.Delete(); outPath.Delete();

View File

@ -15,7 +15,7 @@
<PackageReference Include="Avalonia.Desktop" Version="0.10.10" /> <PackageReference Include="Avalonia.Desktop" Version="0.10.10" />
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.10" /> <PackageReference Include="Avalonia.Diagnostics" Version="0.10.10" />
<PackageReference Include="Avalonia.ReactiveUI" Version="0.10.10" /> <PackageReference Include="Avalonia.ReactiveUI" Version="0.10.10" />
<PackageReference Include="ReactiveUI.Fody" Version="16.2.6" /> <PackageReference Include="ReactiveUI.Fody" Version="16.3.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Wabbajack.Paths.IO\Wabbajack.Paths.IO.csproj" /> <ProjectReference Include="..\Wabbajack.Paths.IO\Wabbajack.Paths.IO.csproj" />