diff --git a/PD2Launcherv2/App.xaml b/PD2Launcherv2/App.xaml
index 3ea55f7d..b2a42ae6 100644
--- a/PD2Launcherv2/App.xaml
+++ b/PD2Launcherv2/App.xaml
@@ -10,17 +10,13 @@
-
-
- pack://application:,,,/Resources/Fonts/exocet-blizzard-heavy.otf#Exocet Blizzard OT Light Bold
+ pack://application:,,,/Resources/Fonts/exocet-blizzard-heavy.ttf#Exocet Blizzard OT Light Bold
pack://application:,,,/Resources/Fonts/exocet-blizzard-light.ttf#Exocet Blizzard OT Light Regular
pack://application:,,,/Resources/Fonts/exocet-blizzard-medium.ttf#Exocet Blizzard OT Medium Regular
diff --git a/PD2Launcherv2/App.xaml.cs b/PD2Launcherv2/App.xaml.cs
index eb007f6a..90adfe7b 100644
--- a/PD2Launcherv2/App.xaml.cs
+++ b/PD2Launcherv2/App.xaml.cs
@@ -10,6 +10,13 @@
using System.Net.Http;
using System.Text;
using System.Windows;
+using PD2Launcherv2.Utils;
+using PD2Shared.GameFileUpdate;
+using PD2Shared.Logging;
+using static PD2Shared.Logging.LoggingStatic;
+using PD2Shared.Utils;
+
+[assembly: System.Runtime.CompilerServices.RuntimeCompatibilityAttribute(WrapNonExceptionThrows = true)]
namespace PD2Launcherv2
{
@@ -19,6 +26,8 @@ namespace PD2Launcherv2
///
public partial class App : Application
{
+ private readonly Stopwatch _bootStopwatch = Stopwatch.StartNew();
+
///
/// Holds the service provider for dependency injection.
///
@@ -58,7 +67,7 @@ private static void ConfigureServices(ServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton(provider =>
new FileUpdateHelpers( provider.GetRequiredService()));
services.AddTransient();
@@ -98,7 +107,7 @@ protected override async void OnStartup(StartupEventArgs e)
try
{
- if (Process.GetProcessesByName("Game").Any())
+ if (LaunchGameHelpers.IsGameRunning)
{
Debug.WriteLine("Game already running.");
return;
@@ -110,8 +119,8 @@ protected override async void OnStartup(StartupEventArgs e)
await filterHelpers.CheckAndUpdateFilterAsync(selected);
}
- var launcherArgs = localStorage.LoadSection(StorageKey.LauncherArgs);
- if (!launcherArgs.disableAutoUpdate)
+ var launcherOptions = localStorage.LoadSection(StorageKey.LauncherOptions);
+ if (!launcherOptions.DisableAutoUpdate)
{
try
{
@@ -136,23 +145,107 @@ protected override async void OnStartup(StartupEventArgs e)
return;
}
+ // This is a bit meh, but let's keep the convention and make this case-insensitive
+ var createConsole = e.Args.Any(arg => arg.Equals("--console", StringComparison.OrdinalIgnoreCase));
+
+ // This is not expected to throw
+ Logging.SetUp(createConsole);
+
+ if (Wine.IsRunningUnderWine)
+ {
+ if (Wine.Version != null)
+ {
+ string wineVersionStr = Wine.Version.ToString();
+
+ if (Wine.BuildId != null)
+ {
+ wineVersionStr += $" ({Wine.BuildId})";
+ }
+
+ if (Wine.OsName != null)
+ {
+ wineVersionStr += $" on {Wine.OsName}";
+
+ if (Wine.OsRelease != null)
+ {
+ wineVersionStr += $" {Wine.OsRelease}";
+ }
+ }
+
+ L.CallerInformation($"Running under Wine {wineVersionStr}");
+ }
+ else
+ {
+ L.CallerInformation($"Running under an undetermined Wine version");
+ }
+ }
+
+ L.CallerInformation($"Using up to {Environment.ProcessorCount} concurrent task(s)");
+
+ if (!SanityChecks.Run())
+ {
+ L.CallerInformation($"Sanity checks failed and user declined to continue.");
+
+ this.Shutdown(1);
+ return;
+ }
+
// Normal UI mode
base.OnStartup(e);
var mainWindow = _serviceProvider.GetService();
+
+ if (mainWindow != null)
+ {
+ mainWindow.ContentRendered += MainWindow_ContentRendered;
+ }
+
mainWindow?.Show();
}
+ private void MainWindow_ContentRendered(object? sender, EventArgs e)
+ {
+ ((MainWindow)sender!).ContentRendered -= MainWindow_ContentRendered;
+
+ L.CallerDebug($"Time till {nameof(MainWindow)} rendered: {_bootStopwatch.Elapsed}.");
+ }
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ Logging.ShutDown(e.ApplicationExitCode);
+
+ base.OnExit(e);
+ }
+
// Handle non-UI thread exceptions
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
- LogException(e.ExceptionObject as Exception);
+ // Blindly cast the object to Exception thanks to RuntimeCompatibilityAttribute (https://learn.microsoft.com/en-us/dotnet/api/system.unhandledexceptioneventargs.exceptionobject#remarks)
+ var ex = (Exception)e.ExceptionObject;
+
+ L.Fatal(ex, "Unhandled exception");
+
+ this.Dispatcher.Invoke(() =>
+ {
+ MsgBox.Exception(ex, "Unhandled exception:");
+
+ // Unlike DispatcherUnhandledException, WER will still kick in.
+ // However, due to reliance on task asynchronous programming model, it is very unlikely that this handler will ever be used.
+ this.Shutdown(1);
+ });
}
// Handle UI thread exceptions
private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
- LogException(e.Exception);
+ var ex = e.Exception;
+
+ L.Fatal(ex, "Unhandled exception");
+ MsgBox.Exception(ex, "Unhandled exception:");
+
e.Handled = true; // Prevent application from crashing
+
+ // ...yet shut it down on our own terms now, knowing that we have prevented Windows Error Reporting from kicking in.
+ this.Shutdown(1);
}
private void CleanUpTempStorageFiles()
@@ -174,47 +267,5 @@ private void CleanUpTempStorageFiles()
}
}
}
-
- private void LogException(Exception ex)
- {
- if (ex == null) return;
-
- string logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory);
-
- string logFile = Path.Combine(logPath, $"pd2launcher_error__{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt");
-
- // Use StackTrace to get more detailed info about where the exception occurred
- var stackTrace = new StackTrace(ex, true);
- var frame = stackTrace.GetFrames()?.FirstOrDefault();
- var method = frame?.GetMethod();
- var declaringType = method?.DeclaringType;
- var methodName = method?.Name;
-
- // Prepare the log entry
- var sb = new StringBuilder();
- sb.AppendLine("==============================================================================");
- sb.AppendLine($"Timestamp: {DateTime.Now}");
- sb.AppendLine("Exception Class:");
- sb.AppendLine(declaringType?.FullName ?? "N/A");
- sb.AppendLine("Exception Method:");
- sb.AppendLine($"{methodName ?? "N/A"}");
- sb.AppendLine("Exception Message:");
- sb.AppendLine(ex.Message);
- sb.AppendLine("Stack Trace:");
- sb.AppendLine(ex.StackTrace);
-
- // Include inner exception details if available
- if (ex.InnerException != null)
- {
- sb.AppendLine("Inner Exception Message:");
- sb.AppendLine(ex.InnerException.Message);
- sb.AppendLine("Inner Exception Stack Trace:");
- sb.AppendLine(ex.InnerException.StackTrace);
- }
- sb.AppendLine("==============================================================================");
-
- // Append the log entry to the file
- File.AppendAllText(logFile, sb.ToString());
- }
}
}
\ No newline at end of file
diff --git a/PD2Launcherv2/Constants.cs b/PD2Launcherv2/Constants.cs
deleted file mode 100644
index 0585acc7..00000000
--- a/PD2Launcherv2/Constants.cs
+++ /dev/null
@@ -1,145 +0,0 @@
-using PD2Shared.Models;
-
-namespace ProjectDiablo2Launcherv2
-{
- public static class Constants
- {
- public static readonly List excludedFiles = new List
- {
- "D2.LNG", "BnetLog.txt", "ProjectDiablo.cfg", "ddraw.ini", "default.filter", "loot.filter", "UI.ini", "d2gl.yaml",
- };
-
- public static class Ddraw
- {
- public const string IniFileName = "ddraw.ini";
-
- public static DdrawOptions DefaultDdrawOptions => new()
- {
- Fullscreen = true,
- MaxFps = "60",
- Shader = "Shaders\\xbr\\xbr-lv2-noblend.glsl",
- PosX = -32000,
- PosY = -32000,
- Renderer = "opengl",
- SaveSettings = "1",
- Resizeable = true,
- MaxGameTicks = "-2",
- HandleMouse = true,
- Hook = "4",
- MinFps = "0",
- SingleCpu = true
- };
- }
-
- public static class LocalStorage
- {
- public const string LauncherArgsKey = "LauncherArgs";
- public const string DdrawOptionsKey = "DdrawOptions";
- public const string FileUpdateModelKey = "FileUpdateModel";
- public const string AuthorAndFilterKey = "AuthorAndFilterKey";
- }
-
- public static List MaxFpsPickerItems()
- {
- return new List
- {
- new DisplayValuePair { DisplayValue = "60 (default)", ActualValue = "60" },
- new DisplayValuePair { DisplayValue = "-1 (screen rate)", ActualValue = "-1" },
- new DisplayValuePair { DisplayValue = "unlimited", ActualValue = "0" },
- new DisplayValuePair { DisplayValue = "n = cap", ActualValue = "n" }
- };
- }
-
- public static List ModePickerItems()
- {
- return new List
- {
- new DisplayValuePair { DisplayValue = "Fullscreen (default)", ActualValue = "fullscreen" },
- new DisplayValuePair { DisplayValue = "Windowed", ActualValue = "windowed" },
- new DisplayValuePair { DisplayValue = "Borderless", ActualValue = "borderless" }
- };
- }
-
- public static List MaxGameTicksPickerItems()
- {
- return new List
- {
- new DisplayValuePair { DisplayValue = "-2 Default Refresh Rate", ActualValue = "-2" },
- new DisplayValuePair { DisplayValue = "-1 (disable)", ActualValue = "-1" },
- new DisplayValuePair { DisplayValue = "0 emulate 60hz", ActualValue = "0" },
- new DisplayValuePair { DisplayValue = "60", ActualValue = "60" },
- new DisplayValuePair { DisplayValue = "30", ActualValue = "30" },
- new DisplayValuePair { DisplayValue = "25", ActualValue = "25" },
- new DisplayValuePair { DisplayValue = "20", ActualValue = "20" },
- new DisplayValuePair { DisplayValue = "15", ActualValue = "15" }
- };
- }
-
- public static List SaveWindowPositionPickerItems()
- {
- return new List
- {
- new DisplayValuePair { DisplayValue = "global (default)", ActualValue = "1" },
- new DisplayValuePair { DisplayValue = "game specific", ActualValue = "2" },
- new DisplayValuePair { DisplayValue = "disable", ActualValue = "0" }
- };
- }
-
- public static List RendererPickerItems()
- {
- return new List
- {
- new DisplayValuePair { DisplayValue = "OpenGL (default)", ActualValue = "opengl" },
- new DisplayValuePair { DisplayValue = "Auto", ActualValue = "auto" },
- new DisplayValuePair { DisplayValue = "direct3d9", ActualValue = "direct3d9" },
- new DisplayValuePair { DisplayValue = "GDI", ActualValue = "gdi" }
- };
- }
-
- public static List HookPickerItems()
- {
- return new List
- {
- new DisplayValuePair { DisplayValue = "all modules (default)", ActualValue = "4" },
- new DisplayValuePair { DisplayValue = "IAT hooking", ActualValue = "1" },
- new DisplayValuePair { DisplayValue = "microsoft detours", ActualValue = "2" },
- new DisplayValuePair { DisplayValue = "IAT+detours", ActualValue = "3" },
- new DisplayValuePair { DisplayValue = "disable", ActualValue = "0" }
- };
- }
-
- public static List MinFpsPickerItems()
- {
- return new List
- {
- new DisplayValuePair { DisplayValue = "0 (default)", ActualValue = "0" },
- new DisplayValuePair { DisplayValue = "-1 (use max fps)", ActualValue = "-1" },
- new DisplayValuePair { DisplayValue = "5", ActualValue = "5" },
- new DisplayValuePair { DisplayValue = "10", ActualValue = "10" }
- };
- }
-
- public static List ShaderPickerItems()
- {
- return new List
- {
- new DisplayValuePair { DisplayValue = "xbr-lv2-noblend (default)", ActualValue = "Shaders\\xbr\\xbr-lv2-noblend.glsl" },
- new DisplayValuePair { DisplayValue = "xbr-lv2", ActualValue = "Shaders\\xbr-lv2.glsl" },
- new DisplayValuePair { DisplayValue = "xbr-lv3", ActualValue = "Shaders\\xbr\\xbr-lv3.glsl" },
- new DisplayValuePair { DisplayValue = "xbrz-freescale", ActualValue = "Shaders\\xbrz-freescale.glsl" },
- new DisplayValuePair { DisplayValue = "4xbrz", ActualValue = "Shaders\\xbrz\\4xbrz.glsl" },
- new DisplayValuePair { DisplayValue = "5xbrz", ActualValue = "Shaders\\xbrz\\5xbrz.glsl" },
- new DisplayValuePair { DisplayValue = "6xbrz", ActualValue = "Shaders\\xbrz\\6xbrz.glsl" },
- new DisplayValuePair { DisplayValue = "aa-shader-4.0", ActualValue = "Shaders\\anti-aliasing\\aa-shader-4.0.glsl" },
- new DisplayValuePair { DisplayValue = "advanced-aa", ActualValue = "Shaders\\anti-aliasing\\advanced-aa.glsl" },
- new DisplayValuePair { DisplayValue = "reverse-aa", ActualValue = "Shaders\\anti-aliasing\\reverse-aa.glsl" },
- new DisplayValuePair { DisplayValue = "bilinear", ActualValue = "Shaders\\bilinear.glsl" },
- new DisplayValuePair { DisplayValue = "bright", ActualValue = "Shaders\\bright.glsl" },
- new DisplayValuePair { DisplayValue = "crt-lottes-fast", ActualValue = "Shaders\\crt-lottes-fast.glsl" },
- new DisplayValuePair { DisplayValue = "cubic", ActualValue = "Shaders\\cubic.glsl" },
- new DisplayValuePair { DisplayValue = "scanline", ActualValue = "Shaders\\scanline.glsl" },
- new DisplayValuePair { DisplayValue = "simple-sharp-bilinear", ActualValue = "Shaders\\simple-sharp-bilinear.glsl" }
- };
- }
- }
-}
\ No newline at end of file
diff --git a/PD2Launcherv2/CustomControl/BlankButtonWithText.cs b/PD2Launcherv2/CustomControl/BlankButtonWithText.cs
new file mode 100644
index 00000000..a62c4912
--- /dev/null
+++ b/PD2Launcherv2/CustomControl/BlankButtonWithText.cs
@@ -0,0 +1,44 @@
+using System.Windows;
+using System.Windows.Controls;
+
+namespace PD2Launcherv2.CustomControl
+{
+ public class BlankButtonWithText : Button
+ {
+ public enum ButtonKindEnum
+ {
+ Normal,
+ SplitTop,
+ SplitBottom
+ }
+
+ static BlankButtonWithText()
+ {
+ DefaultStyleKeyProperty.OverrideMetadata(typeof(BlankButtonWithText), new FrameworkPropertyMetadata(typeof(BlankButtonWithText)));
+ }
+
+ public static readonly DependencyProperty TextProperty = DependencyProperty.Register(
+ "Text",
+ typeof(string),
+ typeof(BlankButtonWithText),
+ new PropertyMetadata("..."));
+
+ public static readonly DependencyProperty ButtonKindProperty = DependencyProperty.Register(
+ "ButtonKind",
+ typeof(ButtonKindEnum),
+ typeof(BlankButtonWithText),
+ new PropertyMetadata(ButtonKindEnum.Normal));
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public ButtonKindEnum ButtonKind
+ {
+ get => (ButtonKindEnum)GetValue(ButtonKindProperty);
+ set => SetValue(ButtonKindProperty, value);
+ }
+ }
+}
diff --git a/PD2Launcherv2/CustomControl/CustomImageButton.cs b/PD2Launcherv2/CustomControl/CustomImageButton.cs
index 74e543f8..1bb06df4 100644
--- a/PD2Launcherv2/CustomControl/CustomImageButton.cs
+++ b/PD2Launcherv2/CustomControl/CustomImageButton.cs
@@ -1,11 +1,36 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
+using System.Windows.Media.Imaging;
namespace PD2Launcherv2.CustomControl
{
public class CustomImageButton : Button
{
+ private static RenderTargetBitmap _missingImage = null!;
+ private static BitmapSource MissingImage
+ {
+ get
+ {
+ if (_missingImage == null)
+ {
+ Rect imageSize = new(new Size(100, 100));
+
+ _missingImage = new RenderTargetBitmap((int)imageSize.Width, (int)imageSize.Height, 96, 96, PixelFormats.Pbgra32);
+
+ DrawingVisual visual = new();
+ using (DrawingContext context = visual.RenderOpen())
+ {
+ context.DrawRectangle(Brushes.Magenta, pen: null, imageSize);
+ }
+
+ _missingImage.Render(visual);
+ }
+
+ return _missingImage;
+ }
+ }
+
static CustomImageButton()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(CustomImageButton), new FrameworkPropertyMetadata(typeof(CustomImageButton)));
@@ -23,6 +48,12 @@ static CustomImageButton()
typeof(CustomImageButton),
new PropertyMetadata(default(ImageSource)));
+ public static readonly DependencyProperty DisabledImageSourceProperty = DependencyProperty.Register(
+ "DisabledImageSource",
+ typeof(ImageSource),
+ typeof(CustomImageButton),
+ new PropertyMetadata(MissingImage));
+
public ImageSource NormalImageSource
{
get => (ImageSource)GetValue(NormalImageSourceProperty);
@@ -34,5 +65,11 @@ public ImageSource PressedImageSource
get => (ImageSource)GetValue(PressedImageSourceProperty);
set => SetValue(PressedImageSourceProperty, value);
}
+
+ public ImageSource DisabledImageSource
+ {
+ get => (ImageSource)GetValue(DisabledImageSourceProperty);
+ set => SetValue(DisabledImageSourceProperty, value);
+ }
}
}
\ No newline at end of file
diff --git a/PD2Launcherv2/Helpers/DDrawHelpers.cs b/PD2Launcherv2/Helpers/DDrawHelpers.cs
index 34b5700e..95a6bbbb 100644
--- a/PD2Launcherv2/Helpers/DDrawHelpers.cs
+++ b/PD2Launcherv2/Helpers/DDrawHelpers.cs
@@ -1,8 +1,8 @@
using MadMilkman.Ini;
+using PD2Shared;
using PD2Shared.Interfaces;
using PD2Shared.Models;
-using ProjectDiablo2Launcherv2;
using System.Diagnostics;
using System.IO;
diff --git a/PD2Launcherv2/MainWindow.xaml b/PD2Launcherv2/MainWindow.xaml
index 9d00b23c..b8a758d2 100644
--- a/PD2Launcherv2/MainWindow.xaml
+++ b/PD2Launcherv2/MainWindow.xaml
@@ -2,19 +2,31 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:PD2Launcherv2.CustomControl"
- Title="Project Diablo 2 Launcher" Height="600" Width="800"
- WindowStyle="None" AllowsTransparency="True" Background="Transparent">
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ mc:Ignorable="d"
+ Style="{StaticResource PixelPerfectFrameworkElement}"
+ Title="Project Diablo 2 Launcher"
+ WindowStyle="None" AllowsTransparency="True" Background="Transparent"
+ ResizeMode="NoResize"
+ SizeToContent="WidthAndHeight"
+ Closing="Window_Closing"
+ KeyDown="Window_KeyDown"
+ KeyUp="Window_KeyUp"
+ IsKeyboardFocusWithinChanged="Window_IsKeyboardFocusWithinChanged"
+ Activated="Window_Activated"
+ Icon="pack://application:,,,/Resources/Icons/icon.ico">
-
@@ -26,49 +38,50 @@
-
-
-
-
+
+
+ Home
+
-
+
+
-
-
-
-
+
+
+ Trade
+
-
+
-
-
-
-
-
-
+
+
+ Reddit
+
+
+
-
-
-
-
+
+
+ Twitter
+
-
+
-
-
-
-
-
-
+
+
+ Discord
+
+
+
-
-
-
-
+
+
+ Wiki
+
@@ -126,75 +139,127 @@
+ Click="MinimizeButton_Click"
+ TabIndex="6" />
+ DisabledImageSource="pack://application:,,,/Resources/Images/close_disabled.png"
+ Click="CloseButton_Click"
+ TabIndex="7"/>
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
+
@@ -205,28 +270,42 @@
FontFamily="{StaticResource BlizzLight}"/>
-
-
+
+
+ Source="pack://application:,,,/Resources/Images/btn_beta.jpg"
+ Visibility="{Binding BetaVisibility}"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"/>
+
+
-
+ Margin="5,0,0,0"
+ NormalImageSource="pack://application:,,,/Resources/Images/btn_switch.jpg"
+ PressedImageSource="pack://application:,,,/Resources/Images/btn_switch_pressed.jpg"
+ DisabledImageSource="pack://application:,,,/Resources/Images/btn_switch_disabled.png"
+ Command="{Binding OpenAboutCommand}"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ TabIndex="5"/>
-
@@ -236,14 +315,19 @@
+
+
+
+
+
+
+
-
+
diff --git a/PD2Launcherv2/MainWindow.xaml.cs b/PD2Launcherv2/MainWindow.xaml.cs
index 13c4a325..98db9ece 100644
--- a/PD2Launcherv2/MainWindow.xaml.cs
+++ b/PD2Launcherv2/MainWindow.xaml.cs
@@ -9,15 +9,22 @@
using PD2Launcherv2.Views;
using System.ComponentModel;
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net.Http;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
-using System.Windows.Media.Imaging;
+using System.Windows.Media;
using System.Windows.Navigation;
using System.Windows.Threading;
using System.IO;
+using PD2Launcherv2.Utils;
+using PD2Launcherv2.Utils.Gl;
+using PD2Shared.GameFileUpdate;
+using PD2Shared.Logging;
+using static PD2Shared.Logging.LoggingStatic;
+using PD2Shared.Utils;
namespace PD2Launcherv2
{
@@ -26,6 +33,16 @@ namespace PD2Launcherv2
/// .
public partial class MainWindow : Window, INotifyPropertyChanged
{
+ private enum KeyComboDown
+ {
+ Play,
+
+ Update,
+ Restore,
+ Download,
+ Reset
+ }
+
public event PropertyChangedEventHandler PropertyChanged;
private readonly ILocalStorage _localStorage;
private readonly FileUpdateHelpers _fileUpdateHelpers;
@@ -33,7 +50,45 @@ public partial class MainWindow : Window, INotifyPropertyChanged
private readonly LaunchGameHelpers _launchGameHelpers;
private readonly NewsHelpers _newsHelpers;
private readonly DDrawHelpers _dDrawHelpers;
- private readonly GameFileUpdateHelpers _gameFileUpdater;
+ private readonly GameFileUpdater _gameFileUpdater;
+
+ private CancellationTokenSource? _currentCts = null;
+ private bool _cancellingAllowed = false;
+ private bool _closePending = false;
+ private bool _closePendingAllowClose = false;
+
+ private bool _isOffline;
+
+ private KeyComboDown _keyComboDown = KeyComboDown.Play;
+
+ private readonly ProgressCookie _progressCookie = new();
+
+ TextBlock? _progressTotalText = null;
+ TextBlock? _progressFileCountText = null;
+ TextBlock? _progressBytesText = null;
+ TextBlock? _progressBytesPerSecText = null;
+ int _progressBytesPrecision;
+
+ private string _playButtonText;
+ private bool _playButtonTextLocked;
+ private bool _progressErrorShown;
+
+ private readonly Brush NormalTextBrush;
+ private readonly Brush ErrorTextBrush;
+
+ private bool _suppressRendererChangedMessages = false;
+
+ // Auto-close
+ private static readonly TimeSpan AutoCloseTimeSpan = TimeSpan.FromSeconds(10);
+
+ private readonly object _autoCloseLock = new();
+ private bool _autoCloseActive = false;
+ private Process? _autoCloseGameProcess = null;
+ private readonly EventWaitHandle _autoCloseThreadInterrupt = new(initialState: false, EventResetMode.ManualReset);
+ private Thread? _autoCloseThread = null;
+ private readonly Stopwatch _autoCloseProgressUpdateStopwatch = new();
+ private DispatcherTimer? _autoCloseProgressUpdateTimer = null;
+
private bool _isBeta;
public bool IsBeta
{
@@ -93,6 +148,65 @@ public Visibility CustomVisibility
}
}
+ private bool _forceSoftwareRenderer;
+ public bool ForceSoftwareRenderer
+ {
+ get => _forceSoftwareRenderer;
+ set
+ {
+ if (_forceSoftwareRenderer != value)
+ {
+ _forceSoftwareRenderer = value;
+ System.Windows.Media.RenderOptions.ProcessRenderMode = _forceSoftwareRenderer ? System.Windows.Interop.RenderMode.SoftwareOnly : System.Windows.Interop.RenderMode.Default;
+ OnPropertyChanged(nameof(ForceSoftwareRenderer));
+ }
+ }
+ }
+
+ private bool _useHttp2;
+ public bool UseHttp2
+ {
+ get => _useHttp2;
+ set
+ {
+ if (_useHttp2 != value)
+ {
+ _useHttp2 = value;
+ OnPropertyChanged(nameof(UseHttp2));
+ }
+ }
+ }
+
+ private bool _isDisableUpdates;
+ public bool IsDisableUpdates
+ {
+ get => _isDisableUpdates;
+ set
+ {
+ if (_isDisableUpdates != value)
+ {
+ _isDisableUpdates = value;
+ UpdatesNotificationVisibility = value ? Visibility.Visible : Visibility.Collapsed;
+ OnPropertyChanged(nameof(IsDisableUpdates));
+ }
+ }
+ }
+
+ private bool _autoCloseAfterLaunch;
+ public bool AutoCloseAfterLaunch
+ {
+ get => _autoCloseAfterLaunch;
+ set
+ {
+ if (_autoCloseAfterLaunch != value)
+ {
+ _autoCloseAfterLaunch = value;
+ AutoCloseCheckBox.IsChecked = _autoCloseAfterLaunch;
+ OnPropertyChanged(nameof(AutoCloseAfterLaunch));
+ }
+ }
+ }
+
private Visibility _updatesNotificationVisibility = Visibility.Collapsed;
public Visibility UpdatesNotificationVisibility
{
@@ -108,7 +222,6 @@ public Visibility UpdatesNotificationVisibility
}
public List NewsItems { get; set; }
- public bool IsDisableUpdates { get; private set; }
public ICommand OpenOptionsCommand { get; private set; }
public ICommand OpenLootCommand { get; private set; }
public ICommand OpenAboutCommand { get; private set; }
@@ -117,6 +230,9 @@ public MainWindow()
{
InitializeComponent();
+ NormalTextBrush = (Brush)FindResource("GoldLighterBrush");
+ ErrorTextBrush = (Brush)FindResource("RedLighterBrush");
+
OpenOptionsCommand = new RelayCommand(ShowOptionsView);
OpenLootCommand = new RelayCommand(ShowLootView);
OpenAboutCommand = new RelayCommand(ShowAboutView);
@@ -127,16 +243,24 @@ public MainWindow()
_filterHelpers = (FilterHelpers)App.ServiceProvider.GetService(typeof(FilterHelpers));
_launchGameHelpers = (LaunchGameHelpers)App.ServiceProvider.GetService(typeof(LaunchGameHelpers));
_newsHelpers = (NewsHelpers)App.ServiceProvider.GetService(typeof(NewsHelpers));
- _gameFileUpdater = (GameFileUpdateHelpers)App.ServiceProvider.GetService(typeof(GameFileUpdateHelpers));
+ _gameFileUpdater = (GameFileUpdater)App.ServiceProvider.GetService(typeof(GameFileUpdater));
LoadAndUpdateDDrawOptions();
- InitWindow();
- EnsureWindowIsVisible();
Loaded += MainWindow_Loaded;
LoadConfiguration();
+ LoadOptions();
+
+ CheckGlCtxAndPrompt(
+ // This property is incredibly ambiguous
+ usesD2gl: _localStorage.LoadSection(StorageKey.LauncherArgs).graphics == false,
+ // This is quite horrible and should be made into an enum
+ cncDdrawUsesOgl: _localStorage.LoadSection(StorageKey.DdrawOptions).Renderer == "opengl"
+ );
// Registering to receive NavigationMessage
Messenger.Default.Register(this, OnNavigationMessageReceived);
Messenger.Default.Register(this, OnConfigurationChanged);
+ Messenger.Default.Register(this, OnLauncherOptionsChanged);
+ Messenger.Default.Register(this, OnRendererChanged);
DataContext = this;
this.Closed += MainWindow_Closed;
@@ -155,6 +279,24 @@ public MainWindow()
_localStorage.Update(StorageKey.FileUpdateModel, storeUpdate);
}
+ this.Title = MsgBox.DefaultDialogTitle;
+ this.VersionText.Text = PD2Shared.Constants.VersionString;
+ UseFileCountProgressMapping();
+ ResetUI();
+ ToggleOffline(show: false);
+
+ if (Wine.IsRunningUnderWine)
+ {
+ WineLogo16Image.ToolTip = Wine.Version != null ? $"Wine {Wine.Version} detected" : "Undetermined Wine version";
+ }
+ else
+ {
+ WineLogo16Image.Visibility = Visibility.Hidden;
+ }
+
+ // Auto-close
+ AutoCloseResetProgress();
+ InputManager.Current.PostProcessInput += AutoClosePostProcessInput;
// Don't try to update launcher in debug mode
// TEST
@@ -165,6 +307,7 @@ public MainWindow()
CheckForUpdates();
#endif
}
+
private void OnNavigationMessageReceived(NavigationMessage message)
{
Overlay.Visibility = Visibility.Collapsed;
@@ -200,7 +343,7 @@ private async void CheckForUpdates()
{
Dispatcher.Invoke(() =>
{
- DownloadProgressBar.Value = value * 100;
+ DownloadProgressBar.Value = value * DownloadProgressBar.Maximum;
if (DownloadProgressBar.Visibility != Visibility.Visible)
{
DownloadProgressBar.Visibility = Visibility.Visible;
@@ -235,43 +378,282 @@ private void BackgroundImage_MouseLeftButtonDown(object sender, MouseButtonEvent
private async void PlayButton_Click(object sender, RoutedEventArgs e)
{
Debug.WriteLine("PlayButton_Click start");
+
+ L.Separator();
+ L.CallerInformation($"Clicked on '{PlayButton.Text}'");
+
+ // Store this early on to allow releasing the keys immediately upon clicking the button
+ KeyComboDown keyComboDown = _keyComboDown;
+
+ if (keyComboDown == KeyComboDown.Play)
+ {
+ if (LaunchGameHelpers.IsGameRunning)
+ {
+ L.CallerWarning("Attempted to start the game while another instance is already running.");
+
+ MsgBox.Warn("Another instance of the game is already running.");
+ return;
+ }
+ }
+
+ UpdateMode updateMode;
+ bool noFilterUpdate;
+ bool noLaunch;
+
+ switch (keyComboDown)
+ {
+ case KeyComboDown.Play:
+ updateMode = UpdateMode.Normal;
+ noFilterUpdate = false;
+ noLaunch = false;
+ break;
+
+ case KeyComboDown.Update:
+ updateMode = UpdateMode.Normal;
+ noFilterUpdate = false;
+ noLaunch = true;
+ break;
+
+ case KeyComboDown.Restore:
+ updateMode = UpdateMode.Restore;
+ noFilterUpdate = true;
+ noLaunch = true;
+ break;
+
+ case KeyComboDown.Download:
+ updateMode = UpdateMode.Download;
+ noFilterUpdate = true;
+ noLaunch = true;
+ break;
+
+ case KeyComboDown.Reset:
+ updateMode = UpdateMode.Reset;
+ noFilterUpdate = true;
+ noLaunch = true;
+ break;
+
+ // Only switch expressions can benefit from "exhaustive switch"
+ default:
+ throw new InvalidEnumArgumentException();
+ }
+
UpdateUIForOperationStart();
try
{
- if (Process.GetProcessesByName("Game").Any())
+ bool workOffline = IsDisableUpdates && !noLaunch;
+ bool proceed = false;
+
+ {
+ Exception? caughtEx = null;
+
+ using (_currentCts = new CancellationTokenSource())
+ {
+ try
+ {
+ CancelButton.IsEnabled = true;
+ CancelButton.Visibility = Visibility.Visible;
+ _cancellingAllowed = true;
+
+ L.Separator();
+
+ await _gameFileUpdater.UpdateAsync(
+ workOffline,
+ updateMode,
+ UseHttp2,
+ _localStorage.LoadSection(StorageKey.FileUpdateModel),
+ new ProgressWithCookie(_progressCookie, UpdateProgressValues),
+ new ProgressWithCookie(_progressCookie, UpdatePlayButtonText),
+ new ProgressWithCookie(_progressCookie, ToggleOffline),
+ new ProgressWithCookie(_progressCookie, ToggleProgressErrorIndicator),
+ _currentCts.Token);
+ }
+ catch (OperationCanceledException ex) when (ex.CancellationToken == _currentCts.Token)
+ {
+ // A user-requested cancellation -- just bail
+ L.CallerWarning("Canceled.");
+ return;
+ }
+ catch (DownloadException ex)
+ {
+ // These contain AggregateException and are vile to log
+ // Since all contained inner exceptions must have been logged already -- don't log them here
+ L.CallerError($"{nameof(DownloadException)} caught: '{ex.Message}'");
+
+ caughtEx = ex;
+ }
+ catch (FatalGameFileUpdateException ex)
+ {
+ // These will be handled below
+ L.CallerError($"{nameof(FatalGameFileUpdateException)} caught: '{ex.Message}'");
+
+ caughtEx = ex;
+ }
+ catch (Exception ex)
+ {
+ L.CallerError(ex, $"{nameof(GameFileUpdater.UpdateAsync)}() threw");
+
+ caughtEx = ex;
+ }
+ finally
+ {
+ _cancellingAllowed = false;
+ CancelButton.Visibility = Visibility.Hidden;
+
+ _currentCts = null;
+
+ if (_closePending)
+ {
+ _closePendingAllowClose = true;
+ this.Close();
+ }
+ }
+ }
+
+ if (caughtEx == null)
+ {
+ proceed = true;
+ }
+ else
+ {
+ void HandleFatalGameFileUpdateException(string cause, string effect)
+ {
+ const string ActionMsg = "\nRefusing to launch the game.";
+ const string OfflineActionMsg = "\nAttempt to launch the game anyway?";
+
+ if (noLaunch)
+ {
+ MsgBox.Exception(
+ caughtEx.InnerException,
+ cause);
+ }
+ else
+ {
+ if (!workOffline)
+ {
+ MsgBox.Exception(
+ caughtEx.InnerException,
+ string.Join('\n', cause, effect, ActionMsg));
+ }
+ else
+ {
+ if (MsgBox.Exception(
+ caughtEx.InnerException,
+ string.Join('\n', cause, effect, OfflineActionMsg),
+ MessageBoxImage.Warning,
+ MessageBoxButton.YesNo,
+ MessageBoxResult.No) == MessageBoxResult.Yes)
+ {
+ proceed = true;
+ }
+ }
+ }
+ }
+
+ if (caughtEx is OfflineInvalidManifest)
+ {
+ HandleFatalGameFileUpdateException(
+ cause: updateMode == UpdateMode.Reset ?
+ // Manifest gets cleared during Reset
+ "Failed to retrieve metadata." :
+ "Failed to retrieve metadata and there is no local manifest to work with.",
+ effect: "Game files could not be validated and the integrity of the game cannot be guaranteed."
+ );
+ }
+ else if (caughtEx is InvalidMetadataRetrieved)
+ {
+ HandleFatalGameFileUpdateException(
+ cause: "Retrieved metadata is invalid.",
+ effect: "Game files could not be validated and the integrity of the game cannot be guaranteed."
+ );
+ }
+ else if (caughtEx is OfflineNeedsDownload)
+ {
+ HandleFatalGameFileUpdateException(
+ cause: "Game files failed validation and cannot be re-downloaded.",
+ effect: "The integrity of the game cannot be guaranteed."
+ );
+ }
+ else
+ {
+ // Is this still needed?
+ if (caughtEx is HttpRequestException)
+ {
+ ToggleOffline(show: true);
+ }
+
+ MsgBox.Exception(caughtEx);
+ }
+ }
+ }
+
+ if (!proceed)
{
- MessageBox.Show("Game is already running.");
return;
}
- var selectedAuthorAndFilter = _localStorage.LoadSection(StorageKey.SelectedAuthorAndFilter);
- if (selectedAuthorAndFilter?.selectedFilter != null)
+ // Clear progress indicator at this point
+ UpdateProgressValues(new ProgressValues().Clear().Extract());
+
+ if (!noFilterUpdate)
{
- bool isUpdated = await _filterHelpers.CheckAndUpdateFilterAsync(selectedAuthorAndFilter);
+ // Make this step obey IsDisableUpdates and also bail in case of _isOffline not to produce more errors
+ if (!workOffline && !_isOffline)
+ {
+ var selectedAuthorAndFilter = _localStorage.LoadSection(StorageKey.SelectedAuthorAndFilter);
+ if (selectedAuthorAndFilter?.selectedFilter != null)
+ {
+ UpdatePlayButtonText("Updating filter...");
+
+ try
+ {
+ await _filterHelpers.CheckAndUpdateFilterAsync(selectedAuthorAndFilter);
+ }
+ catch (Exception ex)
+ {
+ L.CallerError(ex, $"{nameof(FilterHelpers.CheckAndUpdateFilterAsync)}() threw");
+ MsgBox.Exception(ex, "Failed to update the filter:");
+
+ return;
+ }
+ }
+ }
+ }
+
+ if (noLaunch)
+ {
+ return;
}
- LauncherArgs launcherArgs = _localStorage.LoadSection(StorageKey.LauncherArgs);
- if (!launcherArgs.disableAutoUpdate)
{
+ UpdatePlayButtonText("Launching...");
+
+ bool useAutoClose = AutoCloseAfterLaunch;
+ Process gameProcess;
+
try
{
- await _gameFileUpdater.UpdateFromShaMetadataAsync(_localStorage, new Progress(UpdateProgress), () => { });
- Debug.WriteLine("made it out of the update check");
- await _fileUpdateHelpers.SyncFilesFromEnvToRoot(_localStorage);
+ gameProcess = _launchGameHelpers.LaunchGame(_localStorage, useAutoClose ? AutoCloseGameProcessExited : null);
+ }
+ catch (Exception ex)
+ {
+ L.CallerError(ex, $"{nameof(_launchGameHelpers.LaunchGame)}() threw");
+ MsgBox.Exception(ex, "Failed to launch the game:");
+
+ return;
}
- catch (HttpRequestException ex)
+
+ if (useAutoClose)
{
- Debug.WriteLine($"Update failed: {ex.Message}. Proceeding in offline mode.");
- MessageBox.Show("Could not check for updates. Proceeding in offline mode.", "Offline Mode", MessageBoxButton.OK, MessageBoxImage.Information);
+ AutoCloseBegin(gameProcess);
}
+ else
+ {
+ gameProcess.Dispose();
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(1.5));
}
- _launchGameHelpers.LaunchGame(_localStorage);
- }
- catch (Exception ex)
- {
- Debug.WriteLine($"Exception occurred during PlayButton_Click: {ex.Message}");
- ShowErrorMessage($"An error occurred: {ex.Message}");
}
finally
{
@@ -281,45 +663,310 @@ private async void PlayButton_Click(object sender, RoutedEventArgs e)
}
}
+ private bool Cancel()
+ {
+ if (!_cancellingAllowed)
+ {
+ return false;
+ }
+
+ _cancellingAllowed = false;
+ CancelButton.IsEnabled = false;
+
+ L.CallerWarning("Cancellation requested!");
+ _currentCts!.Cancel(throwOnFirstException: true);
+
+ return true;
+ }
+
+ private void CancelButton_Click(object sender, RoutedEventArgs e)
+ {
+ Cancel();
+ }
+
+ private void Window_Closing(object sender, CancelEventArgs e)
+ {
+ if (_closePending)
+ {
+ e.Cancel = !_closePendingAllowClose;
+ return;
+ }
+
+ if (this.Cancel())
+ {
+ e.Cancel = true;
+
+ _closePending = true;
+ }
+
+ if (AutoCloseAbort())
+ {
+ L.CallerDebug($"Auto-close aborted due to window closing.");
+ }
+ }
+
+ private void CheckKeys(KeyboardDevice kd)
+ {
+ _keyComboDown = kd.Modifiers switch
+ {
+ // Pressing Alt+Space will pop up system menu. Similarly, pressing Alt alone can focus it (even with WindowStyle.None).
+ // Therefore, handling Alt alone isn't great (without disabling system menu first, but that's too invasive).
+
+ ModifierKeys.Control | ModifierKeys.Shift | ModifierKeys.Alt => KeyComboDown.Reset,
+ ModifierKeys.Control | ModifierKeys.Shift => KeyComboDown.Download,
+ ModifierKeys.Shift => KeyComboDown.Restore,
+ ModifierKeys.Control => KeyComboDown.Update,
+ _ => KeyComboDown.Play,
+ };
+ }
+
+ private void Window_KeyDown(object sender, KeyEventArgs e)
+ {
+ CheckKeys(e.KeyboardDevice);
+ RefreshPlayButtonText();
+ }
+
+ private void Window_KeyUp(object sender, KeyEventArgs e)
+ {
+ CheckKeys(e.KeyboardDevice);
+ RefreshPlayButtonText();
+ }
+
+ private void Window_IsKeyboardFocusWithinChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (!IsKeyboardFocusWithin)
+ {
+ _keyComboDown = KeyComboDown.Play;
+ }
+
+ RefreshPlayButtonText();
+ }
+
+ private void Window_Activated(object sender, EventArgs e)
+ {
+ if (AutoCloseAbort())
+ {
+ L.CallerDebug($"Auto-close aborted due to window activation.");
+ }
+
+ // Attempt to refocus when closing a modal dialog to get keyboard focus back
+ if (!this.IsKeyboardFocusWithin)
+ {
+ this.Focus();
+ }
+ }
+
private void UpdateUIForOperationStart()
{
- try
+ Mouse.OverrideCursor = Cursors.AppStarting;
+
+ _playButtonTextLocked = true;
+ UpdatePlayButtonText("Updating...");
+ PlayButton.IsEnabled = false;
+
+ UpdateProgressValues(new ProgressValues().Clear().Extract());
+ DownloadProgressBar.Visibility = Visibility.Visible;
+ AboutButton.IsEnabled = false;
+ OptionsButton.IsEnabled = false;
+ LootButton.IsEnabled = false;
+ }
+
+ [MemberNotNull(nameof(_playButtonText))]
+ private void ResetUI()
+ {
+ _progressCookie.Advance();
+
+ LootButton.IsEnabled = true;
+ OptionsButton.IsEnabled = true;
+ AboutButton.IsEnabled = true;
+ CancelButton.Visibility = Visibility.Hidden;
+ CancelButton.IsEnabled = true;
+ DownloadProgressBar.Visibility = Visibility.Hidden;
+ UpdateProgressValues(new ProgressValues().Clear().Extract());
+ ToggleProgressErrorIndicator(false);
+
+ PlayButton.IsEnabled = true;
+ _playButtonTextLocked = false;
+ UpdatePlayButtonText("Play");
+
+ Mouse.OverrideCursor = null;
+ }
+
+ private void UseFileCountProgressMapping()
+ {
+ UpdateProgressValues(new ProgressValues().Clear().Extract());
+ _progressBytesPrecision = 2;
+
+ _progressTotalText = null;
+ _progressFileCountText = ProgressLargeText;
+ _progressBytesText = ProgressSmallText1;
+ _progressBytesPerSecText = ProgressSmallText2;
+ }
+
+ private void UseTotalProgressMapping()
+ {
+ UpdateProgressValues(new ProgressValues().Clear().Extract());
+ _progressBytesPrecision = 0;
+
+ _progressTotalText = ProgressLargeText;
+ _progressFileCountText = ProgressSmallText2;
+ _progressBytesText = ProgressSmallText1;
+ _progressBytesPerSecText = null;
+ }
+
+ private void UpdateProgressValues(ProgressValues.IData progressData)
+ {
+ if (progressData.TotalSet) UpdateTotalProgress(progressData.Total);
+ if (progressData.FileCountSet) UpdateFileCountProgress(progressData.FileCount);
+ if (progressData.BytesSet) UpdateBytesProgress(progressData.Bytes);
+ if (progressData.BytesPerSecSet) UpdateBytesPerSecProgress(progressData.BytesPerSec);
+ }
+
+ private void UpdateTotalProgress(double? progress)
+ {
+ if (progress == null)
{
- var updatingImageUri = new Uri("pack://application:,,,/Resources/Images/updating_disabled.jpg");
- PlayButton.NormalImageSource = new BitmapImage(updatingImageUri);
- DownloadProgressBar.Visibility = Visibility.Visible;
DownloadProgressBar.Value = 0;
}
- catch (UriFormatException ex)
+ else
{
- Debug.WriteLine($"URI format exception: {ex.Message}");
+ DownloadProgressBar.Value = progress.Value * DownloadProgressBar.Maximum;
+ }
+
+ if (_progressTotalText == null)
+ {
+ return;
+ }
+
+ if (progress == null)
+ {
+ _progressTotalText.Visibility = Visibility.Hidden;
+ }
+ else
+ {
+ _progressTotalText.Text = $"{progress * 100:N1}%";
+ _progressTotalText.Visibility = Visibility.Visible;
}
}
- private void ResetUI()
+ private void UpdateFileCountProgress(ProgressValues.FileCountProgress? progress)
{
- // Code to reset the Play button and hide the progress bar
- Dispatcher.Invoke(() =>
+ if (_progressFileCountText == null)
{
- try
- {
- var playImageUri = new Uri("pack://application:,,,/Resources/Images/play.jpg");
- PlayButton.NormalImageSource = new BitmapImage(playImageUri);
- }
- catch (UriFormatException ex)
- {
- Debug.WriteLine($"URI format exception: {ex.Message}");
- }
- DownloadProgressBar.Visibility = Visibility.Hidden;
- });
+ return;
+ }
+
+ if (progress == null)
+ {
+ _progressFileCountText.Visibility = Visibility.Hidden;
+ return;
+ }
+
+ _progressFileCountText.Text = $"{progress.Current:N0}/{progress.Total:N0}";
+ _progressFileCountText.Visibility = Visibility.Visible;
}
- private void UpdateProgress(double value)
+ private void UpdateBytesProgress(ProgressValues.BytesProgress? progress)
{
- Dispatcher.Invoke(() =>
+ if (_progressBytesText == null)
{
- DownloadProgressBar.Value = value * 100;
- });
+ return;
+ }
+
+ if (progress == null)
+ {
+ _progressBytesText.Visibility = Visibility.Hidden;
+ return;
+ }
+
+ var currentStr = Formatting.FormatSizeInMiB(progress.Current, appendUnits: progress.Total == null, _progressBytesPrecision);
+ var slashStr = progress.Total == null ? "" : "/";
+ var totalStr = progress.Total == null ? "" : Formatting.FormatSizeInMiB(progress.Total.Value, appendUnits: true, _progressBytesPrecision);
+
+ _progressBytesText.Text = $"{currentStr}{slashStr}{totalStr}";
+ _progressBytesText.Visibility = Visibility.Visible;
+ }
+
+ private void UpdateBytesPerSecProgress(ProgressValues.BytesPerSecProgress? progress)
+ {
+ if (_progressBytesPerSecText == null)
+ {
+ return;
+ }
+
+ if (progress == null)
+ {
+ _progressBytesPerSecText.Visibility = Visibility.Hidden;
+ return;
+ }
+
+ _progressBytesPerSecText.Text = $"({Formatting.FormatThroughputInMiB(progress.Bytes, progress.ElapsedMilliseconds)})";
+ _progressBytesPerSecText.Visibility = Visibility.Visible;
+ }
+
+ [MemberNotNull(nameof(_playButtonText))]
+ private void UpdatePlayButtonText(string text)
+ {
+ _playButtonText = text;
+
+ RefreshPlayButtonText();
+ }
+
+ private void ToggleOffline(bool show)
+ {
+ _isOffline = show;
+
+ OfflineIndicatorImage.Visibility = _isOffline ? Visibility.Visible : Visibility.Hidden;
+ }
+
+ private static string GetTextForKeyComboDown(KeyComboDown keyComboDown)
+ {
+ switch (keyComboDown)
+ {
+ case KeyComboDown.Play:
+ return null!;
+
+ case KeyComboDown.Update:
+ return "Update";
+ case KeyComboDown.Restore:
+ return "Restore";
+ case KeyComboDown.Download:
+ return "Download";
+ case KeyComboDown.Reset:
+ return "Reset";
+
+ // Only switch expressions can benefit from "exhaustive switch"
+ default:
+ throw new InvalidEnumArgumentException();
+ }
+ }
+
+ private void RefreshPlayButtonText()
+ {
+ if (_playButtonTextLocked)
+ {
+ PlayButton.Text = _playButtonText;
+ }
+ else
+ {
+ PlayButton.Text = GetTextForKeyComboDown(_keyComboDown) ?? _playButtonText;
+ }
+ }
+
+ private void ToggleProgressErrorIndicator(bool show)
+ {
+ if (_progressErrorShown == show)
+ {
+ return;
+ }
+
+ _progressErrorShown = show;
+
+ var brush = show ? ErrorTextBrush : NormalTextBrush;
+
+ ProgressLargeText.Foreground = brush;
+ ProgressSmallText1.Foreground = brush;
+ ProgressSmallText2.Foreground = brush;
}
private void onDownloadComplete()
@@ -342,7 +989,20 @@ private void ShowOptionsView()
{
ClearNavigationStack();
Overlay.Visibility = Visibility.Visible;
- MainFrame.Navigate(new OptionsView());
+
+ try
+ {
+ // This is a nasty workaround for OptionsViewModel's excessive and reentrant event firing during initialization
+ // caused by not differentiating between properties being set programmatically and interactively.
+ _suppressRendererChangedMessages = true;
+
+ MainFrame.Navigate(new OptionsView());
+ MainFrame.Focus();
+ }
+ finally
+ {
+ _suppressRendererChangedMessages = false;
+ }
}
private void ShowLootView()
@@ -350,6 +1010,7 @@ private void ShowLootView()
ClearNavigationStack();
Overlay.Visibility = Visibility.Visible;
MainFrame.Navigate(new FiltersView());
+ MainFrame.Focus();
}
private void ShowAboutView()
@@ -357,6 +1018,7 @@ private void ShowAboutView()
ClearNavigationStack();
Overlay.Visibility = Visibility.Visible;
MainFrame.Navigate(new AboutView());
+ MainFrame.Focus();
}
private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
@@ -382,7 +1044,6 @@ private void DonateButton_Click(object sender, RoutedEventArgs e)
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
- MainWindow_Closed(sender, e);
this.Close();
}
@@ -402,13 +1063,18 @@ private void TextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
private void LoadConfiguration()
{
var fileUpdateModel = _localStorage.LoadSection(StorageKey.FileUpdateModel);
- var launcherArgs = _localStorage.LoadSection(StorageKey.LauncherArgs);
IsBeta = fileUpdateModel?.FilePath == "Beta";
IsCustom = fileUpdateModel?.FilePath == "Custom";
- IsDisableUpdates = launcherArgs?.disableAutoUpdate == true;
+ }
- // Use property to control visibility
- UpdatesNotificationVisibility = IsDisableUpdates ? Visibility.Visible : Visibility.Collapsed;
+ private void LoadOptions()
+ {
+ LauncherOptions launcherOptions = _localStorage.LoadSection(StorageKey.LauncherOptions);
+
+ ForceSoftwareRenderer = launcherOptions.ForceSoftwareRenderer;
+ UseHttp2 = launcherOptions.UseHttp2;
+ IsDisableUpdates = launcherOptions.DisableAutoUpdate;
+ AutoCloseAfterLaunch = launcherOptions.AutoCloseAfterLaunch;
}
private void OnConfigurationChanged(ConfigurationChangeMessage message)
@@ -417,12 +1083,79 @@ private void OnConfigurationChanged(ConfigurationChangeMessage message)
OnPropertyChanged(nameof(IsBeta));
IsCustom = message.IsCustom;
OnPropertyChanged(nameof(IsCustom));
- // Use property to control visibility
- UpdatesNotificationVisibility = message.IsDisableUpdates ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private void OnLauncherOptionsChanged(LauncherOptionsChangeMessage message)
+ {
+ ForceSoftwareRenderer = message.ForceSoftwareRenderer;
+ OnPropertyChanged(nameof(ForceSoftwareRenderer));
+ UseHttp2 = message.UseHttp2;
+ OnPropertyChanged(nameof(UseHttp2));
+ IsDisableUpdates = message.DisableAutoUpdate;
+ OnPropertyChanged(nameof(IsDisableUpdates));
+ }
+
+ private void OnRendererChanged(RendererChangeMessage message)
+ {
+ if (_suppressRendererChangedMessages)
+ {
+ return;
+ }
+
+ CheckGlCtxAndPrompt(message.UseD2GL, message.CncDdrawUsesOGL);
+ }
+
+ private static void CheckGlCtxAndPrompt(bool usesD2gl, bool cncDdrawUsesOgl)
+ {
+ if (!usesD2gl && !cncDdrawUsesOgl)
+ {
+ return;
+ }
+
+ if (usesD2gl)
+ {
+ if (GlTest.BestCtx.GlCtxInfo == null && GlTest.BestCtx.StageReached.IndicatesGlFailure())
+ {
+ MsgBox.Exception(
+ GlTest.BestCtx.Exception,
+ "Selected D2GL renderer wrapper might not work:",
+ MessageBoxImage.Warning
+ );
+ }
+ else if(GlTest.BestCtx.GlCtxInfo != null)
+ {
+ // D2GL requires a 3.3 context at minimum. Might as well crash with Access Violation otherwise.
+ // It effectively asks for 3.3 Core profile, but any 3.3+ should be fine.
+ if (GlTest.BestCtx.GlCtxInfo.Version < new Version(3, 3))
+ {
+ MsgBox.Warn(
+ "Selected D2GL renderer wrapper might not work.\n" +
+ "D2GL requires at least a 3.3 context.\n" +
+ "\n" +
+ "Best GL context created:\n" +
+ "\n" +
+ GlTest.BestCtx.GlCtxInfo.ToString()
+ );
+ }
+ }
+ }
+ else
+ {
+ if (GlTest.BestCtx.GlCtxInfo == null && GlTest.BestCtx.StageReached.IndicatesGlFailure())
+ {
+ MsgBox.Exception(
+ GlTest.BestCtx.Exception,
+ "Selected cnc-ddraw renderer wrapper (set to OpenGL renderer) might not work:",
+ MessageBoxImage.Warning
+ );
+ }
+ }
}
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
+ RestoreWindowPosition();
+
await InitializeAsync();
}
@@ -496,11 +1229,22 @@ private void MainWindow_Closed(object sender, EventArgs e)
Debug.WriteLine($"\n\n Saving window position: Left = {this.Left}, Top = {this.Top} \n\n");
_localStorage.Update(StorageKey.WindowPosition, windowPosition);
+
+ // Usage of these local values instead of LocalStorage directly might lead to discrepancy
+ _localStorage.Update(StorageKey.LauncherOptions, new LauncherOptions
+ {
+ ForceSoftwareRenderer = this.ForceSoftwareRenderer,
+ UseHttp2 = this.UseHttp2,
+ DisableAutoUpdate = this.IsDisableUpdates,
+ AutoCloseAfterLaunch = this.AutoCloseAfterLaunch
+ });
}
- private void EnsureWindowIsVisible()
+ private void RestoreWindowPosition()
{
- var windowPosition = _localStorage.LoadSection(StorageKey.WindowPosition);
+ WindowPositionModel windowPosition = _localStorage.LoadSection(StorageKey.WindowPosition);
+
+ Debug.WriteLine($"\n\n Loaded window position: Left = {windowPosition.Left}, Top = {windowPosition.Top} \n\n");
// Check if the window is out of bounds
bool isOutOfBounds =
@@ -509,25 +1253,7 @@ private void EnsureWindowIsVisible()
windowPosition.Left > SystemParameters.VirtualScreenLeft + SystemParameters.VirtualScreenWidth ||
windowPosition.Top > SystemParameters.VirtualScreenTop + SystemParameters.VirtualScreenHeight;
- if (windowPosition == null || isOutOfBounds)
- {
- CenterWindowOnScreen();
- }
- else
- {
- // Restore the window to its last saved position
- this.Left = windowPosition.Left;
- this.Top = windowPosition.Top;
- }
- }
-
- private void InitWindow()
- {
- var windowPosition = _localStorage.LoadSection(StorageKey.WindowPosition);
-
- Debug.WriteLine($"\n\n Loaded window position: Left = {windowPosition?.Left}, Top = {windowPosition?.Top} \n\n");
-
- if (windowPosition == null || (windowPosition.Left == 0 && windowPosition.Top == 0))
+ if (windowPosition == null || isOutOfBounds || (windowPosition.Left == 0 && windowPosition.Top == 0))
{
CenterWindowOnScreen();
}
@@ -545,9 +1271,70 @@ private void ShowErrorMessage(string message)
public void InitializeDefaultSettings(ILocalStorage localStorage)
{
+ // Since LauncherOptions has been recently added, expect existing config files (aka LocalStorage)
+ // to be missing that section. Since the whole LocalStorage implementation is a bit sketchy and the below
+ // initialization doesn't even work -- perform only this specific manual step for now.
+ //
+ // Keep in mind that LocalStorage.Update() will still rotate and rewrite the entire file. *sigh*
+ if (_localStorage.LoadSectionIfExists(StorageKey.LauncherOptions) == null)
+ {
+ bool localStorageUpdated = false;
+
+ if (Wine.IsRunningUnderWine)
+ {
+ if (MsgBox.Info(
+ "It appears to be the first time the launcher has been run.\n" +
+ "Additionally, it's running under Wine.\n" +
+ "\n" +
+ "Would you like to set up launcher options for maximum Wine compatibility?\n" +
+ "(This can be re-adjusted in the Options menu at any time).",
+ MessageBoxButton.YesNo,
+ MessageBoxResult.Yes) == MessageBoxResult.Yes)
+ {
+ localStorage.Update(StorageKey.LauncherOptions, new LauncherOptions()
+ {
+ ForceSoftwareRenderer = true,
+ // HTTP/2 performance in Wine is currently subpar
+ UseHttp2 = false
+ });
+
+ localStorageUpdated = true;
+ }
+
+ if (MsgBox.Info(
+ "Would you like to apply PD2-specific Wine configuration?\n" +
+ "(This can be re-adjusted in the Options menu at any time).",
+ MessageBoxButton.YesNo,
+ MessageBoxResult.Yes) == MessageBoxResult.Yes)
+ {
+ try
+ {
+ Wine.ApplyWineConfiguration();
+ }
+ catch (Wine.WineException ex)
+ {
+ L.CallerError(ex.InnerException, ex.Message);
+ MsgBox.Exception(ex, "Failed to apply Wine configuration:");
+ }
+ catch (Exception ex)
+ {
+ L.CallerError(ex, "Failed to apply Wine configuration.");
+ MsgBox.Exception(ex, "Failed to apply Wine configuration:");
+ }
+ }
+ }
+
+ if (!localStorageUpdated)
+ {
+ localStorage.Update(StorageKey.LauncherOptions, new LauncherOptions());
+ }
+ }
+
+ // None of the below logic works as expected
_localStorage.InitializeIfNotExists(StorageKey.FileUpdateModel, new FileUpdateModel());
_localStorage.InitializeIfNotExists(StorageKey.DdrawOptions, new DdrawOptions());
_localStorage.InitializeIfNotExists(StorageKey.LauncherArgs, new LauncherArgs());
+ _localStorage.InitializeIfNotExists(StorageKey.LauncherOptions, new LauncherOptions());
_localStorage.InitializeIfNotExists(StorageKey.SelectedAuthorAndFilter, new SelectedAuthorAndFilter());
_localStorage.InitializeIfNotExists(StorageKey.Pd2AuthorList, new Pd2AuthorList());
_localStorage.InitializeIfNotExists(StorageKey.News, new News());
@@ -662,7 +1449,8 @@ await _fileUpdateHelpers.GetCloudFileMetadataAsync(
catch (Exception ex)
{
Debug.WriteLine($"Unhandled exception: {ex}");
- UpdatesNotificationVisibility = Visibility.Visible;
+
+ ToggleOffline(show: true);
onDownloadComplete?.Invoke();
return;
}
@@ -826,5 +1614,173 @@ public static void ShowTopmostMessageBox(string message, string title)
topmostWindow.Show();
}
+
+ private async void GoToLogButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ await Shell.OpenFolderAndSelectItemsAsync(Logging.LogDirPath, Logging.LogFileName);
+ }
+ catch (Exception ex)
+ {
+ L.CallerWarning(ex, $"{nameof(Shell.OpenFolderAndSelectItemsAsync)}() threw");
+ MsgBox.Exception(ex, "Failed to navigate to the log file:", MessageBoxImage.Warning);
+ }
+ }
+
+ private void AutoCloseBegin(Process gameProcess)
+ {
+ lock (_autoCloseLock)
+ {
+ if (_autoCloseActive)
+ {
+ return;
+ }
+
+ if (gameProcess.HasExited)
+ {
+ // If the process has already terminated -- bail
+ return;
+ }
+
+ _autoCloseGameProcess = gameProcess;
+
+ _autoCloseProgressUpdateStopwatch.Restart();
+ _autoCloseProgressUpdateTimer = new DispatcherTimer(
+ TimeSpan.FromMilliseconds(UpdateThrottle.DefaultIntervalMilliseconds),
+ DispatcherPriority.Render,
+ AutoCloseProgressUpdateTimer,
+ this.Dispatcher
+ );
+ _autoCloseProgressUpdateTimer.Start();
+
+ _autoCloseThreadInterrupt.Reset();
+ _autoCloseThread = new Thread(AutoCloseThread);
+ _autoCloseThread.Start();
+
+ _autoCloseActive = true;
+
+ L.CallerDebug($"Auto-closing in {AutoCloseTimeSpan}...");
+ }
+ }
+
+ private bool AutoCloseAbort(bool joinThread = true)
+ {
+ Thread? autoCloseThread = null;
+
+ lock (_autoCloseLock)
+ {
+ if (!_autoCloseActive)
+ {
+ return false;
+ }
+
+ _autoCloseThreadInterrupt.Set();
+ if (joinThread)
+ {
+ autoCloseThread = _autoCloseThread;
+ }
+ _autoCloseThread = null;
+
+ _autoCloseProgressUpdateTimer!.Stop();
+ _autoCloseProgressUpdateTimer = null;
+
+ this.Dispatcher.Invoke(AutoCloseResetProgress);
+
+ _autoCloseGameProcess!.Exited -= AutoCloseGameProcessExited;
+ _autoCloseGameProcess = null;
+
+ _autoCloseActive = false;
+ }
+
+ autoCloseThread?.Join();
+
+ return true;
+ }
+
+ private void AutoCloseResetProgress()
+ {
+ AutoCloseProgressBar.Value = 0;
+ }
+
+ private void AutoCloseGameProcessExited(object? sender, EventArgs e)
+ {
+ // This will be called from a thread pool
+
+ if (!AutoCloseAbort())
+ {
+ // If not active yet -- just bail.
+ // AutoCloseBegin() will also bail if the process has terminated already.
+ return;
+ }
+
+ if (sender is Process process)
+ {
+ L.CallerDebug($"Auto-close aborted due to game process terminating with {process.ExitCode} exit code.");
+
+ process.Dispose();
+ }
+ else
+ {
+ L.CallerDebug($"Auto-close aborted due to game process terminating.");
+ }
+
+ this.Dispatcher.Invoke(() =>
+ {
+ // Try to bring the window into foreground
+ this.Topmost = true;
+ this.Activate();
+ this.Topmost = false;
+ });
+ }
+
+ private void AutoClosePostProcessInput(object sender, ProcessInputEventArgs e)
+ {
+ if (e.StagingItem.Input is MouseButtonEventArgs m)
+ {
+ // Minimize number of different events being handled here
+ if (m.ButtonState == MouseButtonState.Pressed && m.RoutedEvent.RoutingStrategy == RoutingStrategy.Tunnel)
+ {
+ if (AutoCloseAbort())
+ {
+ L.CallerDebug($"Auto-close aborted due to mouse down event.");
+ }
+ }
+ }
+ else if (e.StagingItem.Input is KeyEventArgs k)
+ {
+ // Minimize number of different events being handled here
+ if (k.IsDown && !k.IsRepeat && k.RoutedEvent.RoutingStrategy == RoutingStrategy.Tunnel)
+ {
+ if (AutoCloseAbort())
+ {
+ L.CallerDebug($"Auto-close aborted due to key down event.");
+ }
+ }
+ }
+ }
+
+ private void AutoCloseThread()
+ {
+ if (_autoCloseThreadInterrupt.WaitOne(AutoCloseTimeSpan))
+ {
+ return;
+ }
+ else
+ {
+ if (AutoCloseAbort(joinThread: false))
+ {
+ L.CallerDebug("Auto-closing...");
+ this.Dispatcher.Invoke(this.Close);
+ }
+ }
+ }
+
+ private void AutoCloseProgressUpdateTimer(object? sender, EventArgs e)
+ {
+ double progress = Math.Max(0, AutoCloseTimeSpan.Ticks - _autoCloseProgressUpdateStopwatch.Elapsed.Ticks) / (double)AutoCloseTimeSpan.Ticks;
+
+ AutoCloseProgressBar.Value = progress * AutoCloseProgressBar.Maximum;
+ }
}
}
\ No newline at end of file
diff --git a/PD2Launcherv2/Messages/ConfigurationChangeMessage.cs b/PD2Launcherv2/Messages/ConfigurationChangeMessage.cs
index 518d8a1e..5eb308fc 100644
--- a/PD2Launcherv2/Messages/ConfigurationChangeMessage.cs
+++ b/PD2Launcherv2/Messages/ConfigurationChangeMessage.cs
@@ -5,6 +5,5 @@ public class ConfigurationChangeMessage
{
public bool IsBeta { get; set; }
public bool IsCustom { get; set; }
- public bool IsDisableUpdates { get; set; }
}
}
\ No newline at end of file
diff --git a/PD2Launcherv2/Messages/LauncherOptionsChangeMessage.cs b/PD2Launcherv2/Messages/LauncherOptionsChangeMessage.cs
new file mode 100644
index 00000000..a8aef6cd
--- /dev/null
+++ b/PD2Launcherv2/Messages/LauncherOptionsChangeMessage.cs
@@ -0,0 +1,9 @@
+namespace PD2Launcherv2.Messages
+{
+ public class LauncherOptionsChangeMessage
+ {
+ public bool ForceSoftwareRenderer { get; init; }
+ public bool UseHttp2 { get; init; }
+ public bool DisableAutoUpdate { get; init; }
+ }
+}
diff --git a/PD2Launcherv2/Messages/RendererChangeMessage.cs b/PD2Launcherv2/Messages/RendererChangeMessage.cs
new file mode 100644
index 00000000..39be55b2
--- /dev/null
+++ b/PD2Launcherv2/Messages/RendererChangeMessage.cs
@@ -0,0 +1,9 @@
+namespace PD2Launcherv2.Messages
+{
+ public class RendererChangeMessage
+ {
+ // This should really be a common enum and not a bool
+ public bool UseD2GL { get; init; }
+ public bool CncDdrawUsesOGL { get; init; }
+ }
+}
diff --git a/PD2Launcherv2/PD2Launcherv2.csproj b/PD2Launcherv2/PD2Launcherv2.csproj
index 4ac15617..9e9ecab7 100644
--- a/PD2Launcherv2/PD2Launcherv2.csproj
+++ b/PD2Launcherv2/PD2Launcherv2.csproj
@@ -23,8 +23,16 @@
true
+
+ full
+
+
+
+ embedded
+
+
-
+
@@ -32,33 +40,38 @@
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
-
+
+
@@ -66,10 +79,6 @@
-
-
-
-
@@ -82,7 +91,7 @@
-
+
@@ -90,35 +99,36 @@
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
-
-
-
-
-
+
+
diff --git a/PD2Launcherv2/Resources/Fonts/exocet-blizzard-heavy.otf b/PD2Launcherv2/Resources/Fonts/exocet-blizzard-heavy.ttf
similarity index 100%
rename from PD2Launcherv2/Resources/Fonts/exocet-blizzard-heavy.otf
rename to PD2Launcherv2/Resources/Fonts/exocet-blizzard-heavy.ttf
diff --git a/PD2Launcherv2/Resources/Images/bg1.jpg b/PD2Launcherv2/Resources/Images/bg1.jpg
index 786a82f1..9474768d 100644
Binary files a/PD2Launcherv2/Resources/Images/bg1.jpg and b/PD2Launcherv2/Resources/Images/bg1.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/bg2.jpg b/PD2Launcherv2/Resources/Images/bg2.jpg
index a353bb4e..bcabf371 100644
Binary files a/PD2Launcherv2/Resources/Images/bg2.jpg and b/PD2Launcherv2/Resources/Images/bg2.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/bg_plain.jpg b/PD2Launcherv2/Resources/Images/bg_plain.jpg
index 8092e75f..e810128b 100644
Binary files a/PD2Launcherv2/Resources/Images/bg_plain.jpg and b/PD2Launcherv2/Resources/Images/bg_plain.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/blank.png b/PD2Launcherv2/Resources/Images/blank.png
new file mode 100644
index 00000000..7dd1f7b5
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank.png differ
diff --git a/PD2Launcherv2/Resources/Images/blank_disabled.png b/PD2Launcherv2/Resources/Images/blank_disabled.png
new file mode 100644
index 00000000..77834287
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_disabled.png differ
diff --git a/PD2Launcherv2/Resources/Images/blank_pressed.png b/PD2Launcherv2/Resources/Images/blank_pressed.png
new file mode 100644
index 00000000..b13031b4
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_pressed.png differ
diff --git a/PD2Launcherv2/Resources/Images/blank_split_bottom.png b/PD2Launcherv2/Resources/Images/blank_split_bottom.png
new file mode 100644
index 00000000..3278e457
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_bottom.png differ
diff --git a/PD2Launcherv2/Resources/Images/blank_split_bottom_disabled.png b/PD2Launcherv2/Resources/Images/blank_split_bottom_disabled.png
new file mode 100644
index 00000000..522b57b4
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_bottom_disabled.png differ
diff --git a/PD2Launcherv2/Resources/Images/blank_split_bottom_pressed.png b/PD2Launcherv2/Resources/Images/blank_split_bottom_pressed.png
new file mode 100644
index 00000000..d7d55d94
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_bottom_pressed.png differ
diff --git a/PD2Launcherv2/Resources/Images/blank_split_top.png b/PD2Launcherv2/Resources/Images/blank_split_top.png
new file mode 100644
index 00000000..d885704b
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_top.png differ
diff --git a/PD2Launcherv2/Resources/Images/blank_split_top_disabled.png b/PD2Launcherv2/Resources/Images/blank_split_top_disabled.png
new file mode 100644
index 00000000..44a5872e
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_top_disabled.png differ
diff --git a/PD2Launcherv2/Resources/Images/blank_split_top_pressed.png b/PD2Launcherv2/Resources/Images/blank_split_top_pressed.png
new file mode 100644
index 00000000..8afa86ba
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/blank_split_top_pressed.png differ
diff --git a/PD2Launcherv2/Resources/Images/btn_beta.jpg b/PD2Launcherv2/Resources/Images/btn_beta.jpg
index 2515c188..43e39fd3 100644
Binary files a/PD2Launcherv2/Resources/Images/btn_beta.jpg and b/PD2Launcherv2/Resources/Images/btn_beta.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/btn_live.jpg b/PD2Launcherv2/Resources/Images/btn_live.jpg
index ffcde2c6..ae1bbbb3 100644
Binary files a/PD2Launcherv2/Resources/Images/btn_live.jpg and b/PD2Launcherv2/Resources/Images/btn_live.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/btn_more.jpg b/PD2Launcherv2/Resources/Images/btn_more.jpg
index 4a502f24..e2d465b6 100644
Binary files a/PD2Launcherv2/Resources/Images/btn_more.jpg and b/PD2Launcherv2/Resources/Images/btn_more.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/btn_more_pressed.jpg b/PD2Launcherv2/Resources/Images/btn_more_pressed.jpg
index 085a5260..974b2348 100644
Binary files a/PD2Launcherv2/Resources/Images/btn_more_pressed.jpg and b/PD2Launcherv2/Resources/Images/btn_more_pressed.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/btn_no_updates3.jpg b/PD2Launcherv2/Resources/Images/btn_no_updates3.jpg
index 94aab60f..95c2b64a 100644
Binary files a/PD2Launcherv2/Resources/Images/btn_no_updates3.jpg and b/PD2Launcherv2/Resources/Images/btn_no_updates3.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/btn_switch.jpg b/PD2Launcherv2/Resources/Images/btn_switch.jpg
index dac743fa..425406ee 100644
Binary files a/PD2Launcherv2/Resources/Images/btn_switch.jpg and b/PD2Launcherv2/Resources/Images/btn_switch.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/btn_switch_disabled.png b/PD2Launcherv2/Resources/Images/btn_switch_disabled.png
new file mode 100644
index 00000000..ce8006c2
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/btn_switch_disabled.png differ
diff --git a/PD2Launcherv2/Resources/Images/btn_switch_pressed.jpg b/PD2Launcherv2/Resources/Images/btn_switch_pressed.jpg
index 56eb9412..f00732ae 100644
Binary files a/PD2Launcherv2/Resources/Images/btn_switch_pressed.jpg and b/PD2Launcherv2/Resources/Images/btn_switch_pressed.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/checkbox_checked.png b/PD2Launcherv2/Resources/Images/checkbox_checked.png
index 8ffff529..0d4493d8 100644
Binary files a/PD2Launcherv2/Resources/Images/checkbox_checked.png and b/PD2Launcherv2/Resources/Images/checkbox_checked.png differ
diff --git a/PD2Launcherv2/Resources/Images/checkbox_unchecked.png b/PD2Launcherv2/Resources/Images/checkbox_unchecked.png
index ed801a3d..cf1990ed 100644
Binary files a/PD2Launcherv2/Resources/Images/checkbox_unchecked.png and b/PD2Launcherv2/Resources/Images/checkbox_unchecked.png differ
diff --git a/PD2Launcherv2/Resources/Images/close.jpg b/PD2Launcherv2/Resources/Images/close.jpg
index 9a7add3f..1a2bc53b 100644
Binary files a/PD2Launcherv2/Resources/Images/close.jpg and b/PD2Launcherv2/Resources/Images/close.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/close_disabled.png b/PD2Launcherv2/Resources/Images/close_disabled.png
new file mode 100644
index 00000000..e9479d5d
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/close_disabled.png differ
diff --git a/PD2Launcherv2/Resources/Images/close_pressed.jpg b/PD2Launcherv2/Resources/Images/close_pressed.jpg
index a26d6aa3..3fa701e7 100644
Binary files a/PD2Launcherv2/Resources/Images/close_pressed.jpg and b/PD2Launcherv2/Resources/Images/close_pressed.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/custom_notif.jpg b/PD2Launcherv2/Resources/Images/custom_notif.jpg
index 100282f4..f2260cdc 100644
Binary files a/PD2Launcherv2/Resources/Images/custom_notif.jpg and b/PD2Launcherv2/Resources/Images/custom_notif.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/donate.jpg b/PD2Launcherv2/Resources/Images/donate.jpg
deleted file mode 100644
index 092af618..00000000
Binary files a/PD2Launcherv2/Resources/Images/donate.jpg and /dev/null differ
diff --git a/PD2Launcherv2/Resources/Images/donate_pressed.jpg b/PD2Launcherv2/Resources/Images/donate_pressed.jpg
deleted file mode 100644
index 0b4d4996..00000000
Binary files a/PD2Launcherv2/Resources/Images/donate_pressed.jpg and /dev/null differ
diff --git a/PD2Launcherv2/Resources/Images/empty_button.png b/PD2Launcherv2/Resources/Images/empty_button.png
index ce584199..616ca2eb 100644
Binary files a/PD2Launcherv2/Resources/Images/empty_button.png and b/PD2Launcherv2/Resources/Images/empty_button.png differ
diff --git a/PD2Launcherv2/Resources/Images/logo.gif b/PD2Launcherv2/Resources/Images/logo.gif
index b2e2af11..b74faef2 100644
Binary files a/PD2Launcherv2/Resources/Images/logo.gif and b/PD2Launcherv2/Resources/Images/logo.gif differ
diff --git a/PD2Launcherv2/Resources/Images/loot.jpg b/PD2Launcherv2/Resources/Images/loot.jpg
deleted file mode 100644
index aa0bf543..00000000
Binary files a/PD2Launcherv2/Resources/Images/loot.jpg and /dev/null differ
diff --git a/PD2Launcherv2/Resources/Images/loot_pressed.jpg b/PD2Launcherv2/Resources/Images/loot_pressed.jpg
deleted file mode 100644
index 10ad0349..00000000
Binary files a/PD2Launcherv2/Resources/Images/loot_pressed.jpg and /dev/null differ
diff --git a/PD2Launcherv2/Resources/Images/minimize.jpg b/PD2Launcherv2/Resources/Images/minimize.jpg
index 3e730488..fa2df52c 100644
Binary files a/PD2Launcherv2/Resources/Images/minimize.jpg and b/PD2Launcherv2/Resources/Images/minimize.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/minimize_pressed.jpg b/PD2Launcherv2/Resources/Images/minimize_pressed.jpg
index 752c879b..d76aad2f 100644
Binary files a/PD2Launcherv2/Resources/Images/minimize_pressed.jpg and b/PD2Launcherv2/Resources/Images/minimize_pressed.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/offline24.png b/PD2Launcherv2/Resources/Images/offline24.png
new file mode 100644
index 00000000..926dc9a0
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/offline24.png differ
diff --git a/PD2Launcherv2/Resources/Images/options.jpg b/PD2Launcherv2/Resources/Images/options.jpg
deleted file mode 100644
index ba5ba8c4..00000000
Binary files a/PD2Launcherv2/Resources/Images/options.jpg and /dev/null differ
diff --git a/PD2Launcherv2/Resources/Images/options_pressed.jpg b/PD2Launcherv2/Resources/Images/options_pressed.jpg
deleted file mode 100644
index 1cd3f28d..00000000
Binary files a/PD2Launcherv2/Resources/Images/options_pressed.jpg and /dev/null differ
diff --git a/PD2Launcherv2/Resources/Images/play.jpg b/PD2Launcherv2/Resources/Images/play.jpg
deleted file mode 100644
index 7d8d4b46..00000000
Binary files a/PD2Launcherv2/Resources/Images/play.jpg and /dev/null differ
diff --git a/PD2Launcherv2/Resources/Images/play_pressed.jpg b/PD2Launcherv2/Resources/Images/play_pressed.jpg
deleted file mode 100644
index 0e1ab20f..00000000
Binary files a/PD2Launcherv2/Resources/Images/play_pressed.jpg and /dev/null differ
diff --git a/PD2Launcherv2/Resources/Images/thin_next.jpg b/PD2Launcherv2/Resources/Images/thin_next.jpg
index 9bad28b7..9949ae3b 100644
Binary files a/PD2Launcherv2/Resources/Images/thin_next.jpg and b/PD2Launcherv2/Resources/Images/thin_next.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/thin_prev.jpg b/PD2Launcherv2/Resources/Images/thin_prev.jpg
index 98728437..16645843 100644
Binary files a/PD2Launcherv2/Resources/Images/thin_prev.jpg and b/PD2Launcherv2/Resources/Images/thin_prev.jpg differ
diff --git a/PD2Launcherv2/Resources/Images/updating_disabled.jpg b/PD2Launcherv2/Resources/Images/updating_disabled.jpg
deleted file mode 100644
index 2f1344df..00000000
Binary files a/PD2Launcherv2/Resources/Images/updating_disabled.jpg and /dev/null differ
diff --git a/PD2Launcherv2/Resources/Images/winelogo16.png b/PD2Launcherv2/Resources/Images/winelogo16.png
new file mode 100644
index 00000000..8d4fce56
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/winelogo16.png differ
diff --git a/PD2Launcherv2/Resources/Images/winelogo32.png b/PD2Launcherv2/Resources/Images/winelogo32.png
new file mode 100644
index 00000000..450a1111
Binary files /dev/null and b/PD2Launcherv2/Resources/Images/winelogo32.png differ
diff --git a/PD2Launcherv2/Resources/Styles/Colors.xaml b/PD2Launcherv2/Resources/Styles/Colors.xaml
index 54690ae6..7e8b2172 100644
--- a/PD2Launcherv2/Resources/Styles/Colors.xaml
+++ b/PD2Launcherv2/Resources/Styles/Colors.xaml
@@ -5,10 +5,16 @@
#FFA78E65
#FFB59E76
#FF947A56
+ #FFA7656D
+ #FFB5767D
+ #FF945661
#007759
+
+
+
#998363
#b39a74
diff --git a/PD2Launcherv2/Resources/Styles/ControlStyles.xaml b/PD2Launcherv2/Resources/Styles/ControlStyles.xaml
index 00bb549f..4663ef10 100644
--- a/PD2Launcherv2/Resources/Styles/ControlStyles.xaml
+++ b/PD2Launcherv2/Resources/Styles/ControlStyles.xaml
@@ -1,14 +1,17 @@
+ xmlns:local="clr-namespace:PD2Launcherv2.CustomControl"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:comment="https://whatever/comment"
+ mc:Ignorable="comment">
-
-
+
+
+
+
+
+
+
+
+
+
-
+
+
@@ -429,7 +651,7 @@
-
+
@@ -438,10 +660,10 @@
-
+
@@ -457,14 +679,14 @@
diff --git a/PD2Launcherv2/SanityChecks.cs b/PD2Launcherv2/SanityChecks.cs
new file mode 100644
index 00000000..c64583cb
--- /dev/null
+++ b/PD2Launcherv2/SanityChecks.cs
@@ -0,0 +1,152 @@
+using System.Text;
+using System.Windows;
+using PD2Launcherv2.Utils;
+using PD2Launcherv2.Utils.Gl;
+using PD2Shared.Logging;
+using static PD2Shared.Logging.LoggingStatic;
+using PD2Shared.Utils;
+
+namespace PD2Launcherv2
+{
+ internal static class SanityChecks
+ {
+ private static bool FailurePrompt(string message)
+ {
+ if (MsgBox.Warn(
+ message + "\n" +
+ "\n" +
+ "Continue regardless?",
+ MessageBoxButton.YesNo,
+ MessageBoxResult.No) == MessageBoxResult.No)
+ {
+ return false;
+ }
+
+ L.CallerWarning("User ignored sanity check failure.");
+ return true;
+ }
+
+ private static bool CheckGameDirPathEncoding()
+ {
+ string gameDirPath = Env.GetCwd();
+
+ var encodingAltNames = new string[] { Env.AnsiEncoding.WebName, Env.AnsiEncoding.BodyName, Env.AnsiEncoding.HeaderName }
+ .Where(n => !string.IsNullOrEmpty(n))
+ .Distinct()
+ .Select(n => $"'{n}'");
+ string encodingDisplayName = $"'{Env.AnsiEncoding.EncodingName}' (aka {string.Join(", ", encodingAltNames)})";
+
+ string roundTrip = Env.AnsiEncoding.GetString(Encoding.Convert(Encoding.Unicode, Env.AnsiEncoding, Encoding.Unicode.GetBytes(gameDirPath)));
+
+ if (roundTrip != gameDirPath)
+ {
+ L.CallerWarning($"Game directory '{gameDirPath}' cannot be represented in system-default ANSI encoding: {encodingDisplayName}.");
+
+ if (!FailurePrompt(
+ $"Game directory '{gameDirPath}' cannot be represented in system-default ANSI encoding: {encodingDisplayName}.\n" +
+ "\n" +
+ $"Problematic characters: '{new string(gameDirPath.Except(roundTrip).Distinct().ToArray())}'.\n" +
+ "\n" +
+ "This will likely cause PD2 to crash."))
+ {
+ return false;
+ }
+ }
+ else
+ {
+ L.CallerInformation($"Game directory '{gameDirPath}' can be represented in system-default ANSI encoding: {encodingDisplayName}.");
+ }
+
+ return true;
+ }
+
+ private static bool CheckIfDirectoriesWritable()
+ {
+ {
+ string dirPath = Env.ProcessDirPath;
+
+ var ex = Env.CheckIfDirectoryIsWritable(dirPath);
+
+ if (ex != null)
+ {
+ L.CallerWarning(ex, $"Launcher directory '{dirPath}' is not writable.");
+
+ if (!FailurePrompt(
+ $"Launcher directory '{dirPath}' is not writable.\n" +
+ "\n" +
+ "This can lead to unexpected issues."))
+ {
+ return false;
+ }
+ }
+ else
+ {
+ L.CallerInformation($"Launcher directory '{dirPath}' is writable.");
+ }
+ }
+
+ {
+ string dirPath = Env.GetCwd();
+
+ var ex = Env.CheckIfDirectoryIsWritable(dirPath);
+
+ if (ex != null)
+ {
+ L.CallerWarning(ex, $"Launcher working directory '{dirPath}' is not writable.");
+
+ if (!FailurePrompt(
+ $"Launcher working directory '{dirPath}' is not writable.\n" +
+ "\n" +
+ "This can lead to unexpected issues."))
+ {
+ return false;
+ }
+ }
+ else
+ {
+ L.CallerInformation($"Launcher working directory '{dirPath}' is writable.");
+ }
+ }
+
+ return true;
+ }
+
+ private static bool TestGlContext()
+ {
+ using LoggedRoutine loggedRoutine = new();
+
+ // Force creating GL contexts
+ _ = GlTest.BestCtx;
+
+ // This is too early to complain about any issues with GL.
+ // Evaluate GlTest.BestCtx in light of current launcher options in MainWindow.
+ return true;
+ }
+
+ public static bool Run()
+ {
+ List> sanityChecks = new()
+ {
+ () => CheckGameDirPathEncoding(),
+ () => CheckIfDirectoriesWritable(),
+ () => TestGlContext()
+ };
+
+ using LoggedScope loggedScope = new($"Running {sanityChecks.Count} sanity check(s)...");
+
+ for (int i = 0; i < sanityChecks.Count; ++i)
+ {
+ var check = sanityChecks[i];
+
+ L.CallerInformation($"> {i + 1}/{sanityChecks.Count}");
+
+ if (!check())
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/PD2Launcherv2/Utils/Gl/GlCtxInfo.cs b/PD2Launcherv2/Utils/Gl/GlCtxInfo.cs
new file mode 100644
index 00000000..cc184223
--- /dev/null
+++ b/PD2Launcherv2/Utils/Gl/GlCtxInfo.cs
@@ -0,0 +1,68 @@
+namespace PD2Launcherv2.Utils.Gl
+{
+ public class GlCtxInfo
+ {
+ public GlCtxInfo(
+ Version? version,
+ string glVendor,
+ string glRenderer,
+ string glVersion,
+ string glShadingLanguageVersion,
+
+ bool? isForwardCompatible,
+ bool? isDebug,
+ bool? isRobust,
+ bool? isNoError,
+
+ bool? isCore,
+ bool? isCompatibility
+ )
+ {
+ Version = version;
+
+ GlVendor = glVendor;
+ GlRenderer = glRenderer;
+ GlVersion = glVersion;
+ GlShadingLanguageVersion = glShadingLanguageVersion;
+
+ IsForwardCompatible = isForwardCompatible;
+ IsDebug = isDebug;
+ IsRobust = isRobust;
+ IsNoError = isNoError;
+
+ IsCore = isCore;
+ IsCompatibility = isCompatibility;
+ }
+
+ public Version? Version { get; }
+
+ public string GlVendor { get; }
+ public string GlRenderer { get; }
+ public string GlVersion { get; }
+ public string GlShadingLanguageVersion { get; }
+
+ public bool? IsForwardCompatible { get; }
+ public bool? IsDebug { get; }
+ public bool? IsRobust { get; }
+ public bool? IsNoError { get; }
+
+ public bool? IsCore { get; }
+ public bool? IsCompatibility { get; }
+
+ public string[] ToLines()
+ {
+ return new string[] {
+ $"{(Version?.ToString() ?? "Unknown version")}{(IsCore == true ? " Core" : IsCompatibility == true ? " Compatibility" : string.Empty)}{(IsForwardCompatible == true ? " Forward-compatible" : string.Empty)}",
+ $"GL_VERSION: {GlVersion}",
+ $"GL_RENDERER: {GlRenderer}",
+ $"GL_VENDOR: {GlVendor}",
+ $"GL_SHADING_LANGUAGE_VERSION: {GlShadingLanguageVersion}"
+ };
+ }
+
+ public override string ToString()
+ {
+ return string.Join('\n', ToLines());
+ }
+ }
+}
diff --git a/PD2Launcherv2/Utils/Gl/GlTest.cs b/PD2Launcherv2/Utils/Gl/GlTest.cs
new file mode 100644
index 00000000..396bb190
--- /dev/null
+++ b/PD2Launcherv2/Utils/Gl/GlTest.cs
@@ -0,0 +1,387 @@
+using System.Runtime.InteropServices;
+using PD2Launcherv2.Utils.Gl.Internal;
+using PD2Shared.Logging;
+using static PD2Shared.Logging.LoggingStatic;
+using PD2Shared.Utils;
+
+namespace PD2Launcherv2.Utils.Gl
+{
+ public static class GlTest
+ {
+ // Tracks GetBestContext() progress to determine if an occurring failure deems GL driver unusable
+ public enum BestContextStage
+ {
+ None,
+
+ // Exceptions thrown here indicate GL failure (see: BestContextStageEx.IndicatesGlFailure())
+ ForceLoadingOpenGLDll,
+
+ WindowCreation,
+
+ // Exceptions thrown here indicate GL failure (see: BestContextStageEx.IndicatesGlFailure())
+ PixelFormatSelection,
+ InitialCtxCreation,
+ GotInitialCtx,
+ BestCtxCreation,
+
+ GotBestCtx,
+ LeftWithInitialCtx
+ }
+
+ public class BestCtxInfo
+ {
+ public GlCtxInfo? GlCtxInfo { get; init; }
+ public Exception? Exception { get; init; }
+ public BestContextStage StageReached { get; init; }
+ }
+
+ private static BestCtxInfo? _bestContext = null;
+ public static BestCtxInfo BestCtx
+ {
+ get
+ {
+ if (_bestContext == null)
+ {
+ BestContextStage stageReached = BestContextStage.None;
+
+ try
+ {
+ _bestContext = new()
+ {
+ GlCtxInfo = GetBestContext(out stageReached),
+ Exception = null,
+ StageReached = stageReached
+ };
+
+ if (stageReached < BestContextStage.GotBestCtx)
+ {
+ L.CallerWarning($"{nameof(GetBestContext)}() reached '{stageReached}'");
+ }
+ }
+ catch (Exception ex)
+ {
+ L.CallerWarning(ex, $"{nameof(GetBestContext)}() failed after reaching '{stageReached}'");
+
+ _bestContext = new()
+ {
+ GlCtxInfo = null,
+ Exception = ex,
+ StageReached = stageReached
+ };
+ }
+ }
+
+ return _bestContext;
+ }
+ }
+
+ private static GlCtxInfo GetBestContext(out BestContextStage stage)
+ {
+ ushort classAtom = 0;
+ IntPtr hwnd = IntPtr.Zero;
+ IntPtr hdc = IntPtr.Zero;
+ IntPtr hCtx = IntPtr.Zero;
+
+ try
+ {
+ stage = BestContextStage.ForceLoadingOpenGLDll;
+
+ // Force load opengl.dll to trigger any of its static initializers.
+ // Otherwise, WinAPI-provided WGL might misbehave if this isn't done before setting pixel format.
+ //
+ // This might as well throw an EntryPointNotFoundException exception here.
+ _ = OpenGLDll.wglGetCurrentContext();
+
+ stage = BestContextStage.WindowCreation;
+
+ // Window creation loosely based on OpenGL.Net (https://github.com/luca-piccioni/OpenGL.Net/blob/master/OpenGL.Net/DeviceContextWGL.cs#L205)
+
+ DllImports.WNDCLASSEX windowClass = new("Hidden GL ctx window")
+ {
+ style = DllImports.ClassStyles.CS_OWNDC
+ };
+
+ classAtom = DllImports.RegisterClassEx(windowClass);
+
+ if (classAtom == 0)
+ {
+ throw Win32.GetLastException(nameof(DllImports.RegisterClassEx));
+ }
+
+ hwnd = DllImports.CreateWindowEx(
+ dwExStyle: 0,
+ windowClass.lpszClassName,
+ lpWindowName: string.Empty,
+ dwStyle: 0,
+ x: 0,
+ y: 0,
+ nWidth: 0,
+ nHeight: 0,
+ hWndParent: IntPtr.Zero,
+ hMenu: IntPtr.Zero,
+ windowClass.hInstance,
+ lpParam: IntPtr.Zero
+ );
+
+ if (hwnd == IntPtr.Zero)
+ {
+ throw Win32.GetLastException(nameof(DllImports.CreateWindowEx));
+ }
+
+ hdc = DllImports.GetDC(hwnd);
+
+ if (hdc == IntPtr.Zero)
+ {
+ throw new Exception($"{nameof(DllImports.GetDC)}() failed.");
+ }
+
+ stage = BestContextStage.PixelFormatSelection;
+
+ // Pick the most sane pixel format (similar to D2GL) (https://github.com/bayaraa/d2gl/blob/master/d2gl/src/graphic/context.cpp#L37)
+ DllImports.PIXELFORMATDESCRIPTOR pfd = new()
+ {
+ dwFlags = 0
+ | DllImports.PfdFlags.PFD_DRAW_TO_WINDOW
+ | DllImports.PfdFlags.PFD_DOUBLEBUFFER
+ | DllImports.PfdFlags.PFD_SUPPORT_OPENGL
+ | DllImports.PfdFlags.PFD_GENERIC_ACCELERATED,
+ iPixelType = DllImports.PfdPixelType.PFD_TYPE_RGBA,
+ cColorBits = 32,
+ cDepthBits = 24,
+ cStencilBits = 8,
+ iLayerType = DllImports.PfdLayerType.PFD_MAIN_PLANE,
+ };
+
+ int pixelFormat = DllImports.ChoosePixelFormat(hdc, pfd);
+
+ if (pixelFormat == 0)
+ {
+ throw Win32.GetLastException(nameof(DllImports.ChoosePixelFormat));
+ }
+
+ if (DllImports.DescribePixelFormat(hdc, pixelFormat, pfd) == 0)
+ {
+ throw Win32.GetLastException(nameof(DllImports.DescribePixelFormat));
+ }
+
+ L.CallerInformation($"Chosen pixel format: {pfd.Describe()} ({pixelFormat})");
+
+ if (!DllImports.SetPixelFormat(hdc, pixelFormat, pfd))
+ {
+ throw Win32.GetLastException(nameof(DllImports.SetPixelFormat));
+ }
+
+ stage = BestContextStage.InitialCtxCreation;
+
+ hCtx = OpenGLDll.wglCreateContext(hdc);
+
+ if (hCtx == IntPtr.Zero)
+ {
+ throw Win32.GetLastException(nameof(OpenGLDll.wglCreateContext));
+ }
+
+ using Ctx initCtx = new(ref hCtx, hdc);
+
+ // Preemptively load all GL functions explicitly.
+ // If this fails, not even getting GlCtxInfo is possible, which would be VERY unlikely, but still...
+ try
+ {
+ initCtx.LoadGlFunctions();
+ }
+ catch (EntryPointNotFoundException)
+ {
+ L.CallerError("Unable to load required GL functions for the initial context.");
+
+ throw;
+ }
+
+ L.CallerInformation("Initial GL context:");
+ foreach (var line in initCtx.Info.ToLines())
+ {
+ L.CallerInformation($"> {line}");
+ }
+
+ stage = BestContextStage.GotInitialCtx;
+
+ // Preemptively load all WGL functions explicitly.
+ // If this fails, it's impossible to create a better context and might as well bail.
+ try
+ {
+ initCtx.LoadWglFunctions();
+ }
+ catch (EntryPointNotFoundException ex)
+ {
+ L.CallerError(ex, "Unable to load required WGL functions for the initial context.");
+ L.CallerWarning("No better context can be created. Using initial GL context as best context.");
+
+ return initCtx.Info;
+ }
+
+ if (!(initCtx.Info.Version >= new Version(3, 2) || initCtx.WglExtensions.Contains("WGL_ARB_create_context_profile")))
+ {
+ L.CallerWarning("Initial GL context is neither >=3.2, nor supports WGL_ARB_create_context_profile.");
+ L.CallerWarning("No better context can be created. Using initial GL context as best context.");
+
+ return initCtx.Info;
+ }
+
+ stage = BestContextStage.BestCtxCreation;
+
+ // Test creating contexts D2GL would normally request (https://github.com/bayaraa/d2gl/blob/master/d2gl/src/graphic/context.cpp#L63)
+ foreach (Version version in new Version[] {
+ new(4, 6),
+ new(4, 5),
+ new(4, 4),
+ new(4, 3),
+ new(4, 2),
+ new(4, 1),
+ new(4, 0),
+ new(3, 3),
+ })
+ {
+ L.CallerDebug($"Attempting to create a {version} Core context...");
+
+ initCtx.MakeCurrent();
+ hCtx = initCtx.wglCreateContextAttribsARB(
+ hdc,
+ WglContextAttribs.WGL_CONTEXT_MAJOR_VERSION_ARB, version.Major,
+ WglContextAttribs.WGL_CONTEXT_MINOR_VERSION_ARB, version.Minor,
+ WglContextAttribs.WGL_CONTEXT_PROFILE_MASK_ARB, WglContextAttribs.WGL_CONTEXT_CORE_PROFILE_BIT_ARB,
+ // According to: https://wikis.khronos.org/opengl/OpenGL_Context#Forward_compatibility
+ // requesting forward compatibility for contexts >= 3.3 makes little sense...
+ // Still, keep this attribute for parity with D2GL.
+ WglContextAttribs.WGL_CONTEXT_FLAGS_ARB, WglContextAttribs.WGL_CONTEXT_FORWARD_COMPATIBLE_BIT_ARB
+ );
+
+ if (hCtx != IntPtr.Zero)
+ {
+ using Ctx bestCtx = new(ref hCtx, hdc);
+
+ // Preemptively load all GL functions explicitly.
+ // If this fails, GlCtxInfo won't work, so might as well ignore this context.
+ try
+ {
+ bestCtx.LoadGlFunctions();
+ }
+ catch (EntryPointNotFoundException ex)
+ {
+ L.CallerError(ex, $"Unable to load required GL functions for this {version} context. Skipping context.");
+ continue;
+ }
+
+ var parsedVersion = bestCtx.Info.Version;
+
+ if (parsedVersion == null)
+ {
+ L.CallerWarning($"Unable to determine the exact version of this {version} context:");
+
+ foreach (var line in bestCtx.Info.ToLines())
+ {
+ L.CallerWarning($"> {line}");
+ }
+ L.CallerWarning("Skipping context.");
+
+ continue;
+ }
+ else if (parsedVersion != version && parsedVersion != new Version(version.Major, version.Minor, 0))
+ {
+ L.CallerWarning($"This {version} context is in fact {parsedVersion}. Skipping context.");
+
+ continue;
+ }
+
+ stage = BestContextStage.GotBestCtx;
+
+ L.CallerInformation("Best GL context:");
+ foreach (var line in bestCtx.Info.ToLines())
+ {
+ L.CallerInformation($"> {line}");
+ }
+
+ return bestCtx.Info;
+ }
+ else
+ {
+ var lastError = Marshal.GetLastWin32Error();
+
+ string mainMsg = $"Failed to create {version} Core context";
+
+ // wglCreateContextAttribsARB() fails yet GetLastError() reports ERROR_SUCCESS. Weird, but not impossible.
+ if (lastError == Win32.ERROR_SUCCESS)
+ {
+ L.CallerWarning(mainMsg);
+ }
+ else
+ {
+ var asLastGLerror = (LastGLerror)lastError;
+
+ if (Enum.GetValues().Contains(asLastGLerror))
+ {
+ L.CallerWarning($"{mainMsg}: {asLastGLerror}");
+ }
+ else
+ {
+ L.CallerWarning($"{mainMsg}: {Win32.GetLastErrorMessage(lastError)}.");
+ }
+ }
+ }
+ }
+
+ // At this point, the initial context is the best we got.
+ stage = BestContextStage.LeftWithInitialCtx;
+
+ L.CallerWarning("No better context could be created. Using initial GL context as best context.");
+ return initCtx.Info;
+ }
+ finally
+ {
+ if (hCtx != IntPtr.Zero)
+ {
+ if (!OpenGLDll.wglDeleteContext(hCtx))
+ {
+ L.CallerError(Win32.GetLastErrorMessage(nameof(OpenGLDll.wglDeleteContext)));
+ }
+ }
+
+ if (hdc != IntPtr.Zero)
+ {
+ if (!DllImports.ReleaseDC(hwnd, hdc))
+ {
+ L.CallerError($"{nameof(DllImports.ReleaseDC)}() failed.");
+ }
+ }
+
+ if (hwnd != IntPtr.Zero)
+ {
+ if (!DllImports.DestroyWindow(hwnd))
+ {
+ L.CallerError(Win32.GetLastErrorMessage(nameof(DllImports.DestroyWindow)));
+ }
+ }
+
+ if (classAtom != 0)
+ {
+ if (!DllImports.UnregisterClass(classAtom, DllImports.HThisInstance))
+ {
+ L.CallerError(Win32.GetLastErrorMessage(nameof(DllImports.UnregisterClass)));
+ }
+ }
+ }
+ }
+ }
+
+ // Extension methods:
+
+ public static class BestContextStageEx
+ {
+ public static bool IndicatesGlFailure(this GlTest.BestContextStage stage)
+ {
+ return false
+ || stage == GlTest.BestContextStage.ForceLoadingOpenGLDll
+ || stage == GlTest.BestContextStage.PixelFormatSelection
+ || stage == GlTest.BestContextStage.InitialCtxCreation
+ || stage == GlTest.BestContextStage.GotInitialCtx
+ || stage == GlTest.BestContextStage.BestCtxCreation;
+ }
+ }
+}
diff --git a/PD2Launcherv2/Utils/Gl/Internal/Ctx.cs b/PD2Launcherv2/Utils/Gl/Internal/Ctx.cs
new file mode 100644
index 00000000..c903f822
--- /dev/null
+++ b/PD2Launcherv2/Utils/Gl/Internal/Ctx.cs
@@ -0,0 +1,369 @@
+using System.Collections.Immutable;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
+using PD2Shared.Logging;
+using static PD2Shared.Logging.LoggingStatic;
+using PD2Shared.Utils;
+
+namespace PD2Launcherv2.Utils.Gl.Internal
+{
+ internal class Ctx : IDisposable
+ {
+ // A regex to parse the GL_VERSION string
+ // (https://wikis.khronos.org/opengl/OpenGL_Context#Context_information_queries)
+ // (https://wikis.khronos.org/opengl/GLAPI/glGetString#Description)
+ private static readonly Regex GlVersionRegex = new(@"^(\d+\.\d+(?:\.\d+)?)", RegexOptions.Compiled);
+
+ private static IntPtr _hOpenGLDll = IntPtr.Zero;
+
+ private readonly CtxDelegates _delegates = new();
+ private bool _wglFunctionsLoaded = false;
+ private bool _glFunctionsLoaded = false;
+
+ private bool _disposed = false;
+ private readonly IntPtr _hRenderCtx;
+ private readonly IntPtr _hdc;
+
+ public Ctx(ref IntPtr hRenderCtx, IntPtr hdc)
+ {
+ if (hRenderCtx == IntPtr.Zero)
+ {
+ throw new ArgumentException($"'{nameof(hRenderCtx)}' is invalid.", nameof(hRenderCtx));
+ }
+
+ _hdc = hdc;
+
+ // Take ownership of the handle
+ _hRenderCtx = hRenderCtx;
+ hRenderCtx = IntPtr.Zero;
+ }
+
+ // Context operations
+
+ private GlCtxInfo? _info = null;
+ public GlCtxInfo Info
+ {
+ get
+ {
+ if (_info == null)
+ {
+ LoadGlFunctions();
+
+ // Since it is unknown whether this is a >=3.0 context, don't use glGetIntegerv() with GL_MAJOR_VERSION and GL_MINOR_VERSION.
+ // Just resort to parsing GL_VERSION in a classic fashion.
+
+ FlushGlError();
+
+ string? glVersionString = glGetString(GLenum.GL_VERSION);
+ Version? version = null;
+
+ if (glVersionString == null)
+ {
+ L.CallerError($"{nameof(glGetString)}({GLenum.GL_VERSION}) failed with {glGetError()}.");
+ }
+ else
+ {
+ var regexMatch = GlVersionRegex.Match(glVersionString);
+
+ if (!regexMatch.Success || !Version.TryParse(regexMatch.ValueSpan, out version))
+ {
+ L.CallerWarning($"Failed to parse version number from GL_VERSION string: '{glVersionString}'.");
+ }
+ }
+
+ // Additional context information (https://wikis.khronos.org/opengl/OpenGL_Context#Context_flags)
+
+ bool? isForwardCompatible = null;
+ bool? isDebug = null;
+ bool? isRobust = null;
+ bool? isNoError = null;
+
+ bool? isCore = null;
+ bool? isCompatibility = null;
+
+ if (version != null)
+ {
+ if (version >= new Version(3, 0))
+ {
+ if (glGetIntegerv(GLenum.GL_CONTEXT_FLAGS, out int flags) == GLenumError.GL_NO_ERROR)
+ {
+ var flagsAsGLenum = (GLenum)flags;
+
+ isForwardCompatible = flagsAsGLenum.HasFlag(GLenum.GL_CONTEXT_FLAG_FORWARD_COMPATIBLE_BIT);
+ isDebug = flagsAsGLenum.HasFlag(GLenum.GL_CONTEXT_FLAG_DEBUG_BIT);
+ isRobust = flagsAsGLenum.HasFlag(GLenum.GL_CONTEXT_FLAG_ROBUST_ACCESS_BIT);
+ isNoError = flagsAsGLenum.HasFlag(GLenum.GL_CONTEXT_FLAG_NO_ERROR_BIT);
+ }
+ else
+ {
+ L.CallerError($"{nameof(glGetIntegerv)}({GLenum.GL_CONTEXT_FLAGS}) failed.");
+ }
+ }
+
+ if (version >= new Version(3, 2))
+ {
+ if (glGetIntegerv(GLenum.GL_CONTEXT_PROFILE_MASK, out int mask) == GLenumError.GL_NO_ERROR)
+ {
+ var maskAsGLenum = (GLenum)mask;
+
+ isCore = maskAsGLenum.HasFlag(GLenum.GL_CONTEXT_CORE_PROFILE_BIT);
+ isCompatibility = maskAsGLenum.HasFlag(GLenum.GL_CONTEXT_COMPATIBILITY_PROFILE_BIT);
+ }
+ else
+ {
+ L.CallerError($"{nameof(glGetIntegerv)}({GLenum.GL_CONTEXT_PROFILE_MASK}) failed.");
+ }
+ }
+ }
+
+ string glGetStringOrDefault(GLenum name)
+ {
+ string? res = glGetString(name);
+
+ if (res == null)
+ {
+ L.CallerError($"{nameof(glGetString)}({name}) failed with {glGetError()}.");
+
+ return string.Empty;
+ }
+
+ return res;
+ }
+
+ _info = new GlCtxInfo(
+ version: version,
+ glVendor: glGetStringOrDefault(GLenum.GL_VENDOR),
+ glRenderer: glGetStringOrDefault(GLenum.GL_RENDERER),
+ glVersion: glVersionString ?? string.Empty,
+ glShadingLanguageVersion: glGetStringOrDefault(GLenum.GL_SHADING_LANGUAGE_VERSION),
+
+ isForwardCompatible: isForwardCompatible,
+ isDebug: isDebug,
+ isRobust: isRobust,
+ isNoError: isNoError,
+
+ isCore: isCore,
+ isCompatibility: isCompatibility
+ );
+ }
+
+ return _info;
+ }
+ }
+
+ public void MakeCurrent()
+ {
+ if (!OpenGLDll.wglMakeCurrent(_hdc, _hRenderCtx))
+ {
+ throw Win32.GetLastException(nameof(OpenGLDll.wglMakeCurrent));
+ }
+ }
+
+ // Function/extension loading
+
+ private void LoadFunctions(Func predicate)
+ {
+ // A sane approach to this would be to use an established utility library.
+ //
+ // However, given how narrow the scope is, (with no need to specify/resolve extension and GL version relationships
+ // except for WGL_ARB_create_context_profile which doesn't even specify additional entry points) -- attempt to
+ // load this handful of functions manually.
+
+ MakeCurrent();
+
+ SortedSet failedFunctions = new();
+
+ foreach (var f in _delegates.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
+ .Where(f => f.FieldType.IsSubclassOf(typeof(Delegate)))
+ .Where(f => predicate(f))
+ )
+ {
+ // Strip the leading '_'
+ string funcName = f.Name[1..];
+
+ IntPtr funcPtr = OpenGLDll.wglGetProcAddress(funcName);
+
+ // https://wikis.khronos.org/opengl/Load_OpenGL_Functions#Windows
+ if (funcPtr == IntPtr.Zero ||
+ funcPtr == (IntPtr)1 ||
+ funcPtr == (IntPtr)2 ||
+ funcPtr == (IntPtr)3 ||
+ funcPtr == (IntPtr)(-1)
+ )
+ {
+ // It is not uncommon for the call to not return a valid function pointer yet GetLastError() to return ERROR_SUCCESS
+ if (Marshal.GetLastWin32Error() != Win32.ERROR_SUCCESS)
+ {
+ L.CallerError(Win32.GetLastErrorMessage(nameof(OpenGLDll.wglGetProcAddress), funcName));
+ }
+
+ // Attempt to use native GetProcAddress() instead (https://wikis.khronos.org/opengl/Load_OpenGL_Functions#Windows)
+
+ if (_hOpenGLDll == IntPtr.Zero)
+ {
+ _hOpenGLDll = DllImports.LoadLibrary(OpenGLDll.LibraryName);
+
+ if (_hOpenGLDll == IntPtr.Zero)
+ {
+ throw Win32.GetLastException(nameof(DllImports.LoadLibrary), OpenGLDll.LibraryName);
+ }
+ }
+
+ funcPtr = DllImports.GetProcAddress(_hOpenGLDll, funcName);
+
+ if (funcPtr != IntPtr.Zero)
+ {
+ L.CallerWarning($"{funcName}() resolved via {nameof(DllImports.GetProcAddress)}()");
+ }
+ else if (Marshal.GetLastWin32Error() != Win32.ERROR_SUCCESS)
+ {
+ L.CallerError(Win32.GetLastErrorMessage(nameof(DllImports.GetProcAddress), funcName));
+ }
+ }
+
+ if (funcPtr != IntPtr.Zero)
+ {
+ f.SetValue(_delegates, Marshal.GetDelegateForFunctionPointer(funcPtr, f.FieldType));
+ }
+ else
+ {
+ failedFunctions.Add(funcName);
+ L.CallerError($"{funcName}() could not be resolved");
+ }
+ }
+
+ if (failedFunctions.Any())
+ {
+ throw new EntryPointNotFoundException($"Failed to resolve {failedFunctions.Count} function(s): {string.Join("; ", failedFunctions)}");
+ }
+ }
+
+ public void LoadWglFunctions()
+ {
+ if (_wglFunctionsLoaded)
+ {
+ return;
+ }
+
+ _wglFunctionsLoaded = true;
+
+ LoadFunctions(fieldInfo => fieldInfo.Name.StartsWith("_wgl"));
+ }
+
+ public void LoadGlFunctions()
+ {
+ if (_glFunctionsLoaded)
+ {
+ return;
+ }
+
+ _glFunctionsLoaded = true;
+
+ LoadFunctions(fieldInfo => fieldInfo.Name.StartsWith("_gl"));
+ }
+
+ private ImmutableHashSet? _wglExtensions = null;
+ public ImmutableHashSet WglExtensions
+ {
+ get
+ {
+ if (_wglExtensions == null)
+ {
+ LoadWglFunctions();
+
+ string? extensionsString = wglGetExtensionsStringARB(_hdc) ?? throw Win32.GetLastException(nameof(wglGetExtensionsStringARB));
+
+ _wglExtensions = ImmutableHashSet.Create(extensionsString.Split());
+ }
+
+ return _wglExtensions;
+ }
+ }
+
+ // OpenGL core profile and ARB extension interfaces
+
+ public GLenumError glGetError()
+ {
+ return _delegates.glGetError();
+ }
+
+ public GLenumError glGetIntegerv(GLenum pname, out int data)
+ {
+ _delegates.glGetIntegerv(pname, out data);
+
+ return glGetError();
+ }
+
+ public string? glGetString(GLenum name)
+ {
+ return Marshal.PtrToStringAnsi(_delegates.glGetString(name));
+ }
+
+ // WGL_ARB_create_context
+
+ public IntPtr wglCreateContextAttribsARB(IntPtr hdc, params object[] attribList)
+ {
+ // Assume every attribList element is convertible to int.
+ // Additionally, always append the mandatory NULL to terminate the list.
+ return _delegates.wglCreateContextAttribsARB(hdc, hShareContext: IntPtr.Zero, attribList
+ .Select(a => (int)a)
+ .Append(0)
+ .ToArray()
+ );
+ }
+
+ // WGL_ARB_extensions_string
+
+ public string? wglGetExtensionsStringARB(IntPtr hdc)
+ {
+ return Marshal.PtrToStringAnsi(_delegates.wglGetExtensionsStringARB(hdc));
+ }
+
+ // Helpers
+
+ public void FlushGlError()
+ {
+ GLenumError error;
+
+ do
+ {
+ error = glGetError();
+ }
+ while (error != GLenumError.GL_NO_ERROR);
+ }
+
+ // IDisposable interface
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing)
+ {
+ // TODO: dispose managed state (managed objects)
+ }
+
+ if (!OpenGLDll.wglDeleteContext(_hRenderCtx))
+ {
+ L.CallerError(Win32.GetLastErrorMessage(nameof(OpenGLDll.wglDeleteContext)));
+ }
+
+ _disposed = true;
+ }
+ }
+
+ ~Ctx()
+ {
+ // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+ Dispose(disposing: false);
+ }
+
+ public void Dispose()
+ {
+ // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git a/PD2Launcherv2/Utils/Gl/Internal/CtxDelegates.cs b/PD2Launcherv2/Utils/Gl/Internal/CtxDelegates.cs
new file mode 100644
index 00000000..2d9064ce
--- /dev/null
+++ b/PD2Launcherv2/Utils/Gl/Internal/CtxDelegates.cs
@@ -0,0 +1,73 @@
+using System.Runtime.InteropServices;
+
+namespace PD2Launcherv2.Utils.Gl.Internal
+{
+ internal class CtxDelegates
+ {
+ // OpenGL core profile and ARB extension interfaces (https://registry.khronos.org/OpenGL/api/GL/glcorearb.h)
+
+ protected delegate GLenumError glGetErrorDelegate();
+ protected glGetErrorDelegate? _glGetError = null;
+ public GLenumError glGetError()
+ {
+ if (_glGetError == null)
+ {
+ throw new EntryPointNotFoundException(nameof(glGetError));
+ }
+
+ return _glGetError();
+ }
+
+ protected delegate void glGetIntegervDelegate(GLenum pname, out int data);
+ protected glGetIntegervDelegate? _glGetIntegerv = null;
+ public void glGetIntegerv(GLenum pname, out int data)
+ {
+ if (_glGetIntegerv == null)
+ {
+ throw new NotImplementedException(nameof(glGetIntegerv));
+ }
+
+ _glGetIntegerv(pname, out data);
+ }
+
+ protected delegate IntPtr glGetStringDelegate(GLenum name);
+ protected glGetStringDelegate? _glGetString = null;
+ public IntPtr glGetString(GLenum name)
+ {
+ if (_glGetString == null)
+ {
+ throw new NotImplementedException(nameof(glGetString));
+ }
+
+ return _glGetString(name);
+ }
+
+ // WGL_ARB_create_context (https://registry.khronos.org/OpenGL/extensions/ARB/WGL_ARB_create_context.txt)
+
+ protected delegate IntPtr wglCreateContextAttribsARBDelegate(IntPtr hdc, IntPtr hShareContext, [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.I4)][In] int[] attribList);
+ protected wglCreateContextAttribsARBDelegate? _wglCreateContextAttribsARB = null;
+ public IntPtr wglCreateContextAttribsARB(IntPtr hdc, IntPtr hShareContext, int[] attribList)
+ {
+ if (_wglCreateContextAttribsARB == null)
+ {
+ throw new NotImplementedException(nameof(wglCreateContextAttribsARB));
+ }
+
+ return _wglCreateContextAttribsARB(hdc, hShareContext, attribList);
+ }
+
+ // WGL_ARB_extensions_string (https://registry.khronos.org/OpenGL/extensions/ARB/WGL_ARB_extensions_string.txt)
+
+ protected delegate IntPtr wglGetExtensionsStringARBDelegate(IntPtr hdc);
+ protected wglGetExtensionsStringARBDelegate? _wglGetExtensionsStringARB = null;
+ public IntPtr wglGetExtensionsStringARB(IntPtr hdc)
+ {
+ if (_wglGetExtensionsStringARB == null)
+ {
+ throw new NotImplementedException(nameof(wglGetExtensionsStringARB));
+ }
+
+ return _wglGetExtensionsStringARB(hdc);
+ }
+ }
+}
diff --git a/PD2Launcherv2/Utils/Gl/Internal/DllImports.cs b/PD2Launcherv2/Utils/Gl/Internal/DllImports.cs
new file mode 100644
index 00000000..2b260204
--- /dev/null
+++ b/PD2Launcherv2/Utils/Gl/Internal/DllImports.cs
@@ -0,0 +1,274 @@
+using System.Runtime.InteropServices;
+using PD2Shared.Utils;
+
+namespace PD2Launcherv2.Utils.Gl.Internal
+{
+ internal static class DllImports
+ {
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ public static extern IntPtr GetModuleHandle([Optional] string? lpModuleName);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ public static extern IntPtr LoadLibrary(string lpLibFileName);
+
+ [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true, CharSet = CharSet.Ansi)]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization",
+ "CA2101:Specify marshaling for P/Invoke string arguments",
+ Justification = "ANSI strings are expected, thus CharSet.Ansi is the correct choice.")]
+ public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
+
+ private static IntPtr _hThisInstance = IntPtr.Zero;
+ public static IntPtr HThisInstance
+ {
+ get
+ {
+ if (_hThisInstance == IntPtr.Zero)
+ {
+ _hThisInstance = DllImports.GetModuleHandle(null);
+
+ if (_hThisInstance == IntPtr.Zero)
+ {
+ throw Win32.GetLastException(nameof(GetModuleHandle), (string?)null);
+ }
+ }
+
+ return _hThisInstance;
+ }
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ public class WNDCLASSEX
+ {
+ public WNDCLASSEX(string className)
+ {
+ cbSize = (uint)Marshal.SizeOf(this);
+ lpfnWndProc = Marshal.GetFunctionPointerForDelegate(WindowsWndProc);
+ hInstance = DllImports.HThisInstance;
+ lpszClassName = className;
+ }
+
+ public uint cbSize;
+ public ClassStyles style;
+ public IntPtr lpfnWndProc;
+ public int cbClsExtra;
+ public int cbWndExtra;
+ public IntPtr hInstance;
+ public IntPtr hIcon;
+ public IntPtr hCursor;
+ public IntPtr hbrBackground;
+ [MarshalAs(UnmanagedType.LPTStr)]
+ public string? lpszMenuName;
+ [MarshalAs(UnmanagedType.LPTStr)]
+ public string lpszClassName;
+ public IntPtr hIconSm;
+ }
+
+ // Class styles
+ [Flags]
+ public enum ClassStyles : uint
+ {
+#pragma warning disable format
+ CS_VREDRAW = 0x0001,
+ CS_HREDRAW = 0x0002,
+ CS_DBLCLKS = 0x0008,
+ CS_OWNDC = 0x0020,
+ CS_CLASSDC = 0x0040,
+ CS_PARENTDC = 0x0080,
+ CS_NOCLOSE = 0x0200,
+ CS_SAVEBITS = 0x0800,
+ CS_BYTEALIGNCLIENT = 0x1000,
+ CS_BYTEALIGNWINDOW = 0x2000,
+ CS_GLOBALCLASS = 0x4000,
+
+ CS_IME = 0x00010000,
+ CS_DROPSHADOW = 0x00020000,
+#pragma warning restore format
+ }
+
+ [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ public static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
+
+ private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
+ private static readonly WndProc WindowsWndProc = DefWindowProc;
+
+ [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ public static extern ushort RegisterClassEx([In] WNDCLASSEX lpWndClass);
+
+ [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool UnregisterClass(ushort lpClassAtom, [Optional] IntPtr hInstance);
+
+ [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ public static extern IntPtr CreateWindowEx(
+ // Extended window styles enum has not been extracted
+ uint dwExStyle,
+ [Optional] string? lpClassName,
+ [Optional] string? lpWindowName,
+ // Window styles enum has not been extracted
+ uint dwStyle,
+ int x,
+ int y,
+ int nWidth,
+ int nHeight,
+ [Optional] IntPtr hWndParent,
+ [Optional] IntPtr hMenu,
+ [Optional] IntPtr hInstance,
+ [Optional] IntPtr lpParam
+ );
+
+ [DllImport("user32.dll", ExactSpelling = true, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool DestroyWindow(IntPtr hWnd);
+
+ [DllImport("user32.dll", ExactSpelling = true)]
+ public static extern IntPtr GetDC(IntPtr hWnd);
+
+ [DllImport("user32.dll", ExactSpelling = true)]
+ public static extern bool ReleaseDC(IntPtr hWnd, IntPtr hDc);
+
+ [StructLayout(LayoutKind.Sequential)]
+ public class PIXELFORMATDESCRIPTOR
+ {
+ public PIXELFORMATDESCRIPTOR()
+ {
+ nSize = (ushort)Marshal.SizeOf(this);
+ nVersion = 1;
+ }
+
+ public ushort nSize;
+ public ushort nVersion;
+ public PfdFlags dwFlags;
+ public PfdPixelType iPixelType;
+ public byte cColorBits;
+ public byte cRedBits;
+ public byte cRedShift;
+ public byte cGreenBits;
+ public byte cGreenShift;
+ public byte cBlueBits;
+ public byte cBlueShift;
+ public byte cAlphaBits;
+ public byte cAlphaShift;
+ public byte cAccumBits;
+ public byte cAccumRedBits;
+ public byte cAccumGreenBits;
+ public byte cAccumBlueBits;
+ public byte cAccumAlphaBits;
+ public byte cDepthBits;
+ public byte cStencilBits;
+ public byte cAuxBuffers;
+ public PfdLayerType iLayerType;
+ public byte bReserved;
+ public uint dwLayerMask;
+ public uint dwVisibleMask;
+ public uint dwDamageMask;
+
+ private class ChannelDescription
+ {
+ public char name;
+ public byte size;
+ public byte shift;
+ }
+
+ public string Describe()
+ {
+ if (iPixelType != PfdPixelType.PFD_TYPE_RGBA)
+ {
+ return "?";
+ }
+
+ var channels = new ChannelDescription[]
+ {
+ new() {
+ name = 'R',
+ size = cRedBits,
+ shift = cRedShift
+ },
+ new() {
+ name = 'G',
+ size = cGreenBits,
+ shift = cGreenShift
+ },
+ new() {
+ name = 'B',
+ size = cBlueBits,
+ shift = cBlueShift
+ },
+ new() {
+ name = 'A',
+ size = cAlphaBits,
+ shift = cAlphaShift
+ },
+ }
+ .Where(c => c.size > 0)
+ .OrderBy(c => c.shift);
+
+ string channelDesc = string.Join(string.Empty, channels.Select(c => $"{c.name}{c.size}"));
+ string channelOrder = string.Join(string.Empty, channels.Select(c => c.name));
+
+ return $"{channelDesc} ({channelOrder}) Z:{cDepthBits} Stencil:{cStencilBits}";
+ }
+ }
+
+ [Flags]
+ public enum PfdFlags : uint
+ {
+#pragma warning disable format
+ PFD_DOUBLEBUFFER = 0x00000001,
+ PFD_STEREO = 0x00000002,
+ PFD_DRAW_TO_WINDOW = 0x00000004,
+ PFD_DRAW_TO_BITMAP = 0x00000008,
+ PFD_SUPPORT_GDI = 0x00000010,
+ PFD_SUPPORT_OPENGL = 0x00000020,
+ PFD_GENERIC_FORMAT = 0x00000040,
+ PFD_NEED_PALETTE = 0x00000080,
+ PFD_NEED_SYSTEM_PALETTE = 0x00000100,
+ PFD_SWAP_EXCHANGE = 0x00000200,
+ PFD_SWAP_COPY = 0x00000400,
+ PFD_SWAP_LAYER_BUFFERS = 0x00000800,
+ PFD_GENERIC_ACCELERATED = 0x00001000,
+ PFD_SUPPORT_DIRECTDRAW = 0x00002000,
+ PFD_DIRECT3D_ACCELERATED = 0x00004000,
+ PFD_SUPPORT_COMPOSITION = 0x00008000,
+
+ // PIXELFORMATDESCRIPTOR flags for use in ChoosePixelFormat only
+ PFD_DEPTH_DONTCARE = 0x20000000,
+ PFD_DOUBLEBUFFER_DONTCARE = 0x40000000,
+ PFD_STEREO_DONTCARE = 0x80000000,
+#pragma warning restore format
+ }
+
+ [Flags]
+ public enum PfdPixelType : byte
+ {
+#pragma warning disable format
+ PFD_TYPE_RGBA = 0,
+ PFD_TYPE_COLORINDEX = 1,
+#pragma warning restore format
+ }
+
+ [Flags]
+ public enum PfdLayerType : byte
+ {
+#pragma warning disable format
+ PFD_MAIN_PLANE = 0,
+ PFD_OVERLAY_PLANE = 1,
+ PFD_UNDERLAY_PLANE = unchecked((byte)-1),
+#pragma warning restore format
+ }
+
+ [DllImport("gdi32.dll", ExactSpelling = true, SetLastError = true)]
+ public static extern int ChoosePixelFormat(IntPtr hdc, [In] PIXELFORMATDESCRIPTOR ppfd);
+
+ [DllImport("gdi32.dll", ExactSpelling = true, SetLastError = true)]
+ private static extern int DescribePixelFormat(IntPtr hdc, int pixelFormat, uint bytes, [In, Out] PIXELFORMATDESCRIPTOR ppfd);
+
+ public static int DescribePixelFormat(IntPtr hdc, int pixelFormat, PIXELFORMATDESCRIPTOR ppfd)
+ {
+ return DescribePixelFormat(hdc, pixelFormat, (uint)Marshal.SizeOf(ppfd), ppfd);
+ }
+
+ [DllImport("gdi32.dll", ExactSpelling = true, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool SetPixelFormat(IntPtr hdc, int pixelFormat, [In] PIXELFORMATDESCRIPTOR ppfd);
+ }
+}
diff --git a/PD2Launcherv2/Utils/Gl/Internal/OpenGLDll.cs b/PD2Launcherv2/Utils/Gl/Internal/OpenGLDll.cs
new file mode 100644
index 00000000..1bf9b725
--- /dev/null
+++ b/PD2Launcherv2/Utils/Gl/Internal/OpenGLDll.cs
@@ -0,0 +1,29 @@
+using System.Runtime.InteropServices;
+
+namespace PD2Launcherv2.Utils.Gl.Internal
+{
+ internal static class OpenGLDll
+ {
+ public const string LibraryName = "opengl32.dll";
+
+ [DllImport(LibraryName, ExactSpelling = true)]
+ public static extern IntPtr wglGetCurrentContext();
+
+ [DllImport(LibraryName, ExactSpelling = true, SetLastError = true)]
+ public static extern IntPtr wglCreateContext(IntPtr hdc);
+
+ [DllImport(LibraryName, ExactSpelling = true, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool wglMakeCurrent(IntPtr hdc, IntPtr hglrc);
+
+ [DllImport(LibraryName, ExactSpelling = true, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool wglDeleteContext(IntPtr hglrc);
+
+ [DllImport(LibraryName, ExactSpelling = true, SetLastError = true, CharSet = CharSet.Ansi)]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization",
+ "CA2101:Specify marshaling for P/Invoke string arguments",
+ Justification = "ANSI strings are expected, thus CharSet.Ansi is the correct choice.")]
+ public static extern IntPtr wglGetProcAddress(string lpszProc);
+ }
+}
diff --git a/PD2Launcherv2/Utils/Gl/Internal/types.cs b/PD2Launcherv2/Utils/Gl/Internal/types.cs
new file mode 100644
index 00000000..b419c32f
--- /dev/null
+++ b/PD2Launcherv2/Utils/Gl/Internal/types.cs
@@ -0,0 +1,90 @@
+namespace PD2Launcherv2.Utils.Gl.Internal
+{
+ // OpenGL core profile and ARB extension interfaces
+
+ [Flags]
+ internal enum GLenum : uint
+ {
+ // Include only a small subset
+#pragma warning disable format
+ // GL_VERSION_1_0
+ GL_VENDOR = 0x1F00,
+ GL_RENDERER = 0x1F01,
+ GL_VERSION = 0x1F02,
+
+ // GL_VERSION_2_0
+ GL_SHADING_LANGUAGE_VERSION = 0x8B8C,
+
+ // GL_VERSION_3_0
+ GL_CONTEXT_FLAG_FORWARD_COMPATIBLE_BIT = 0x00000001,
+ GL_CONTEXT_FLAG_DEBUG_BIT = 0x00000002,
+
+ GL_MAJOR_VERSION = 0x821B,
+ GL_MINOR_VERSION = 0x821C,
+
+ GL_CONTEXT_FLAGS = 0x821E,
+
+ // GL_VERSION_3_2
+ GL_CONTEXT_CORE_PROFILE_BIT = 0x00000001,
+ GL_CONTEXT_COMPATIBILITY_PROFILE_BIT = 0x00000002,
+
+ GL_CONTEXT_PROFILE_MASK = 0x9126,
+
+ // GL_VERSION_4_5
+ GL_CONTEXT_FLAG_ROBUST_ACCESS_BIT = 0x00000004,
+
+ // GL_VERSION_4_6
+ GL_CONTEXT_FLAG_NO_ERROR_BIT = 0x00000008,
+#pragma warning restore format
+ }
+
+ internal enum GLenumError : uint
+ {
+#pragma warning disable format
+ // GL_VERSION_1_0
+ GL_NO_ERROR = 0,
+ GL_INVALID_ENUM = 0x0500,
+ GL_INVALID_VALUE = 0x0501,
+ GL_INVALID_OPERATION = 0x0502,
+ GL_STACK_OVERFLOW = 0x0503,
+ GL_STACK_UNDERFLOW = 0x0504,
+ GL_OUT_OF_MEMORY = 0x0505,
+
+ // GL_VERSION_3_0
+ GL_INVALID_FRAMEBUFFER_OPERATION = 0x0506,
+
+ // GL_VERSION_4_5 (or ARB_KHR_robustness)
+ GL_CONTEXT_LOST = 0x0507,
+#pragma warning restore format
+}
+
+ // WGL_ARB_create_context
+
+ internal enum WglContextAttribs : int
+ {
+#pragma warning disable format
+ // Accepted as an attribute name in <*attribList>:
+ WGL_CONTEXT_MAJOR_VERSION_ARB = 0x2091,
+ WGL_CONTEXT_MINOR_VERSION_ARB = 0x2092,
+ WGL_CONTEXT_LAYER_PLANE_ARB = 0x2093,
+ WGL_CONTEXT_FLAGS_ARB = 0x2094,
+ WGL_CONTEXT_PROFILE_MASK_ARB = 0x9126,
+
+ // Accepted as bits in the attribute value for WGL_CONTEXT_FLAGS in <*attribList>:
+ WGL_CONTEXT_DEBUG_BIT_ARB = 0x0001,
+ WGL_CONTEXT_FORWARD_COMPATIBLE_BIT_ARB = 0x0002,
+
+ // Accepted as bits in the attribute value for WGL_CONTEXT_PROFILE_MASK_ARB in <*attribList>:
+ // (Only available if WGL_ARB_create_context_profile is available or if >=3.2)
+ WGL_CONTEXT_CORE_PROFILE_BIT_ARB = 0x00000001,
+ WGL_CONTEXT_COMPATIBILITY_PROFILE_BIT_ARB = 0x00000002,
+#pragma warning restore format
+ }
+
+ // Additional GetLastError() WGL values
+ internal enum LastGLerror : int
+ {
+ ERROR_INVALID_VERSION_ARB = 0x2095,
+ ERROR_INVALID_PROFILE_ARB = 0x2096,
+ }
+}
diff --git a/PD2Launcherv2/Utils/MsgBox.cs b/PD2Launcherv2/Utils/MsgBox.cs
new file mode 100644
index 00000000..a0776730
--- /dev/null
+++ b/PD2Launcherv2/Utils/MsgBox.cs
@@ -0,0 +1,133 @@
+using System.Text;
+using System.Windows;
+
+namespace PD2Launcherv2.Utils
+{
+ public static class MsgBox
+ {
+ public const string DefaultDialogTitle = "Project Diablo 2 Launcher";
+
+ private static MessageBoxResult ShowWrapper(string messageBoxText, MessageBoxImage icon, MessageBoxButton button, MessageBoxResult defaultResult = MessageBoxResult.None)
+ {
+ if (App.Current.MainWindow != null)
+ {
+ return MessageBox.Show(App.Current.MainWindow, messageBoxText, DefaultDialogTitle, button, icon, defaultResult);
+ }
+ else
+ {
+ return MessageBox.Show(messageBoxText, DefaultDialogTitle, button, icon, defaultResult);
+ }
+ }
+
+ public static MessageBoxResult Info(string messageBoxText, MessageBoxButton button = MessageBoxButton.OK, MessageBoxResult defaultResult = MessageBoxResult.None)
+ {
+ return ShowWrapper(messageBoxText, MessageBoxImage.Information, button, defaultResult);
+ }
+
+ public static MessageBoxResult Warn(string messageBoxText, MessageBoxButton button = MessageBoxButton.OK, MessageBoxResult defaultResult = MessageBoxResult.None)
+ {
+ return ShowWrapper(messageBoxText, MessageBoxImage.Warning, button, defaultResult);
+ }
+
+ public static MessageBoxResult Error(string messageBoxText, MessageBoxButton button = MessageBoxButton.OK, MessageBoxResult defaultResult = MessageBoxResult.None)
+ {
+ return ShowWrapper(messageBoxText, MessageBoxImage.Error, button, defaultResult);
+ }
+
+ public static MessageBoxResult Exception(
+ Exception? exception,
+ string? messageBoxText = null,
+ MessageBoxImage icon = MessageBoxImage.Error,
+ MessageBoxButton button = MessageBoxButton.OK,
+ MessageBoxResult defaultResult = MessageBoxResult.None)
+ {
+ if (exception == null && messageBoxText == null)
+ {
+ throw new ArgumentNullException($"Method '{nameof(Exception)}' cannot be called with both: '{nameof(exception)}' and '{nameof(messageBoxText)}' being null.", (Exception)null!);
+ }
+
+ // Available options:
+ //
+ // = = = = = = = = = = =
+ //
+ // [message/exception]
+ //
+ // = = = = = = = = = = =
+ //
+ // [message]
+ //
+ // [exception]
+ //
+ // = = = = = = = = = = =
+ //
+ // [message/exception]
+ //
+ // ---
+ //
+ // [exception]
+ //
+ // [exception]
+ //
+ // [...]
+ //
+ // = = = = = = = = = = =
+ //
+ // Conclusion: Add "---" at entry index 1 where total entry count >= 3
+
+ int entryCount = messageBoxText != null ? 1 : 0;
+
+ for (var currentException = exception!; currentException != null; currentException = currentException.InnerException)
+ {
+ if (currentException is AggregateException)
+ {
+ // Ignore AggregateException exception itself, only its InnerExceptions count
+ continue;
+ }
+
+ if (++entryCount >= 3)
+ {
+ // This much should be enough
+ break;
+ }
+ }
+
+ StringBuilder sb = new(messageBoxText);
+
+ var lastMessage = messageBoxText;
+ var currEntryIdx = lastMessage == null ? 0 : 1;
+
+ for (var currentException = exception!; currentException != null; currentException = currentException.InnerException)
+ {
+ if (currentException is AggregateException)
+ {
+ // Ignore AggregateException exception itself, only its InnerExceptions contain useful messages
+ continue;
+ }
+
+ if (lastMessage == currentException.Message)
+ {
+ // Skip duplicate messages
+ continue;
+ }
+
+ if (sb.Length > 0)
+ {
+ if (entryCount >= 3 && currEntryIdx == 1)
+ {
+ sb.Append("\n\n---\n\n");
+ }
+ else
+ {
+ sb.Append("\n\n");
+ }
+ }
+
+ sb.Append(currentException.Message);
+ lastMessage = currentException.Message;
+ ++currEntryIdx;
+ }
+
+ return ShowWrapper(sb.ToString(), icon, button, defaultResult);
+ }
+ }
+}
diff --git a/PD2Launcherv2/Utils/ProgressWithCookie.cs b/PD2Launcherv2/Utils/ProgressWithCookie.cs
new file mode 100644
index 00000000..9e3f0906
--- /dev/null
+++ b/PD2Launcherv2/Utils/ProgressWithCookie.cs
@@ -0,0 +1,111 @@
+namespace PD2Launcherv2.Utils
+{
+ class ProgressWithCookie : IProgress
+ {
+ private readonly ProgressCookie _cookieRef;
+ private readonly ProgressCookie _cookieCopy;
+
+ private readonly Action _handler;
+ private readonly Progress _progress;
+ private readonly IProgress _progressAsIProgress;
+
+ public ProgressWithCookie(ProgressCookie cookie, Action handler)
+ {
+ ArgumentNullException.ThrowIfNull(cookie, nameof(cookie));
+ ArgumentNullException.ThrowIfNull(handler, nameof(handler));
+
+ _cookieRef = cookie;
+ _cookieCopy = new ProgressCookie(cookie);
+
+ _handler = handler;
+ _progress = new Progress(HandlerWrapper);
+ _progressAsIProgress = (IProgress)_progress;
+ }
+
+ void IProgress.Report(T value)
+ {
+ _progressAsIProgress.Report(value);
+ }
+
+ public event EventHandler? ProgressChanged
+ {
+ add
+ {
+ _progress.ProgressChanged += value;
+ }
+ remove
+ {
+ _progress.ProgressChanged -= value;
+ }
+ }
+
+ private void HandlerWrapper(T value)
+ {
+ if (_cookieRef != _cookieCopy)
+ {
+ return;
+ }
+
+ _handler(value);
+ }
+ }
+
+ class ProgressCookie
+ {
+ private ulong _value;
+
+ public ProgressCookie()
+ {
+ }
+
+ public ProgressCookie(ProgressCookie other)
+ {
+ _value = other._value;
+ }
+
+ public void Advance()
+ {
+ ++_value;
+ }
+
+ public static bool operator ==(ProgressCookie? left, ProgressCookie? right)
+ {
+ if (left is null && right is null) return true;
+ if (left is null || right is null) return false;
+
+ return left._value == right._value;
+ }
+
+ public static bool operator !=(ProgressCookie? left, ProgressCookie? right)
+ {
+ return !(left == right);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(this, obj))
+ {
+ return true;
+ }
+
+ if (ReferenceEquals(obj, null))
+ {
+ return false;
+ }
+
+ var asProgressCookie = obj as ProgressCookie;
+
+ if (asProgressCookie == null)
+ {
+ return false;
+ }
+
+ return this == asProgressCookie;
+ }
+
+ public override int GetHashCode()
+ {
+ return _value.GetHashCode();
+ }
+ }
+}
diff --git a/PD2Launcherv2/Utils/Shell.cs b/PD2Launcherv2/Utils/Shell.cs
new file mode 100644
index 00000000..27fd8044
--- /dev/null
+++ b/PD2Launcherv2/Utils/Shell.cs
@@ -0,0 +1,107 @@
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace PD2Launcherv2.Utils
+{
+ public static class Shell
+ {
+ private static class DllImports
+ {
+ [DllImport("shell32.dll")]
+ [return: MarshalAs(UnmanagedType.Error)]
+ public static extern int SHOpenFolderAndSelectItems(
+ IntPtr pidlFolder,
+ uint cidl,
+ [In, MarshalAs(UnmanagedType.LPArray)] IntPtr[] apidl,
+ uint dwFlags);
+
+ [DllImport("shell32.dll")]
+ [return: MarshalAs(UnmanagedType.Error)]
+ public static extern int SHParseDisplayName(
+ [MarshalAs(UnmanagedType.LPWStr)] string name,
+ IntPtr pBindCtx,
+ [Out] out IntPtr pidl,
+ uint sfgaoIn,
+ [Out] out uint sfgaoOut);
+ }
+
+ public static async Task OpenFolderAndSelectItemsAsync(string dirPath, params string[] fileNames)
+ {
+ // No need for a full set of constants
+ const int S_OK = 0;
+
+ IntPtr dirPidl = IntPtr.Zero;
+ List filePidls = new(fileNames.Length);
+
+ try
+ {
+ // Docs for SHParseDisplayName() suggest calling it from a separate thread (https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shparsedisplayname#remarks).
+ // Additionally, all .NET threads have COM initialized with multi-threaded apartment (https://stackoverflow.com/a/70127040), so this should be safe.
+ await Task.Run(() =>
+ {
+ int hResult;
+
+ hResult = DllImports.SHParseDisplayName(
+ dirPath,
+ pBindCtx: IntPtr.Zero,
+ out dirPidl,
+ sfgaoIn: 0,
+ sfgaoOut: out _);
+
+ if (hResult != S_OK)
+ {
+ Marshal.ThrowExceptionForHR(hResult);
+ }
+
+ foreach (string fileName in fileNames)
+ {
+ hResult = DllImports.SHParseDisplayName(
+ Path.Combine(dirPath, fileName),
+ pBindCtx: IntPtr.Zero,
+ out var filePidl,
+ sfgaoIn: 0,
+ sfgaoOut: out _);
+
+ if (hResult != S_OK)
+ {
+ Marshal.ThrowExceptionForHR(hResult);
+ }
+
+ filePidls.Add(filePidl);
+ }
+
+ if (!filePidls.Any())
+ {
+ // Make sure not to pass zero 'cidl' (item identifier list count) as that will change the behavior of SHOpenFolderAndSelectItems().
+ // Passing a NULL item identifier list in the array is harmless, however.
+ filePidls.Add(IntPtr.Zero);
+ }
+ });
+
+ {
+ IntPtr[] filePidsArray = filePidls.ToArray();
+
+ int hResult = DllImports.SHOpenFolderAndSelectItems(
+ dirPidl,
+ (uint)filePidsArray.Length,
+ filePidsArray,
+ dwFlags: 0);
+
+ if (hResult != S_OK)
+ {
+ Marshal.ThrowExceptionForHR(hResult);
+ }
+ }
+ }
+ finally
+ {
+ Marshal.FreeCoTaskMem(dirPidl);
+
+ foreach (var filePidl in filePidls)
+ {
+ Marshal.FreeCoTaskMem(filePidl);
+ }
+ }
+ }
+ }
+}
diff --git a/PD2Launcherv2/ViewModels/AboutViewModel.cs b/PD2Launcherv2/ViewModels/AboutViewModel.cs
index f3134998..620585b6 100644
--- a/PD2Launcherv2/ViewModels/AboutViewModel.cs
+++ b/PD2Launcherv2/ViewModels/AboutViewModel.cs
@@ -74,8 +74,7 @@ public void ProdBucketAssign()
FilePath = "Live"
};
_localStorage.Update(StorageKey.FileUpdateModel, fileUpdateModel);
- var launcherArgs = _localStorage.LoadSection(StorageKey.LauncherArgs);
- Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = false , IsCustom = false, IsDisableUpdates = launcherArgs.disableAutoUpdate});
+ Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = false , IsCustom = false });
Debug.WriteLine("end ProdBucketAssign\n");
Messenger.Default.Send(new NavigationMessage { Action = NavigationAction.GoBack });
}
@@ -88,9 +87,8 @@ public void BetaBucketAssign()
FilePath = "Beta"
};
_localStorage.Update(StorageKey.FileUpdateModel, fileUpdateModel);
- var launcherArgs = _localStorage.LoadSection(StorageKey.LauncherArgs);
- Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = true , IsCustom = false , IsDisableUpdates = launcherArgs.disableAutoUpdate });
+ Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = true , IsCustom = false });
Debug.WriteLine("end BetaBucketAssign \n");
Messenger.Default.Send(new NavigationMessage { Action = NavigationAction.GoBack });
}
@@ -118,15 +116,12 @@ private void CustomBucketAssign()
_localStorage.Update(StorageKey.FileUpdateModel, fileUpdateModel);
- var launcherArgs = _localStorage.LoadSection(StorageKey.LauncherArgs);
-
Messenger.Default.Send(new ConfigurationChangeMessage
{
IsBeta = false,
- IsCustom = false,
- IsDisableUpdates = launcherArgs.disableAutoUpdate
+ IsCustom = false
});
- Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = false, IsCustom = true, IsDisableUpdates = launcherArgs.disableAutoUpdate });
+ Messenger.Default.Send(new ConfigurationChangeMessage { IsBeta = false, IsCustom = true });
Debug.WriteLine("end SetCustomEnvironment\n");
Messenger.Default.Send(new NavigationMessage { Action = NavigationAction.GoBack });
}
diff --git a/PD2Launcherv2/ViewModels/OptionsViewModel.cs b/PD2Launcherv2/ViewModels/OptionsViewModel.cs
index f6de3757..a72e432d 100644
--- a/PD2Launcherv2/ViewModels/OptionsViewModel.cs
+++ b/PD2Launcherv2/ViewModels/OptionsViewModel.cs
@@ -2,12 +2,16 @@
using GalaSoft.MvvmLight.Messaging;
using PD2Launcherv2.Enums;
using PD2Launcherv2.Helpers;
+using PD2Shared;
using PD2Shared.Interfaces;
using PD2Shared.Models;
-using ProjectDiablo2Launcherv2;
using System.Windows;
using System.Diagnostics;
using PD2Launcherv2.Messages;
+using PD2Launcherv2.Utils;
+using PD2Shared.Logging;
+using static PD2Shared.Logging.LoggingStatic;
+using PD2Shared.Utils;
namespace PD2Launcherv2.ViewModels
{
@@ -40,6 +44,7 @@ public OptionsViewModel(ILocalStorage localStorage)
MinFpsPickerItems = Constants.MinFpsPickerItems();
ShaderPickerItems = Constants.ShaderPickerItems();
LoadLauncherArgs();
+ LoadLauncherOptions();
LoadDDrawStorage();
DealWithLoadingModeComboBox(_localStorage);
CloseCommand = new RelayCommand(CloseView);
@@ -100,6 +105,13 @@ public bool IsDdrawSelected
_isDdrawSelected = value;
OnPropertyChanged(nameof(IsDdrawSelected));
OnPropertyChanged(nameof(DDrawControlsVisible));
+
+ Messenger.Default.Send(new RendererChangeMessage
+ {
+ UseD2GL = !_isDdrawSelected,
+ // This is quite horrible and should be made into an enum
+ CncDdrawUsesOGL = _selectedRenderer == "opengl"
+ });
}
}
}
@@ -136,6 +148,28 @@ private void ToggleAdvancedOptions()
private void SetWindowsPermissions()
{
+ if (Wine.IsRunningUnderWine)
+ {
+ try
+ {
+ Wine.ApplyWineConfiguration();
+
+ MsgBox.Info("Configuration applied successfully.");
+ }
+ catch (Wine.WineException ex)
+ {
+ L.CallerError(ex.InnerException, ex.Message);
+ MsgBox.Exception(ex, "Failed to apply configuration:");
+ }
+ catch (Exception ex)
+ {
+ L.CallerError(ex, "Failed to apply configuration.");
+ MsgBox.Exception(ex, "Failed to apply configuration:");
+ }
+
+ return;
+ }
+
var startInfo = new ProcessStartInfo()
{
FileName = "powershell.exe",
@@ -147,6 +181,28 @@ private void SetWindowsPermissions()
private void RemoveWindowsPermissions()
{
+ if (Wine.IsRunningUnderWine)
+ {
+ try
+ {
+ Wine.RemoveWineConfiguration();
+
+ MsgBox.Info("Configuration removed successfully.");
+ }
+ catch (Wine.WineException ex)
+ {
+ L.CallerError(ex.InnerException, ex.Message);
+ MsgBox.Exception(ex, "Failed to remove configuration:");
+ }
+ catch (Exception ex)
+ {
+ L.CallerError(ex, "Failed to remove configuration.");
+ MsgBox.Exception(ex, "Failed to remove configuration:");
+ }
+
+ return;
+ }
+
var startInfo = new ProcessStartInfo()
{
FileName = "powershell.exe",
@@ -274,6 +330,13 @@ public string SelectedRenderer
{
_selectedRenderer = value;
OnPropertyChanged(nameof(SelectedRenderer));
+
+ Messenger.Default.Send(new RendererChangeMessage
+ {
+ UseD2GL = !_isDdrawSelected,
+ // This is quite horrible and should be made into an enum
+ CncDdrawUsesOGL = _selectedRenderer == "opengl"
+ });
}
}
}
@@ -502,6 +565,46 @@ public bool SkipToBnet
}
}
+ private bool _forceSoftwareRederer;
+ public bool ForceSoftwareRenderer
+ {
+ get => _forceSoftwareRederer;
+ set
+ {
+ if (_forceSoftwareRederer != value)
+ {
+ _forceSoftwareRederer = value;
+ OnPropertyChanged();
+ Messenger.Default.Send(new LauncherOptionsChangeMessage
+ {
+ ForceSoftwareRenderer = value,
+ UseHttp2 = UseHttp2,
+ DisableAutoUpdate = AutoUpdate
+ });
+ }
+ }
+ }
+
+ private bool _useHttp2;
+ public bool UseHttp2
+ {
+ get => _useHttp2;
+ set
+ {
+ if (_useHttp2 != value)
+ {
+ _useHttp2 = value;
+ OnPropertyChanged();
+ Messenger.Default.Send(new LauncherOptionsChangeMessage
+ {
+ ForceSoftwareRenderer = ForceSoftwareRenderer,
+ UseHttp2 = value,
+ DisableAutoUpdate = AutoUpdate
+ });
+ }
+ }
+ }
+
private bool _autoUpdate;
public bool AutoUpdate
{
@@ -513,9 +616,12 @@ public bool AutoUpdate
Debug.WriteLine($"Set _autoUpdate {value}");
_autoUpdate = value;
OnPropertyChanged();
- var fileUpdateMode = _localStorage.LoadSection(StorageKey.FileUpdateModel);
- bool amIBeta = fileUpdateMode.FilePath.Equals("Beta");
- Messenger.Default.Send(new ConfigurationChangeMessage { IsDisableUpdates = value, IsBeta = amIBeta });
+ Messenger.Default.Send(new LauncherOptionsChangeMessage
+ {
+ ForceSoftwareRenderer = ForceSoftwareRenderer,
+ UseHttp2 = UseHttp2,
+ DisableAutoUpdate = value
+ });
}
}
}
@@ -600,11 +706,23 @@ private void LoadLauncherArgs()
IsDdrawSelected = launcherArgs.graphics;
SkipToBnet = launcherArgs.skiptobnet;
SndBkg = launcherArgs.sndbkg;
- AutoUpdate = launcherArgs.disableAutoUpdate;
}
Debug.WriteLine("end LoadLauncherArgs\n");
}
+ private void LoadLauncherOptions()
+ {
+ Debug.WriteLine("\nStart LoadLauncherOptions");
+ LauncherOptions launcherOptions = _localStorage.LoadSection(StorageKey.LauncherOptions);
+ if (launcherOptions != null)
+ {
+ ForceSoftwareRenderer = launcherOptions.ForceSoftwareRenderer;
+ UseHttp2 = launcherOptions.UseHttp2;
+ AutoUpdate = launcherOptions.DisableAutoUpdate;
+ }
+ Debug.WriteLine("end LoadLauncherOptions\n");
+ }
+
private void UpdateLauncherArgsStorage()
{
Debug.WriteLine("\nStart UpdateLauncherArgsStorage");
@@ -613,13 +731,25 @@ private void UpdateLauncherArgsStorage()
// Again, assuming true represents "ddraw"
graphics = IsDdrawSelected,
skiptobnet = SkipToBnet,
- sndbkg = SndBkg,
- disableAutoUpdate = AutoUpdate,
+ sndbkg = SndBkg
};
_localStorage.Update(StorageKey.LauncherArgs, launcherArgs);
Debug.WriteLine("end UpdateLauncherArgsStorage\n");
}
+ private void UpdateLauncherOptionsStorage()
+ {
+ Debug.WriteLine("\nStart UpdateLauncherOptionsStorage");
+ var launcherOptions = new LauncherOptions
+ {
+ ForceSoftwareRenderer = ForceSoftwareRenderer,
+ UseHttp2 = UseHttp2,
+ DisableAutoUpdate = AutoUpdate
+ };
+ _localStorage.Update(StorageKey.LauncherOptions, launcherOptions);
+ Debug.WriteLine("end UpdateLauncherOptionsStorage\n");
+ }
+
private void LoadDDrawCheckBoxOptions()
{
DdrawOptions dDrawOptions = _localStorage.LoadSection(StorageKey.DdrawOptions);
@@ -769,6 +899,7 @@ private void CloseView()
{
//save LauncherArgs Storage
UpdateLauncherArgsStorage();
+ UpdateLauncherOptionsStorage();
//save
UpdateDDrawStorage();
//write ddrawstorage to .ini
diff --git a/PD2Launcherv2/Views/AboutView.xaml b/PD2Launcherv2/Views/AboutView.xaml
index e022fc9c..0530f11e 100644
--- a/PD2Launcherv2/Views/AboutView.xaml
+++ b/PD2Launcherv2/Views/AboutView.xaml
@@ -5,10 +5,19 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:PD2Launcherv2.ViewModels"
Width="750" Height="550"
+ Style="{StaticResource PixelPerfectFrameworkElement}"
mc:Ignorable="d"
Title="AboutView"
DataContext="{Binding AboutViewModel, Source={StaticResource ViewModelLocator}}">
+
+
+
+
+
+
+
+
diff --git a/PD2Launcherv2/Views/FiltersView.xaml b/PD2Launcherv2/Views/FiltersView.xaml
index d5605677..2b32bef2 100644
--- a/PD2Launcherv2/Views/FiltersView.xaml
+++ b/PD2Launcherv2/Views/FiltersView.xaml
@@ -7,6 +7,7 @@
xmlns:converters="clr-namespace:PD2Launcherv2.Converters"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Width="750" Height="550"
+ Style="{StaticResource PixelPerfectFrameworkElement}"
mc:Ignorable="d"
Title="FiltersView"
Loaded="FiltersView_Loaded"
@@ -17,6 +18,12 @@
+
+
+
+
+
+
@@ -24,18 +31,17 @@
-
-
-
@@ -88,7 +94,7 @@
-
+
+ Foreground="{StaticResource GoldBrush}"/>
+ FocusVisualStyle="{x:Null}" Margin="10,0,0,0" FontSize="10" Panel.ZIndex="3"/>
+
+
+
+
+
+
+
+
@@ -15,8 +24,9 @@
-
+
+
@@ -36,23 +46,24 @@
+
-
-
+
-
+ FontSize="14" HorizontalAlignment="Left" Margin="10,5,0,0"/>
-
-
+
+
+
+
+
+
-
+ FontFamily="{StaticResource BlizzMedium}" Foreground="{StaticResource GoldBrush}" IsChecked="{Binding AutoUpdate, Mode=TwoWay}"
+ FontSize="12" Margin="10,10,10,0"/>
+
diff --git a/PD2Launcherv2/Views/OptionsView.xaml.cs b/PD2Launcherv2/Views/OptionsView.xaml.cs
index a1dfda27..5d84617e 100644
--- a/PD2Launcherv2/Views/OptionsView.xaml.cs
+++ b/PD2Launcherv2/Views/OptionsView.xaml.cs
@@ -1,5 +1,6 @@
-using PD2Launcherv2.ViewModels;
-using System.Windows.Controls;
+using System.Windows.Controls;
+using PD2Launcherv2.ViewModels;
+using PD2Shared.Utils;
namespace PD2Launcherv2.Views
{
@@ -12,11 +13,21 @@ public OptionsView()
{
InitializeComponent();
DataContext = App.Resolve();
- }
-
- private void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
+ // It's much easier to have this set up here than via the model
+ if (Wine.IsRunningUnderWine)
+ {
+ WindowsPermissionsText.Text = "Wine configuration";
+ SetWindowsPermissions.Content = "Apply";
+ SetWindowsPermissions.ToolTip = null;
+ RemoveWindowsPermissions.Content = "Remove";
+ RemoveWindowsPermissions.ToolTip = null;
+ }
+ else
+ {
+ WineLogo32Image.Visibility = System.Windows.Visibility.Collapsed;
+ WineLogo32ImageCopy.Visibility = System.Windows.Visibility.Collapsed;
+ }
}
}
}
\ No newline at end of file
diff --git a/PD2Shared/Constants.cs b/PD2Shared/Constants.cs
index 86888820..184c2baf 100644
--- a/PD2Shared/Constants.cs
+++ b/PD2Shared/Constants.cs
@@ -4,6 +4,9 @@ namespace PD2Shared
{
public static class Constants
{
+ // This could be generated and ingested during build as well
+ public const string VersionString = "2.14.3";
+
public static class LauncherUpdate
{
public const string LegacyGcpMetadataUrl =
diff --git a/PD2Shared/Extensions/HttpResponseMessageEx.cs b/PD2Shared/Extensions/HttpResponseMessageEx.cs
new file mode 100644
index 00000000..4ca9cb69
--- /dev/null
+++ b/PD2Shared/Extensions/HttpResponseMessageEx.cs
@@ -0,0 +1,17 @@
+namespace PD2Shared.Extensions
+{
+ public static class HttpResponseMessageEx
+ {
+ // An alternative to EnsureSuccessStatusCode() since it only produces HttpRequestExceptions with (questionably useful) messages such as:
+ // 'Response status code does not indicate success: 404 (Not Found).'
+ public static void ThrowIfUnsuccessful(this HttpResponseMessage httpResponseMessage)
+ {
+ if (!httpResponseMessage.IsSuccessStatusCode)
+ {
+ var reqMsg = httpResponseMessage.RequestMessage!;
+
+ throw new HttpRequestException($"{reqMsg.Method} '{reqMsg.RequestUri}' failed with: {(int)httpResponseMessage.StatusCode} ({httpResponseMessage.StatusCode})", inner: null, httpResponseMessage.StatusCode);
+ }
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/GameFileUpdateException.cs b/PD2Shared/GameFileUpdate/GameFileUpdateException.cs
new file mode 100644
index 00000000..3c964cf2
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/GameFileUpdateException.cs
@@ -0,0 +1,60 @@
+namespace PD2Shared.GameFileUpdate
+{
+ // Base class for all exceptions
+ public abstract class GameFileUpdateException : Exception
+ {
+ public GameFileUpdateException(Exception? innerException = null, string? message = null) : base(message, innerException) { }
+ }
+
+ public class LoadManifestException : GameFileUpdateException
+ {
+ public LoadManifestException(string message, Exception? innerException = null) : base(innerException, message) { }
+ }
+
+ public class SaveManifestException : GameFileUpdateException
+ {
+ public SaveManifestException(string message, Exception? innerException = null) : base(innerException, message) { }
+ }
+
+ public class LoadMetadataException : GameFileUpdateException
+ {
+ public LoadMetadataException(string message, Exception? innerException = null) : base(innerException, message) { }
+ }
+
+ // Base class for fatal exceptions
+ public abstract class FatalGameFileUpdateException : GameFileUpdateException
+ {
+ public FatalGameFileUpdateException(Exception? innerException = null, string? message = null) : base(innerException, message) { }
+ }
+
+ // Offline (fresh metadata not retrieved, either due to an error or being forced to work offline) and the available manifest (if any) contains no data to work with
+ public class OfflineInvalidManifest : FatalGameFileUpdateException
+ {
+ public OfflineInvalidManifest(Exception? innerException = null, string? message = null) : base(innerException, message) { }
+ }
+
+ // Retrieved metadata appears to be invalid (rare)
+ public class InvalidMetadataRetrieved : FatalGameFileUpdateException
+ {
+ public InvalidMetadataRetrieved(Exception? innerException = null, string? message = null) : base(innerException, message) { }
+ }
+
+ // Offline (fresh metadata not retrieved, either due to an error or being forced to work offline),
+ // validation failed based on the available manifest and files need to be re-downloaded, which is impossible.
+ public class OfflineNeedsDownload : FatalGameFileUpdateException
+ {
+ public OfflineNeedsDownload(Exception? innerException = null, string? message = null) : base(innerException, message) { }
+ }
+
+ // Base download failure exception
+ public class DownloadException : GameFileUpdateException
+ {
+ public DownloadException(Exception? innerException = null, string? message = null) : base(innerException, message) { }
+ }
+
+ // Download failed due to hash mismatch (rare)
+ public class DownloadHashMismatchException : DownloadException
+ {
+ public DownloadHashMismatchException(Exception? innerException = null, string? message = null) : base(innerException, message) { }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/GameFileUpdater.cs b/PD2Shared/GameFileUpdate/GameFileUpdater.cs
new file mode 100644
index 00000000..32c9b67d
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/GameFileUpdater.cs
@@ -0,0 +1,1862 @@
+using Newtonsoft.Json.Linq;
+using Serilog.Events;
+using System.Buffers;
+using System.Collections.Immutable;
+using System.Data;
+using System.Diagnostics;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using PD2Shared.Extensions;
+using PD2Shared.GameFileUpdate.Internal;
+using PD2Shared.Logging;
+using static PD2Shared.Logging.LoggingStatic;
+using PD2Shared.Models;
+using PD2Shared.Utils;
+
+namespace PD2Shared.GameFileUpdate
+{
+ using PV = ProgressValues;
+
+ public class GameFileUpdater
+ {
+ // The default buffer size of FileStream() (https://learn.microsoft.com/en-us/dotnet/api/system.io.filestream.-ctor#system-io-filestream-ctor(system-string-system-io-filemode-system-io-fileaccess-system-io-fileshare-system-int32))
+ private const int DefaultStreamBufferSize = 4096;
+ // The default buffer size of Stream.CopyToAsync() (https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.copytoasync#system-io-stream-copytoasync(system-io-stream-system-int32))
+ private const int DefaultLargeStreamBufferSize = 81920;
+ // A reasonably small buffer of just one page
+ private const int DefaultNetworkStreamBufferSize = 4096;
+
+ private readonly Dictionary _fileUpdateModelToContext = new(new FileUpdateModelEqualityComparer());
+
+ private static int CalculateFileBufferSize(long fileSize)
+ {
+ return (int)Math.Clamp(fileSize, (long)DefaultStreamBufferSize, (long)DefaultLargeStreamBufferSize);
+ }
+
+ private static int CalculateFileBufferSize(string path)
+ {
+ // Both: FileInfo constructor and its properties can throw
+ return CalculateFileBufferSize(new FileInfo(path).Length);
+ }
+
+ private static FileStream OpenReadFileStream(string path, int bufferSize)
+ {
+ return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
+ }
+
+ private static FileStream OpenCreateFileStream(string path, int bufferSize)
+ {
+ Env.EnsureDirectoryExists(path);
+
+ return new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
+ }
+
+ private static FileStream OpenWriteFileStream(string path, int bufferSize, long offset)
+ {
+ return new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.None, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)
+ {
+ // While this can throw, Validation should have made sure that the file is of sufficient size
+ Position = offset
+ };
+ }
+
+ private static void LogManifestStats(ManifestEntry[] manifestEntries)
+ {
+ L.CallerDebug($"{manifestEntries.Count(e => e.Size != null)}/{manifestEntries.Length} sizes; {manifestEntries.Count(e => e.Xxh3Hash != null)}/{manifestEntries.Length} XXH3s");
+ }
+
+ private static async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken ct, IProgress>? progress = null)
+ {
+ int bufferSize = CalculateFileBufferSize(sourcePath);
+
+ using (var inStream = OpenReadFileStream(sourcePath, bufferSize))
+ {
+ using (var outStream = OpenCreateFileStream(destinationPath, bufferSize))
+ {
+ if (progress == null)
+ {
+ await inStream.CopyToAsync(outStream, ct).ConfigureAwait(false);
+ }
+ else
+ {
+ var buffer = ArrayPool.Shared.Rent(bufferSize);
+
+ try
+ {
+ int bytesRead;
+ long totalBytesCopied = 0;
+
+ while ((bytesRead = await inStream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false)) > 0)
+ {
+ await outStream.WriteAsync(buffer, 0, bytesRead, ct).ConfigureAwait(false);
+
+ totalBytesCopied += bytesRead;
+ progress.Report(Tuple.Create(bytesRead, totalBytesCopied, inStream.Length));
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+ }
+ }
+ }
+
+ private static async Task ComputeHashesAsync(Digest[] digests, string path, long? sizeLimit, CancellationToken ct, IProgress>? progress = null)
+ {
+ int bufferSize = CalculateFileBufferSize(path);
+
+ using var inStream = OpenReadFileStream(path, bufferSize);
+
+ if (progress == null && digests.Length == 1)
+ {
+ return new Hash[] { await digests.First().HashStream(inStream, ct).ConfigureAwait(false) };
+ }
+ else
+ {
+ var buffer = ArrayPool.Shared.Rent(bufferSize);
+
+ try
+ {
+ int bytesRead;
+ long totalBytesRead = 0;
+
+ var sizeToRead = sizeLimit != null ? (int)Math.Min(buffer.Length, sizeLimit.Value - totalBytesRead) : buffer.Length;
+
+ while ((bytesRead = await inStream.ReadAsync(buffer, 0, sizeToRead, ct).ConfigureAwait(false)) > 0)
+ {
+ foreach (var d in digests)
+ {
+ d.Update(buffer, 0, bytesRead);
+ }
+
+ totalBytesRead += bytesRead;
+ progress?.Report(Tuple.Create(bytesRead, totalBytesRead, sizeLimit != null ? sizeLimit.Value : inStream.Length));
+
+ if (sizeLimit != null && totalBytesRead >= sizeLimit.Value)
+ {
+ break;
+ }
+ }
+
+ return digests.Select(d => d.GetHash()).ToArray();
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+ }
+
+ private static ManifestEntry[] LoadManifest(string path)
+ {
+ // Expected input:
+ //
+ // {
+ // "manifest": {
+ // "entries": {
+ // "BH-LICENSE.md": {
+ // "md5": "990edf479f989d2f07dd0d95dadfdc95",
+ // "size": 35181,
+ // "xxh3": "1940485fe884a490"
+ // },
+ // "BH.dll": {
+ // "md5": "ecdf6624097328a390926b0dcddc2d79",
+ // "size": 1423360,
+ // "xxh3": "2753ec14b38fd8fa"
+ // },
+ // "binkw32.dll": {
+ // "md5": "f0c8199c01b623d97d6597f38e5b52a0",
+ // "size": null
+ // },
+ // [...]
+ // },
+ // "count": 47
+ // }
+ // }
+
+ L.CallerInformation($"Loading '{path}'...");
+
+ FileStream inStream;
+ try
+ {
+ inStream = OpenReadFileStream(path, 0);
+ }
+ catch (Exception ex)
+ {
+ throw new LoadManifestException("Failed to open manifest.", ex);
+ }
+
+ JsonNode? rootNode;
+
+ try
+ {
+ using (inStream)
+ {
+ rootNode = JsonNode.Parse(inStream, new JsonNodeOptions { PropertyNameCaseInsensitive = true });
+ }
+ }
+ catch (JsonException ex)
+ {
+ throw new LoadManifestException("Failed to parse manifest.", ex);
+ }
+
+ if (rootNode == null)
+ {
+ throw new LoadManifestException("Manifest JSON payload is null.");
+ }
+
+ const string rootFieldName = "manifest";
+
+ var manifestNode = rootNode![rootFieldName] ?? throw new LoadManifestException($"Manifest JSON root field '{rootFieldName}' is absent or null.");
+
+ SerializableManifest? serializableManifest;
+
+ try
+ {
+ serializableManifest = JsonSerializer.Deserialize(manifestNode, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ // AllowDuplicateProperties = false // Only available since .NET 10 (https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsondocumentoptions.allowduplicateproperties)
+ });
+ }
+ catch (JsonException ex)
+ {
+ throw new LoadManifestException("Failed to deserialize manifest entries.", ex);
+ }
+
+ if (serializableManifest == null || serializableManifest.Entries == null)
+ {
+ throw new LoadManifestException("Manifest entries are null.");
+ }
+
+ if (serializableManifest.Entries.Count != serializableManifest.Count)
+ {
+ throw new LoadManifestException(message: $"Actual JSON entry count ({serializableManifest.Entries.Count}) does not match the manifest ({serializableManifest.Count}).");
+ }
+
+ {
+ var uniquePaths = new HashSet(serializableManifest.Entries.Count, StringComparer.OrdinalIgnoreCase);
+
+ return serializableManifest.Entries
+ // De-duplicate entries
+ .Where(kvp =>
+ {
+ if (!uniquePaths.Add(kvp.Key))
+ {
+ throw new LoadManifestException($"Manifest entry with duplicate path encountered: '{kvp.Key}'");
+ }
+
+ return true;
+ })
+ .Select(kvp =>
+ {
+ try
+ {
+ return new ManifestEntry(kvp.Key, kvp.Value);
+ }
+ catch (Exception ex)
+ {
+ // Let Serilog output JSON-formatted SerializableManifest.Entry here
+ L.CallerError($"Failed to construct {nameof(ManifestEntry)} for '{kvp.Key}' using {kvp.Value}: {{@SerializableManifest.Entry}}", ExplicitArray(kvp.Value));
+ throw new LoadManifestException($"Failed to construct {nameof(ManifestEntry)} for '{kvp.Key}' with given {kvp.Value}.", ex);
+ }
+ })
+ .ToArray();
+ }
+ }
+
+ private static async Task SaveManifest(string path, ManifestEntry[] manifestEntries, CancellationToken ct)
+ {
+ if (!manifestEntries.Any(e => e.Dirty))
+ {
+ L.CallerDebug("No dirty manifest entries found. Skipping saving the manifest.");
+ return false;
+ }
+
+ L.CallerInformation($"Saving manifest to: '{path}'...");
+
+ FileStream outStream;
+ try
+ {
+ outStream = OpenCreateFileStream(path, 0);
+ }
+ catch (Exception ex)
+ {
+ throw new SaveManifestException($"Failed to create manifest file: '{path}'.", ex);
+ }
+
+ using (outStream)
+ {
+ // Refer to "Expected input" detailed in deserialization logic
+
+ await JsonSerializer.SerializeAsync(outStream, new
+ {
+ manifest = new SerializableManifest(manifestEntries)
+ }, new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ },
+ ct).ConfigureAwait(false);
+ }
+
+ // Clear Dirty flags on all entries
+ foreach (var e in manifestEntries)
+ {
+ e.Dirty = false;
+ }
+
+ LogManifestStats(manifestEntries);
+
+ return true;
+ }
+
+ private static async Task> TrySaveManifest(string path, ManifestEntry[] manifestEntries, CancellationToken ct)
+ {
+ bool? res = null;
+
+ try
+ {
+ res = await SaveManifest(path, manifestEntries, ct).ConfigureAwait(false);
+ }
+ catch( Exception ex)
+ {
+ return Tuple.Create(res, (Exception?)ex);
+ }
+
+ return Tuple.Create(res, (Exception?)null);
+ }
+
+ private static async Task DownloadMetadata(HttpClient httpClient, string url, CancellationToken ct)
+ {
+ L.CallerInformation($"Downloading metadata from: '{url}'...");
+
+ using var response = await httpClient.GetAsync(url, ct).ConfigureAwait(false);
+ response.ThrowIfUnsuccessful();
+
+ JObject rootNode;
+
+ try
+ {
+ rootNode = JObject.Parse(await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
+ }
+ catch (Newtonsoft.Json.JsonReaderException ex)
+ {
+ throw new LoadMetadataException("Failed to parse metadata.", ex);
+ }
+
+ if (rootNode == null)
+ {
+ throw new LoadMetadataException("Metadata JSON payload is null.");
+ }
+
+ const string rootFieldName = "checksum";
+
+ var checksumNode = rootNode[rootFieldName] ?? throw new LoadMetadataException($"Metadata JSON root field '{rootFieldName}' is absent or null.");
+
+ List? stringEntries;
+
+ try
+ {
+ stringEntries = checksumNode.ToObject>();
+ }
+ catch (Newtonsoft.Json.JsonReaderException ex)
+ {
+ throw new LoadMetadataException("Failed to deserialize metadata entries.", ex);
+ }
+
+ if (stringEntries == null)
+ {
+ throw new LoadMetadataException("Metadata entries are null.");
+ }
+
+ var uniquePaths = new HashSet(stringEntries.Count, StringComparer.OrdinalIgnoreCase);
+
+ return stringEntries
+ .Select(entry =>
+ {
+ var parts = entry.Split(" ", 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length != 2)
+ {
+ throw new LoadMetadataException($"Invalid metadata entry: '{entry}'");
+ }
+
+ var path = parts[1];
+ var md5 = parts[0];
+
+ // Exclude directories?
+ if (path.EndsWith('/'))
+ {
+ throw new LoadMetadataException($"Metadata entry with directory-like path encountered: '{entry}'");
+ }
+
+ if (!uniquePaths.Add(path))
+ {
+ throw new LoadMetadataException($"Metadata entry with duplicate path encountered: '{entry}'");
+ }
+
+ try
+ {
+ return new ManifestEntry(path, md5);
+ }
+ catch (Exception ex)
+ {
+ throw new LoadMetadataException($"Failed to construct {nameof(ManifestEntry)} with metadata entry: '{entry}'", ex);
+ }
+ })
+ .ToArray();
+ }
+
+ private static async Task> ValidateFileAsync(
+ string path,
+ long? size,
+ Hash expectedHash,
+ CancellationToken ct,
+ bool looseValidation = false,
+ bool sizeIsMinimumSize = false,
+ IProgress>? progress = null)
+ {
+ if (sizeIsMinimumSize && size == null)
+ {
+ throw new ArgumentException($"'{nameof(size)}' cannot be null when '{nameof(sizeIsMinimumSize)}' is true", nameof(size));
+ }
+
+ if (!await Env.FileExistsAsync(path).ConfigureAwait(false))
+ {
+ L.CallerWarning($"{path}: missing");
+
+ return Tuple.Create(false, (long?)null, (Xxh3Hash?)null);
+ }
+
+ (var actualSize, var ex) = await Env.TryGetFileSizeAsync(path).ConfigureAwait(false);
+ if (ex != null)
+ {
+ L.CallerError(ex, $"{nameof(Env.TryGetFileSizeAsync)}() for '{path}' failed.");
+
+ return Tuple.Create(false, (long?)null, (Xxh3Hash?)null);
+ }
+
+ Debug.Assert(actualSize != null);
+
+ if (sizeIsMinimumSize)
+ {
+ Debug.Assert(size != null);
+
+ if (size.Value != actualSize.Value)
+ {
+ if (size.Value > actualSize.Value)
+ {
+ // Partial download cannot be smaller than declared in PartialDownload
+ L.CallerWarning($"{path}: partial size mismatch: {size.Value} > {actualSize.Value} (actual)");
+
+ return Tuple.Create(false, actualSize, (Xxh3Hash ?)null);
+ }
+ else
+ {
+ L.CallerDebug($"{path}: acceptable partial size mismatch: {size.Value} <= {actualSize.Value} (actual)");
+ }
+ }
+ }
+ else
+ {
+ if (looseValidation)
+ {
+ if (actualSize.Value > 0)
+ {
+ // If the file exists and has non-zero size -- that's good enough
+ L.CallerDebug($"{path}: loosely validated: {actualSize.Value} (actual)");
+
+ return Tuple.Create(true, actualSize, (Xxh3Hash ?)null);
+ }
+ else
+ {
+ L.CallerWarning($"{path}: failed loose validation (empty file)");
+
+ return Tuple.Create(false, actualSize, (Xxh3Hash?)null);
+ }
+ }
+ else
+ {
+ if (size != null && size.Value != actualSize.Value)
+ {
+ L.CallerWarning($"{path}: size mismatch: {size.Value} != {actualSize.Value} (actual)");
+
+ return Tuple.Create(false, actualSize, (Xxh3Hash?)null);
+ }
+ }
+ }
+
+ List digests = new(2);
+
+ try
+ {
+ // Attempt to pick either DisposableMd5 or DisposableXxh3 digests.
+ //
+ // DisposableMd5 is significantly faster than NonFinalizingMd5 as it's merely a wrapper around native implementation (System.Security.Cryptography).
+ // Meanwhile, NonFinalizingMd5 is a wrapper around BouncyCastle, which is purely managed code.
+ //
+ // Since performance matters in this scenario and there's no use for the digest to be non-finalizing, go with the disposable variant.
+ digests.Add(Digest.GetDisposable(expectedHash));
+
+ L.CallerVerbose($"{path}: validating against {expectedHash.Name} using {digests.First().GetType().Name}...");
+
+ // If not validating against XXH3, make sure to compute one as well (and add it as the last element to be returned in the end)
+ if (!digests.First().IsHashType())
+ {
+ digests.Add(new DisposableXxh3());
+ }
+
+ Hash[] hashes = await ComputeHashesAsync(digests.ToArray(), path, sizeLimit: sizeIsMinimumSize ? size : (long?)null, ct, progress).ConfigureAwait(false);
+
+ if (hashes.First() == expectedHash)
+ {
+ return Tuple.Create(true, actualSize, (Xxh3Hash?)hashes.Last());
+ }
+ else
+ {
+ L.CallerWarning($"{path}: {digests.First().HashName} mismatch");
+
+ return Tuple.Create(false, actualSize, (Xxh3Hash?)null);
+ }
+ }
+ finally
+ {
+ foreach (var d in digests)
+ {
+ d.Dispose();
+ }
+ }
+ }
+
+ private static async Task ValidateFilesAsync(
+ WorkItem[] filesToValidate,
+ ValidationKind validationKind,
+ ParallelOptions parallelOptions,
+ IProgress? progress)
+ {
+ if (!filesToValidate.Any())
+ {
+ // Return early not to end up with totalBytesToValidate == 0
+ L.CallerWarning($"Nothing to validate for {validationKind}.");
+ return;
+ }
+
+ using var loggedRoutine = new LoggedRoutine();
+
+ int totalFilesValidated = 0;
+ int totalFilesToValidate = filesToValidate.Length;
+ long totalBytesValidated = 0;
+ // Don't use Nullable to allow atomic operations. Use the symbolic IsInvalidSize() instead.
+ long totalBytesToValidate = default(long).GetInvalidSize();
+
+ if (validationKind.IsDownloadFiles())
+ {
+ // Factor in PartialDownloads
+ if (filesToValidate.All(d => d.PartialDownload?.PartialSize != null || d.ManifestEntry.Size != null))
+ {
+ totalBytesToValidate = filesToValidate.Sum(d => d.PartialDownload?.PartialSize ?? d.ManifestEntry.Size!.Value);
+ }
+ }
+ else
+ {
+ if (filesToValidate.All(d => d.ManifestEntry.Size != null || d.ManifestEntry.LooseValidation))
+ {
+ totalBytesToValidate = filesToValidate
+ .Where(d => d.ManifestEntry.Size != null || d.ManifestEntry.LooseValidation)
+ .Sum(d => d.ManifestEntry.LooseValidation ? 0 : d.ManifestEntry.Size!.Value);
+ }
+ }
+
+ {
+ var totalBytesToValidateStr = totalBytesToValidate.IsInvalidSize() ? "?" : Formatting.FormatSizeInMiB(totalBytesToValidate);
+
+ L.CallerInformation($"Validating {totalFilesToValidate} {validationKind} ({totalBytesToValidateStr} total)...");
+ }
+
+ progress?.Report(new PV()
+ .Clear()
+ .SetFileCount(totalFilesValidated, totalFilesToValidate)
+ .SetBytes(totalBytesValidated, totalBytesToValidate)
+ .Extract()
+ );
+
+ SimpleTimer? updateTimer = null;
+
+ if (progress != null)
+ {
+ updateTimer = new(() =>
+ {
+ var localTotalBytesValidated = Interlocked.Read(ref totalBytesValidated);
+ var localTotalBytesToValidate = Interlocked.Read(ref totalBytesToValidate);
+ var localTotalFilesValidated = totalFilesValidated;
+
+ var pv = new PV();
+
+ pv.SetFileCount(localTotalFilesValidated, totalFilesToValidate);
+ if (localTotalBytesToValidate.IsInvalidSize())
+ {
+ pv.SetTotal(localTotalFilesValidated, totalFilesToValidate);
+ }
+ else
+ {
+ pv.SetTotal(localTotalBytesValidated, localTotalBytesToValidate);
+ }
+ pv.SetBytes(localTotalBytesValidated, localTotalBytesToValidate);
+
+ progress.Report(pv.Extract());
+ });
+ }
+
+ using (updateTimer)
+ {
+ await Parallel.ForEachAsync(filesToValidate, parallelOptions, async (f, ct) =>
+ {
+ bool validatingPartialDownload = validationKind.IsDownloadFiles() && f.PartialDownload != null;
+
+ string path = validationKind.IsDownloadFiles() ? f.DownloadPath : f.InstallPath;
+ long? expectedSize = validatingPartialDownload ? f.PartialDownload!.PartialSize : f.ManifestEntry.Size;
+ Hash expectedHash = validatingPartialDownload ? f.PartialDownload!.PartialXxh3Hash : f.ManifestEntry.BestHash;
+
+ var doingLooseValidation = validationKind.IsInstallFiles() && f.ManifestEntry.LooseValidation;
+
+ (bool validationSucceeded, long? actualSize, Xxh3Hash? xxh3Hash) = await ValidateFileAsync(
+ path,
+ expectedSize,
+ expectedHash,
+ ct,
+ doingLooseValidation,
+ sizeIsMinimumSize: validatingPartialDownload,
+ new DirectProgress>(t =>
+ {
+ (var fileBytesValidated, _, _) = t;
+
+ Interlocked.Add(ref totalBytesValidated, fileBytesValidated);
+ })).ConfigureAwait(false);
+
+ if (!validationSucceeded)
+ {
+ // Discard PartialDownload due to failed validation
+ if (validatingPartialDownload)
+ {
+ L.CallerWarning($"{path}: partial download failed validation. Discarding...");
+
+ f.PartialDownload = null;
+ }
+ }
+ else
+ {
+ // Loosely validated file's actual size is irrelevant
+ if (!doingLooseValidation && !validatingPartialDownload)
+ {
+ Debug.Assert(actualSize != null);
+ Debug.Assert(xxh3Hash is not null);
+
+ if (f.ManifestEntry.Size != actualSize)
+ {
+ if (!totalBytesToValidate.IsInvalidSize())
+ {
+ Interlocked.Add(ref totalBytesToValidate, actualSize.Value - f.ManifestEntry.Size.GetValueOrDefault(0));
+ }
+
+ if (f.ManifestEntry.Size != null)
+ {
+ var fromSizeStr = Formatting.FormatSizeInMiB(f.ManifestEntry.Size.Value, appendUnits: false);
+ var toSizeStr = Formatting.FormatSizeInMiB(actualSize.Value);
+
+ L.CallerWarning($"{path}: updating {nameof(f.ManifestEntry.Size)} in manifest: {f.ManifestEntry.Size} -> {actualSize} bytes ({fromSizeStr} -> {toSizeStr})");
+ }
+
+ f.ManifestEntry.Size = actualSize.Value;
+ }
+
+ if (f.ManifestEntry.Xxh3Hash != xxh3Hash)
+ {
+ if (f.ManifestEntry.Xxh3Hash != null)
+ {
+ L.CallerWarning($"{path}: updating {nameof(f.ManifestEntry.Xxh3Hash)} in manifest");
+ }
+
+ f.ManifestEntry.Xxh3Hash = xxh3Hash;
+ }
+ }
+ }
+
+ switch (validationKind)
+ {
+ case ValidationKind.InstallFiles:
+ f.InstallFileValidated = validationSucceeded;
+ break;
+
+ case ValidationKind.DownloadFiles:
+ f.DownloadFileValidated = validationSucceeded;
+ break;
+ }
+
+ Interlocked.Increment(ref totalFilesValidated);
+ }).ConfigureAwait(false);
+ }
+ }
+
+ private static async Task QueryDownloadAsync(HttpClient httpClient, string url, CancellationToken ct)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Head, url);
+
+ // Make absolutely clear no encoding is requested (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding)
+ request.Headers.AcceptEncoding.Clear();
+ request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("identity"));
+ // Explicitly request a "0-" range (the entire file) (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Range#syntax)
+ request.Headers.Range = new RangeHeaderValue((long?)0, (long?)null);
+
+ using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
+ response.ThrowIfUnsuccessful();
+
+ // Ranges are explicitly supported if server responds with 206.
+ // A response of 200 and "Accept-Ranges: bytes" means the server generally accepts ranges, but cannot fulfill this particular request
+ // (likely due to encoding) and will send the entire file instead.
+ var acceptsRanges = response.StatusCode == System.Net.HttpStatusCode.PartialContent;
+
+ if (!acceptsRanges)
+ {
+ L.CallerWarning($"{url}: {response.StatusCode}; Cannot resume");
+ }
+
+ {
+ var h = response.Content.Headers;
+
+ // 206 should also contain Content-Range header
+ if (acceptsRanges && h.ContentRange != null)
+ {
+ var contentLengthStr = h.ContentLength == null ? "?" : Formatting.FormatSizeInMiB(h.ContentLength.Value);
+
+ var cr = h.ContentRange;
+ L.CallerDebug($"{url}: {response.StatusCode}; Content-Range: {cr.From?.ToString() ?? ""}-{cr.To?.ToString() ?? ""}/{cr.Length?.ToString() ?? "*"}; Content-Length: {h.ContentLength?.ToString() ?? "?"} ({contentLengthStr})");
+
+ return cr.Length ?? h.ContentLength;
+ }
+ else
+ {
+ var contentLengthStr = h.ContentLength == null ? "?" : Formatting.FormatSizeInMiB(h.ContentLength.Value);
+
+ L.CallerDebug($"{url}: {response.StatusCode}; Content-Length: {h.ContentLength?.ToString() ?? "?"} ({contentLengthStr})");
+
+ return h.ContentLength;
+ }
+ }
+ }
+
+ private static async Task QueryDownloadsAsync(
+ HttpClient httpClient,
+ WorkItem[] filesToQuery,
+ long initialSize,
+ ParallelOptions parallelOptions,
+ IProgress? progress)
+ {
+ using var loggedScope = new LoggedScope($"Querying {filesToQuery.Length} files...");
+
+ long totalBytesQueried = initialSize;
+ int totalFilesToQuery = filesToQuery.Length;
+ int totalFilesQueried = 0;
+
+ progress?.Report(new PV()
+ .Clear()
+ .SetFileCount(totalFilesQueried, totalFilesToQuery)
+ .SetBytes(totalBytesQueried)
+ .Extract()
+ );
+
+ SimpleTimer? updateTimer = null;
+
+ if (progress != null)
+ {
+ updateTimer = new(() =>
+ {
+ var localTotalBytesQueried = Interlocked.Read(ref totalBytesQueried);
+ var localTotalFilesQueried = totalFilesQueried;
+
+ var pv = new PV();
+
+ progress?.Report(new PV()
+ .SetTotal(localTotalFilesQueried, totalFilesToQuery)
+ .SetFileCount(localTotalFilesQueried, totalFilesToQuery)
+ .SetBytes(localTotalBytesQueried)
+ .Extract()
+ );
+ });
+ }
+
+ using (updateTimer)
+ {
+ await Parallel.ForEachAsync(filesToQuery, parallelOptions, async (f, ct) =>
+ {
+ var queriedSize = await QueryDownloadAsync(httpClient, f.Url, ct).ConfigureAwait(false);
+
+ Interlocked.Increment(ref totalFilesQueried);
+
+ if (queriedSize != null)
+ {
+ Interlocked.Add(ref totalBytesQueried, queriedSize.Value);
+
+ if (f.ManifestEntry.Size != queriedSize)
+ {
+ if (f.ManifestEntry.Size != null)
+ {
+ var fromSizeStr = Formatting.FormatSizeInMiB(f.ManifestEntry.Size.Value, appendUnits: false);
+ var toSizeStr = Formatting.FormatSizeInMiB(queriedSize.Value);
+
+ L.CallerWarning($"{f.ManifestEntry.Path}: updating Size in manifest {f.ManifestEntry.Size} -> {queriedSize} bytes ({fromSizeStr} -> {toSizeStr})");
+ }
+
+ f.ManifestEntry.Size = queriedSize.Value;
+ }
+ }
+ }).ConfigureAwait(false);
+ }
+ }
+
+ private static async Task DownloadFileAsync(
+ HttpClient httpClient,
+ string url,
+ string destinationPath,
+ Md5Hash referenceMd5Hash,
+ Hash expectedHash,
+ CancellationToken ct,
+ PartialDownload? downloadToResume = null,
+ IProgress>? progress = null)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+
+ if (downloadToResume != null)
+ {
+ request.Headers.Range = new RangeHeaderValue(downloadToResume.PartialSize, (long?)null);
+ }
+
+ Hash actualExpectedHash = null!;
+ NonFinalizingDigest digest = null!;
+ NonFinalizingXxh3 xxh3Digest = null!;
+ long totalBytesWritten = 0;
+
+ try
+ {
+ using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
+ response.ThrowIfUnsuccessful();
+
+ // Ranges are explicitly supported if server responds with 206.
+ // A response of 200 and "Accept-Ranges: bytes" means the server generally accepts ranges, but cannot fulfill this particular request
+ // (likely due to encoding) and will send the entire file instead.
+ bool resuming = downloadToResume != null && response.StatusCode == System.Net.HttpStatusCode.PartialContent;
+
+ if (resuming)
+ {
+ L.CallerDebug($"{url}: resuming download at {downloadToResume!.PartialSize} ({Formatting.FormatSizeInMiB(downloadToResume.PartialSize)})...");
+
+ actualExpectedHash = downloadToResume!.ExpectedHash;
+ digest = downloadToResume!.Digest;
+ xxh3Digest = downloadToResume!.Xxh3Digest;
+ totalBytesWritten = downloadToResume!.PartialSize;
+ }
+ else
+ {
+ if (downloadToResume != null)
+ {
+ L.CallerWarning($"{url}: restarting download...");
+ }
+ else
+ {
+ L.CallerDebug($"{url}: downloading...");
+ }
+
+ actualExpectedHash = expectedHash;
+ digest = Digest.GetNonFinalizing(actualExpectedHash);
+ xxh3Digest = digest.IsHashType() ? (NonFinalizingXxh3)digest : new NonFinalizingXxh3();
+ totalBytesWritten = 0;
+ }
+
+ L.CallerVerbose($"{url}: validating against {actualExpectedHash.Name} using {digest.GetType().Name}...");
+
+ using var inStream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+
+ FileStream outStream;
+
+ if (resuming)
+ {
+ outStream = OpenWriteFileStream(destinationPath, CalculateFileBufferSize(response.Content.Headers.ContentRange?.Length ?? 0), offset: downloadToResume!.PartialSize);
+ }
+ else
+ {
+ outStream = OpenCreateFileStream(destinationPath, CalculateFileBufferSize(response.Content.Headers.ContentLength ?? 0));
+ }
+
+ using (outStream)
+ {
+ var buffer = ArrayPool.Shared.Rent(DefaultNetworkStreamBufferSize);
+
+ try
+ {
+ long? totalFileSize = resuming ? response.Content.Headers.ContentRange?.Length : response.Content.Headers.ContentLength;
+
+ int bytesRead;
+
+ while (true)
+ {
+ try
+ {
+ progress?.Report(Tuple.Create(0, totalBytesWritten, totalFileSize, true));
+
+ bytesRead = await inStream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ progress?.Report(Tuple.Create(0, totalBytesWritten, totalFileSize, false));
+ }
+
+ if (bytesRead <= 0)
+ {
+ break;
+ }
+
+ await outStream.WriteAsync(buffer, 0, bytesRead, ct).ConfigureAwait(false);
+
+ digest.Update(buffer, 0, bytesRead);
+ if (digest != xxh3Digest)
+ {
+ xxh3Digest.Update(buffer, 0, bytesRead);
+ }
+ totalBytesWritten += bytesRead;
+ progress?.Report(Tuple.Create(bytesRead, totalBytesWritten, totalFileSize, false));
+ }
+
+ if (digest.GetHash() != actualExpectedHash)
+ {
+ L.CallerError($"{url}: {digest.HashName} mismatch");
+ throw new DownloadHashMismatchException(innerException: null, digest.HashName);
+ }
+
+ return new DownloadResult((Xxh3Hash)xxh3Digest.GetHash());
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+ }
+ catch (OperationCanceledException ex)
+ {
+ bool userRequested = ex.CancellationToken == ct;
+
+ var stateStr = userRequested ? "canceled" : "interrupted";
+ var logEventLevel = userRequested ? LogEventLevel.Warning : LogEventLevel.Error;
+
+ if (totalBytesWritten > 0)
+ {
+ L.CallerWrite(logEventLevel, $"{url}: download {stateStr} at {totalBytesWritten} ({Formatting.FormatSizeInMiB(totalBytesWritten)})");
+ return new DownloadResult(
+ ex,
+ totalBytesWritten,
+ (Xxh3Hash)xxh3Digest.GetHash(),
+ xxh3Digest,
+ referenceMd5Hash,
+ digest,
+ actualExpectedHash);
+ }
+ else
+ {
+ // Treat a zero-sized partial download candidate as an unrecoverable failed download
+
+ L.CallerError($"{url}: download {stateStr}.");
+ return new DownloadResult(ex);
+ }
+ }
+ catch (GameFileUpdateException ex)
+ {
+ // These should have logged their errors by now
+
+ return new DownloadResult(ex);
+ }
+ catch (Exception ex)
+ {
+ L.CallerError(ex, $"{url}: download failed.");
+
+ return new DownloadResult(ex);
+ }
+ }
+
+ private static async Task DownloadFilesAsync(
+ HttpClient httpClient,
+ WorkItem[] filesToDownload,
+ ParallelOptions parallelOptions,
+ IProgress? progress,
+ IProgress? offlineIndicatorProgress,
+ IProgress? downloadErrorIndicatorProgress)
+ {
+ if (!filesToDownload.Any())
+ {
+ // Return early not to end up with totalBytesToDownload == 0
+ L.CallerWarning($"Nothing to download.");
+ return Array.Empty();
+ }
+
+ using var loggedRoutine = new LoggedRoutine();
+
+ int totalFilesDownloaded = 0;
+ int totalFilesToDownload = filesToDownload.Length;
+ long totalBytesDownloaded = filesToDownload
+ .Where(f => f.PartialDownload != null)
+ .Sum(f => f.PartialDownload!.PartialSize);
+
+ // Don't use Nullable to allow atomic operations. Use the symbolic IsInvalidSize() instead.
+ long totalBytesToDownload = filesToDownload.Any(f => f.ManifestEntry.Size == null) ? default(long).GetInvalidSize() : filesToDownload
+ .Sum(f => f.ManifestEntry.Size!.Value);
+
+ // Monotonic value for throughput estimation
+ long totalBytesDownloadedEver = 0;
+ // Number of active network stream reads
+ // Throughput estimator will not report stalls unless this value > 0
+ int networkStreamReadsCount = 0;
+
+ {
+ var totalRemainingBytesToDownloadStr = totalBytesToDownload.IsInvalidSize() ? "?" : Formatting.FormatSizeInMiB(totalBytesToDownload - totalBytesDownloaded);
+ var totalPartialDownloads = filesToDownload.Count(f => f.PartialDownload != null);
+
+ L.CallerInformation($"Downloading {totalFilesToDownload} file(s) ({totalRemainingBytesToDownloadStr} total; {totalPartialDownloads} partial download(s) being resumed)...");
+ }
+
+ {
+ var pv = new PV().Clear();
+
+ pv.SetFileCount(totalFilesDownloaded, totalFilesToDownload);
+ if (totalBytesToDownload.IsInvalidSize())
+ {
+ pv.SetTotal(totalFilesDownloaded, totalFilesToDownload);
+ }
+ else
+ {
+ pv.SetTotal(totalBytesDownloaded, totalBytesToDownload);
+ }
+ pv.SetBytes(totalBytesDownloaded, totalBytesToDownload);
+
+ progress?.Report(pv.Extract());
+ }
+
+ List downloadResults = new(totalFilesToDownload);
+
+ try
+ {
+ // Do throughput estimation asynchronously to be able to detect and present any connection stalls
+
+ var throughputStopwatch = Stopwatch.StartNew();
+
+ const int ThroughputEstimatorIntervalMilliseconds = 100;
+ const int OverSecondWorthSampleCount = 1000 / ThroughputEstimatorIntervalMilliseconds + 2;
+ // A poor-man's circular buffer that stores slightly more than a second worth of samples
+ LinkedList throughputSamples = new();
+ // First ever-recorded sample
+ ThroughputEstimatorSample firstEverSample = null!;
+ // First sample with the final download size
+ ThroughputEstimatorSample firstFinalSample = null!;
+ bool connectionStalled = false;
+
+ var throughputProgressThrottle = new UpdateThrottle(intervalMilliseconds: 200);
+ // Hopefully this won't end up being too spammy on slower connections
+ var throughputLoggingThrottle = new UpdateThrottle(intervalMilliseconds: 1000);
+ bool throughputLoggingPaused = false;
+ long maxBytesPerSec = 0;
+
+ using var throughputEstimationTimer = new SimpleTimer(TimeSpan.FromMilliseconds(ThroughputEstimatorIntervalMilliseconds), () =>
+ {
+ long localTotalBytesDownloadedEver = Interlocked.Read(ref totalBytesDownloadedEver);
+ var timePointMilliseconds = throughputStopwatch.ElapsedMilliseconds;
+
+ var currentSample = new ThroughputEstimatorSample(localTotalBytesDownloadedEver, timePointMilliseconds);
+ throughputSamples.AddFirst(currentSample);
+
+ if (throughputSamples.Count == 1)
+ {
+ firstEverSample = currentSample;
+ }
+
+ if (firstFinalSample == null || currentSample.Bytes > firstFinalSample.Bytes)
+ {
+ firstFinalSample = currentSample;
+ }
+
+ if (throughputSamples.Count > OverSecondWorthSampleCount)
+ {
+ throughputSamples.RemoveLast();
+ }
+
+ bool stalled =
+ networkStreamReadsCount > 0 &&
+ throughputSamples.Count >= OverSecondWorthSampleCount &&
+ throughputSamples
+ .Take(OverSecondWorthSampleCount)
+ .Select(s => s.Bytes)
+ .Distinct()
+ .Count() == 1;
+
+ // Stop logging if connection has stalled -- no need to keep putting out zeros into the log
+ if (stalled && throughputLoggingPaused)
+ {
+ offlineIndicatorProgress?.Report(true);
+
+ if (!connectionStalled)
+ {
+ connectionStalled = true;
+ L.CallerWarning("Connection stalled.");
+ }
+
+ return;
+ }
+ else
+ {
+ offlineIndicatorProgress?.Report(false);
+
+ throughputLoggingPaused = false;
+
+ if (connectionStalled)
+ {
+ connectionStalled = false;
+ L.CallerWarning("Connection restored.");
+ }
+ }
+
+ // Estimating using samples spanning across less than a second will inflate the throughput
+ if (throughputSamples.Count < OverSecondWorthSampleCount)
+ {
+ return;
+ }
+
+ var lastSample = throughputSamples.Last!.Value;
+
+ var bytesDownloaded = currentSample.Bytes - lastSample.Bytes;
+ var elapsedMilliseconds = currentSample.TimePointMilliseconds - lastSample.TimePointMilliseconds;
+
+ throughputProgressThrottle.UpdateIfPossible(() =>
+ {
+ progress?.Report(new PV()
+ .SetBytesPerSec(bytesDownloaded, elapsedMilliseconds)
+ .Extract()
+ );
+ });
+
+ var bytesPerSec = bytesDownloaded * 1000 / elapsedMilliseconds;
+ maxBytesPerSec = Math.Max(maxBytesPerSec, bytesPerSec);
+
+ throughputLoggingThrottle.UpdateIfPossible(() =>
+ {
+ throughputLoggingPaused = stalled;
+
+ L.CallerDebug($"Throughput: {Formatting.FormatThroughputInMiB(bytesPerSec)}");
+ });
+ }, onDispose: () =>
+ {
+ if (maxBytesPerSec > 0)
+ {
+ L.CallerDebug($"Max throughput: {Formatting.FormatThroughputInMiB(maxBytesPerSec)}");
+ }
+
+ if (firstEverSample != null && firstFinalSample != null)
+ {
+ L.CallerDebug($"Avg throughput: {Formatting.FormatThroughputInMiB(firstFinalSample.Bytes - firstEverSample.Bytes, firstFinalSample.TimePointMilliseconds - firstEverSample.TimePointMilliseconds)}");
+ }
+ });
+
+ // The actual download...
+
+ SimpleTimer? updateTimer = null;
+
+ if (progress != null)
+ {
+ updateTimer = new(() =>
+ {
+ var localTotalBytesDownloaded = Interlocked.Read(ref totalBytesDownloaded);
+ var localTotalBytesToDownload = Interlocked.Read(ref totalBytesToDownload);
+ var localTotalFilesDownloaded = totalFilesDownloaded;
+
+ var pv = new PV();
+
+ pv.SetFileCount(localTotalFilesDownloaded, totalFilesToDownload);
+ if (localTotalBytesToDownload.IsInvalidSize())
+ {
+ pv.SetTotal(localTotalFilesDownloaded, totalFilesToDownload);
+ }
+ else
+ {
+ pv.SetTotal(localTotalBytesDownloaded, localTotalBytesToDownload);
+ }
+ pv.SetBytes(localTotalBytesDownloaded, localTotalBytesToDownload);
+
+ progress.Report(pv.Extract());
+ });
+ }
+
+ using (updateTimer)
+ {
+ await Parallel.ForEachAsync(filesToDownload.OrderByDescending(f => f.ManifestEntry.Size), parallelOptions, async (f, ct) =>
+ {
+ bool sizeConfirmed = false;
+ bool lastReadingNetworkStream = false;
+
+ long previousTotalFileBytesDownloaded = f.PartialDownload?.PartialSize ?? 0;
+
+ f.DownloadResult = await DownloadFileAsync(
+ httpClient,
+ f.Url,
+ f.DownloadPath,
+ f.ManifestEntry.Md5Hash,
+ f.ManifestEntry.BestHash,
+ ct,
+ f.PartialDownload,
+ new DirectProgress>(t =>
+ {
+ (var fileBytesDownloaded, var totalFileBytesDownloaded, var totalFileSize, var readingNetworkStream) = t;
+
+ if (!sizeConfirmed)
+ {
+ sizeConfirmed = true;
+
+ if (totalFileSize != null)
+ {
+ if (!totalBytesToDownload.IsInvalidSize())
+ {
+ Interlocked.Add(ref totalBytesToDownload, totalFileSize.Value - f.ManifestEntry.Size.GetValueOrDefault(0));
+ }
+
+ if (f.ManifestEntry.Size != totalFileSize)
+ {
+ if (f.ManifestEntry.Size != null)
+ {
+ var fromSizeStr = Formatting.FormatSizeInMiB(f.ManifestEntry.Size.Value, appendUnits: false);
+ var toSizeStr = Formatting.FormatSizeInMiB(totalFileSize.Value);
+
+ L.CallerWarning($"{f.ManifestEntry.Path}: updating {nameof(f.ManifestEntry.Size)} in manifest: {f.ManifestEntry.Size} -> {totalFileSize} bytes ({fromSizeStr} -> {toSizeStr})");
+ }
+
+ f.ManifestEntry.Size = totalFileSize;
+ }
+ }
+ }
+
+ Interlocked.Add(ref totalBytesDownloaded, totalFileBytesDownloaded - previousTotalFileBytesDownloaded);
+ previousTotalFileBytesDownloaded = totalFileBytesDownloaded;
+ Interlocked.Add(ref totalBytesDownloadedEver, fileBytesDownloaded);
+
+ if (lastReadingNetworkStream != readingNetworkStream)
+ {
+ if (readingNetworkStream)
+ {
+ Interlocked.Increment(ref networkStreamReadsCount);
+ }
+ else
+ {
+ Interlocked.Decrement(ref networkStreamReadsCount);
+ }
+
+ lastReadingNetworkStream = readingNetworkStream;
+ }
+ })).ConfigureAwait(false);
+
+ lock (downloadResults)
+ {
+ downloadResults.Add(f.DownloadResult);
+ }
+
+ if (!f.DownloadResult.IsSuccess)
+ {
+ downloadErrorIndicatorProgress?.Report(true);
+ }
+ else
+ {
+ if (f.ManifestEntry.Xxh3Hash != f.DownloadResult.Xxh3Hash)
+ {
+ if (f.ManifestEntry.Xxh3Hash != null)
+ {
+ L.CallerWarning($"{f.ManifestEntry.Path}: updating {nameof(f.ManifestEntry.Xxh3Hash)} in manifest");
+ }
+
+ f.ManifestEntry.Xxh3Hash = f.DownloadResult.Xxh3Hash;
+ }
+
+ Interlocked.Increment(ref totalFilesDownloaded);
+ }
+ }).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Swallow any cancellations to make sure DownloadResults get returned
+ }
+
+ return downloadResults.ToArray();
+ }
+
+ private static async Task RestoreFilesAsync(
+ WorkItem[] filesToRestore,
+ CancellationToken ct,
+ IProgress? progress)
+ {
+ if (!filesToRestore.Any())
+ {
+ // Return early not to end up with totalBytesToRestore == 0
+ L.CallerWarning($"Nothing to restore.");
+ return;
+ }
+
+ using var loggedRoutine = new LoggedRoutine();
+
+ var updateThrottle = new UpdateThrottle();
+
+ int totalFilesRestored = 0;
+ int totalFilesToRestore = filesToRestore.Length;
+ long totalBytesRestored = 0;
+ long? totalBytesToRestore = filesToRestore.Any(f => f.ManifestEntry.Size == null) ? (long?)null :
+ filesToRestore
+ .Sum(f => f.ManifestEntry.Size!.Value);
+
+ {
+ var totalBytesToRestoreStr = totalBytesToRestore == null ? "?" : Formatting.FormatSizeInMiB(totalBytesToRestore.Value);
+
+ L.CallerInformation($"Restoring {totalFilesToRestore} files ({totalBytesToRestoreStr} total)...");
+ }
+
+ progress?.Report(new PV()
+ .Clear()
+ .SetFileCount(totalFilesRestored, totalFilesToRestore)
+ .SetBytes(totalBytesRestored, totalBytesToRestore)
+ .Extract()
+ );
+
+ // Perform this stage sequentially
+
+ foreach (var f in filesToRestore)
+ {
+ L.CallerDebug($"{f.InstallPath}: restoring...");
+
+ await CopyFileAsync(f.DownloadPath, f.InstallPath, ct, new DirectProgress>(t =>
+ {
+ (var fileBytesCopied, var _, var _) = t;
+
+ totalBytesRestored += fileBytesCopied;
+
+ updateThrottle.UpdateIfPossible(() =>
+ {
+ var pv = new PV();
+
+ if (totalBytesToRestore != null)
+ {
+ pv.SetTotal(totalBytesRestored, totalBytesToRestore.Value);
+ }
+ else
+ {
+ pv.SetTotal(totalFilesRestored, totalFilesToRestore);
+ }
+ pv.SetBytes(totalBytesRestored, totalBytesToRestore);
+ progress?.Report(pv.Extract());
+ });
+ })).ConfigureAwait(false);
+
+ ++totalFilesRestored;
+
+ var pv = new PV();
+
+ pv.SetFileCount(totalFilesRestored, totalFilesToRestore);
+ if (totalBytesToRestore != null)
+ {
+ pv.SetTotal(totalBytesRestored, totalBytesToRestore.Value);
+ }
+ else
+ {
+ pv.SetTotal(totalFilesRestored, totalFilesToRestore);
+ }
+ pv.SetBytes(totalBytesRestored, totalBytesToRestore);
+ progress?.Report(pv.Extract());
+ }
+ }
+
+ public async Task UpdateAsync(
+ bool workOffline,
+ UpdateMode updateMode,
+ bool useHttp2,
+ FileUpdateModel fileUpdateModel,
+ IProgress? progress = null,
+ IProgress? disabledTextProgress = null,
+ IProgress? offlineIndicatorProgress = null,
+ IProgress? downloadErrorIndicatorProgress = null,
+ CancellationToken cancellationToken = default)
+ {
+ using var loggedRoutine = new LoggedRoutine();
+
+ var parallelOptions = new ParallelOptions
+ {
+ CancellationToken = cancellationToken,
+ MaxDegreeOfParallelism = Environment.ProcessorCount
+ };
+
+ using var socketsHttpHandler = new SocketsHttpHandler()
+ {
+ ConnectTimeout = TimeSpan.FromSeconds(3),
+
+ // These seem to be only relevant for HTTP/2
+ KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
+ KeepAlivePingDelay = TimeSpan.FromSeconds(5),
+ KeepAlivePingTimeout = TimeSpan.FromSeconds(5),
+ };
+
+ using var httpClient = new HttpClient(socketsHttpHandler)
+ {
+ DefaultVersionPolicy = useHttp2 ? HttpVersionPolicy.RequestVersionOrHigher : HttpVersionPolicy.RequestVersionOrLower,
+ DefaultRequestVersion = useHttp2 ? new Version(2, 0) : new Version(1, 1),
+
+#if !DEBUG
+ // Slightly above the 15 sec timeout of a DNS query (https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.timeout#remarks).
+ // This will only affect stalling connections in practice.
+ // Any downloads interrupted due to timeout can be subsequently resumed.
+ Timeout = TimeSpan.FromSeconds(20)
+#else
+ // Use an aggressive timeout for debugging
+ Timeout = TimeSpan.FromSeconds(2)
+#endif
+ };
+
+ httpClient.DefaultRequestHeaders.UserAgent.Clear();
+ httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PD2Launcher", Constants.VersionString));
+
+ L.CallerWrite(workOffline ? LogEventLevel.Warning : LogEventLevel.Information, $"Attempting to work {(workOffline ? "OFFLINE" : "online")}...");
+ L.CallerWrite(updateMode.IsNormal() ? LogEventLevel.Information : LogEventLevel.Warning, $"Using {updateMode} {nameof(UpdateMode)}");
+ L.CallerInformation($"Using HttpClient with HTTP/{httpClient.DefaultRequestVersion}");
+
+ // Make sure remoteUrlRoot ends with a single '/' for easy concatenation
+ string remoteUrlRoot = fileUpdateModel.Client.TrimEnd('/') + "/";
+ // The only better alternative to simple concatenation here is a dedicated library
+ string metadataUrl = remoteUrlRoot + "metadata.json";
+
+ string installRoot = Env.GetCwd();
+ string launcherFilesRoot = Env.GetLauncherFilesRootDirPath();
+ string updateModelRoot = Path.Combine(launcherFilesRoot, fileUpdateModel.FilePath);
+ string downloadRoot = Path.Combine(updateModelRoot, "downloads");
+
+ string manifestPath = Path.Combine(updateModelRoot, "manifest.json");
+
+ L.CallerInformation($"Using install path: '{installRoot}'");
+ L.CallerInformation($"Using download path: '{downloadRoot}'");
+
+ // Load current context
+
+ if (!_fileUpdateModelToContext.TryGetValue(fileUpdateModel, value: out Context? ctx))
+ {
+ ctx = new();
+ _fileUpdateModelToContext.Add(fileUpdateModel, ctx);
+ }
+
+ // Load local manifest if not loaded already
+
+ // ...unless running in Reset mode
+ if (updateMode.IsReset())
+ {
+ L.CallerWarning($"Clearing manifest due to {updateMode} {nameof(UpdateMode)}...");
+
+ ctx.manifestEntries = Array.Empty();
+ }
+ else
+ {
+ if (!ctx.manifestEntries.Any())
+ {
+ try
+ {
+ ctx.manifestEntries = LoadManifest(manifestPath);
+
+ L.CallerInformation($"Loaded {ctx.manifestEntries.Length} manifest entries");
+ }
+ catch (LoadManifestException ex)
+ {
+ L.CallerError(ex.InnerException, ex.Message);
+ }
+ catch (Exception ex)
+ {
+ L.CallerError(ex.InnerException, $"{nameof(LoadManifest)}() threw");
+ }
+ }
+ else
+ {
+ L.CallerInformation($"Manifest entries already loaded: {ctx.manifestEntries.Length}");
+ }
+ }
+
+ // Retrieve remote metadata...
+
+ bool isOffline = true;
+ Exception? metadataEx = null;
+
+ // ...unless working offline
+ if (workOffline)
+ {
+ L.CallerWarning($"Skipping metadata download due to working OFFLINE...");
+ }
+ else
+ {
+ disabledTextProgress?.Report("Metadata...");
+
+ ManifestEntry[] metadataEntries = Array.Empty();
+
+ try
+ {
+ metadataEntries = await DownloadMetadata(httpClient, metadataUrl, parallelOptions.CancellationToken).ConfigureAwait(false);
+
+ L.CallerInformation($"Metadata entries retrieved: {metadataEntries.Length}");
+ }
+ catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken)
+ {
+ // Rethrow own cancellation
+ throw;
+ }
+ catch (LoadMetadataException ex)
+ {
+ metadataEx = ex;
+
+ L.CallerError(ex.InnerException, ex.Message);
+ }
+ catch (Exception ex)
+ {
+ metadataEx = ex;
+
+ if (ex is HttpRequestException || ex is OperationCanceledException)
+ {
+ // Treat external cancellation as a likely connection disruption
+ offlineIndicatorProgress?.Report(true);
+ }
+
+ L.CallerError(ex.InnerException, $"{nameof(DownloadMetadata)} threw");
+ }
+
+ if (metadataEx == null)
+ {
+ isOffline = false;
+
+ if (!metadataEntries.Any())
+ {
+ offlineIndicatorProgress?.Report(true);
+
+ // This should really never happen, but if the retrieved metadata is, in fact, invalid, it's impossible to proceed.
+ throw new InvalidMetadataRetrieved();
+ }
+
+ // ...and merge with manifestEntries
+
+ int newMetadataEntries = 0;
+ int reusedMetadataEntries = 0;
+ int updatedMetadataEntries = 0;
+
+ // For every matching entry in manifestEntries with the same MD5, retain any additional info present in manifestEntries...
+ //
+ // Since left outer joins are only available since .NET 10, rely on GroupJoin (https://learn.microsoft.com/en-us/dotnet/csharp/linq/standard-query-operators/join-operations#emulate-a-left-outer-join)
+ foreach (var e in metadataEntries.GroupJoin(ctx.manifestEntries, meta => meta.Path, mani => mani.Path, (meta, manis) => new
+ {
+ meta,
+ // Expect manifest entries to be de-duplicated at this point, therefore the collection should either contain one element or none
+ mani = manis.FirstOrDefault()
+ }, StringComparer.OrdinalIgnoreCase))
+ {
+ // No matching manifestEntry for metadata one
+ if (e.mani == null)
+ {
+ ++newMetadataEntries;
+ continue;
+ }
+
+ if (e.meta.Md5Hash == e.mani.Md5Hash)
+ {
+ ++reusedMetadataEntries;
+ e.meta.Size = e.mani.Size;
+ e.meta.Xxh3Hash = e.mani.Xxh3Hash;
+ e.meta.Dirty = false;
+ }
+ else
+ {
+ ++updatedMetadataEntries;
+ }
+ }
+
+ // ...and eventually, replace local manifestEntries with trusted metadataEntries.
+ ctx.manifestEntries = metadataEntries;
+
+ L.CallerInformation($"Final manifest entries: {metadataEntries.Length} ({newMetadataEntries} new, {reusedMetadataEntries} reused, {updatedMetadataEntries} updated)");
+ LogManifestStats(ctx.manifestEntries);
+ }
+
+ // Based on successful metadata download, indicate whether we think we're offline or not
+
+ L.CallerWrite(isOffline ? LogEventLevel.Warning : LogEventLevel.Information, $"Deemed {(isOffline ? "OFFLINE" : "online")}");
+ offlineIndicatorProgress?.Report(isOffline);
+ }
+
+ if (isOffline && !ctx.manifestEntries.Any())
+ {
+ // Running offline with no prior manifest
+ throw new OfflineInvalidManifest(metadataEx);
+ }
+
+ // Validate files according to the manifest and, if needed, determine files to restore and to download
+
+ var filesToRestore = Array.Empty();
+ var filesToDownload = Array.Empty();
+
+ disabledTextProgress?.Report("Validating...");
+ {
+ using var loggedScope = new LoggedScope("Validating...");
+
+ var workItems = ctx.manifestEntries
+ .Select(e => new WorkItem(
+ manifestEntry: e,
+ // Just concat as that's the most reliable approach given remoteUrlRoot has been sanitized
+ url: remoteUrlRoot + e.Path,
+ // Use Path.GetFullPath() in place of Path.Combine() since Path is expected to be relative and GetFullPath()
+ // can deal with and transform all path separators to native ones
+ downloadPath: Path.GetFullPath(e.Path, downloadRoot),
+ installPath: Path.GetFullPath(e.Path, installRoot),
+ partialDownload: ctx.partialDownloads.GetValueOrDefault(e.Path)))
+ .ToArray();
+
+ // Verify that PartialDownloads refer to the exact files present in the manifest (in case of metadata update occurring between cancel/resume)
+ foreach (var d in workItems)
+ {
+ if (d.PartialDownload != null && d.PartialDownload.ReferenceMd5Hash != d.ManifestEntry.Md5Hash)
+ {
+ L.CallerWarning($"{d.ManifestEntry.Path}: partial download refers to a different version of the file. Discarding...");
+
+ d.PartialDownload = null;
+ }
+ }
+
+ switch (updateMode)
+ {
+ case UpdateMode.Normal:
+ // (I1) FilesToRestore = [All files] -> [InstallFiles that failed Validation]
+ {
+ await ValidateFilesAsync(workItems, ValidationKind.InstallFiles, parallelOptions, progress).ConfigureAwait(false);
+
+ // Restore all InstallFiles that explicitly failed validation (excluding loosely validated InstallFiles)
+ filesToRestore = workItems
+ .Where(wi => !wi.InstallFileValidated)
+ .ToArray();
+ }
+ break;
+
+ case UpdateMode.Restore:
+ // (I2) FilesToRestore = [All files] -> [InstallFiles that failed Validation] + [InstallFiles loosely validated]
+ {
+ await ValidateFilesAsync(workItems, ValidationKind.InstallFiles, parallelOptions, progress).ConfigureAwait(false);
+
+ // InstallFiles that either failed validation or were loosely validated
+ filesToRestore = workItems
+ .Where(wi => !wi.InstallFileValidated || wi.ManifestEntry.LooseValidation)
+ .ToArray();
+ }
+ break;
+
+ case UpdateMode.Download:
+ // (I3) FilesToRestore = [None]
+ {
+ L.CallerWarning($"Not restoring any files due to {updateMode} {nameof(UpdateMode)}...");
+ }
+ break;
+
+ case UpdateMode.Reset:
+ // (I4) FilesToRestore = [All files]
+ {
+ L.CallerWarning($"Forcing unconditional restore of all {workItems.Length} files due to {updateMode} {nameof(UpdateMode)}...");
+
+ filesToRestore = workItems;
+ }
+ break;
+ }
+
+ L.CallerInformation($"Files to restore: {filesToRestore.Length}");
+
+ switch (updateMode)
+ {
+ case UpdateMode.Normal:
+ case UpdateMode.Restore:
+ // (D1) FilesToDownload = [FilesToRestore] -> [DownloadFiles that failed Validation] + [DownloadFiles with PartialDownloads]
+ {
+ await ValidateFilesAsync(filesToRestore, ValidationKind.DownloadFiles, parallelOptions, progress).ConfigureAwait(false);
+
+ filesToDownload = filesToRestore
+ .Where(wi => !wi.DownloadFileValidated || wi.PartialDownload != null)
+ .ToArray();
+ }
+ break;
+
+ case UpdateMode.Download:
+ // (D2) FilesToDownload = [All files] -> [DownloadFiles that failed Validation] + [DownloadFiles with PartialDownloads]
+ {
+ await ValidateFilesAsync(workItems, ValidationKind.DownloadFiles, parallelOptions, progress).ConfigureAwait(false);
+
+ filesToDownload = workItems
+ .Where(wi => !wi.DownloadFileValidated || wi.PartialDownload != null)
+ .ToArray();
+ }
+ break;
+
+ case UpdateMode.Reset:
+ // (D3) FilesToDownload = [All files]
+ {
+ // Validate any associated PartialDownloads
+ var partialDownloads = workItems
+ .Where(wi => wi.PartialDownload != null)
+ .ToArray();
+
+ L.CallerWarning($"Forcing validation of {partialDownloads.Length} {ValidationKind.DownloadFiles} with a {nameof(PartialDownload)} due to {updateMode} {nameof(UpdateMode)}...");
+
+ await ValidateFilesAsync(partialDownloads, ValidationKind.DownloadFiles, parallelOptions, progress).ConfigureAwait(false);
+
+ L.CallerWarning($"Forcing download of all {workItems.Length} {ValidationKind.DownloadFiles} due to {updateMode} {nameof(UpdateMode)}...");
+
+ filesToDownload = workItems;
+ }
+ break;
+ }
+
+ {
+ var partialDownloadsCount = filesToDownload.Count(f => f.PartialDownload != null);
+
+ L.CallerInformation($"Files to download: {filesToDownload.Length} (including {partialDownloadsCount} partial download(s))");
+ }
+ }
+
+ // Attempt to save the manifest at this stage.
+ //
+ // This can succeed when the files were already there (and have been validated) but the manifest was missing.
+ //
+ // (Disallow cancelling this)
+ await SaveManifest(manifestPath, ctx.manifestEntries, ct: default).ConfigureAwait(false);
+
+ // Validation failed and some files need to be re-downloaded, which is impossible
+ if (isOffline && filesToDownload.Any())
+ {
+ throw new OfflineNeedsDownload(metadataEx);
+ }
+
+ DownloadResult[] downloadResults = Array.Empty();
+
+ if (!isOffline && filesToDownload.Any())
+ {
+ // Determine Content-Length and availability of Content-Range for any download missing Size...
+
+ // ...or all of them in case of running in Reset mode
+ if (updateMode.IsReset())
+ {
+ L.CallerWarning($"Forcing query of all {filesToDownload.Length} files due to {updateMode} {nameof(UpdateMode)}...");
+ }
+
+ var filesMissingSize = updateMode.IsReset() ? filesToDownload :
+ filesToDownload
+ .Where(f => f.ManifestEntry.Size == null)
+ .ToArray();
+
+ if (filesMissingSize.Any())
+ {
+ disabledTextProgress?.Report("Querying...");
+
+ var initialSize = updateMode.IsReset() ? 0 :
+ filesToDownload
+ .Where(f => f.ManifestEntry.Size != null)
+ .Sum(f => f.ManifestEntry.Size!.Value);
+
+ await QueryDownloadsAsync(httpClient, filesMissingSize, initialSize, parallelOptions, progress).ConfigureAwait(false);
+
+ // Attempt to save the manifest at this stage.
+ //
+ // This should succeed as all missing Sizes should have been just retrieved.
+ //
+ // (Disallow cancelling this)
+ await SaveManifest(manifestPath, ctx.manifestEntries, ct: default).ConfigureAwait(false);
+ }
+
+ disabledTextProgress?.Report("Downloading...");
+
+ // Prune PartialDownloads before starting downloads.
+ // These should have been already assigned to their respective WorkItems before Validation.
+ // Once the downloads start, these PartialDownloads are instantly inaccurate/invalid.
+ ctx.partialDownloads.Clear();
+
+ // DownloadFilesAsync() should catch most exceptions to be able to return its DownloadResults
+ downloadResults = await DownloadFilesAsync(
+ httpClient,
+ filesToDownload,
+ parallelOptions,
+ progress,
+ offlineIndicatorProgress,
+ downloadErrorIndicatorProgress).ConfigureAwait(false);
+
+ // Go over all DownloadResults and store PartialDownloads
+ foreach (var f in filesToDownload)
+ {
+ if (f.DownloadResult?.PartialDownload != null)
+ {
+ L.CallerInformation($"{f.ManifestEntry.Path}: storing partial download at {f.DownloadResult.PartialDownload.PartialSize} ({Formatting.FormatSizeInMiB(f.DownloadResult.PartialDownload.PartialSize)})");
+
+ ctx.partialDownloads.Add(f.ManifestEntry.Path, f.DownloadResult.PartialDownload);
+ }
+ }
+
+ L.CallerInformation($"Successfully downloaded {filesToDownload.Count(f => f.DownloadResult?.IsSuccess == true)}/{filesToDownload.Length} files");
+
+ // Just throw if cancellation occurred (likely inside DownloadFilesAsync()) without going over all of DownloadResults' Exceptions.
+ // Since subsequent operations will also trip up on OperationCanceledException, there's no point in trying to continue.
+ parallelOptions.CancellationToken.ThrowIfCancellationRequested();
+ }
+
+ // Attempt to try to save the manifest as soon as possible after downloading.
+ //
+ // This should succeed in case Sizes could only be determined by downloading files in full.
+ // However, if any of the files fail to download, the manifest will never get saved.
+ //
+ // Due to variety of possible failures in DownloadFilesAsync(), attempt to save the manifest safely, so that any failure in doing so
+ // won't overshadow exceptions thrown by DownloadFilesAsync().
+ //
+ // (Disallow cancelling this)
+ (var _, var saveManifestEx) = await TrySaveManifest(manifestPath, ctx.manifestEntries, ct: default).ConfigureAwait(false);
+
+ if (saveManifestEx != null)
+ {
+ L.CallerError(saveManifestEx, $"{nameof(TrySaveManifest)}() failed.");
+ }
+
+ // Rethrow any exceptions from DownloadFilesAsync()
+ if (downloadResults.Any(r => !r.IsSuccess && r.PartialDownload == null))
+ {
+ throw new DownloadException(new AggregateException(downloadResults
+ .Where(r => !r.IsSuccess && r.PartialDownload == null)
+ .Select(r => r.Exception!)
+ .ToArray()),
+ $"Failed downloads: {downloadResults.Count(r => !r.IsSuccess && r.PartialDownload == null)}/{downloadResults.Length}");
+ }
+
+ // Rethrow any exceptions from TrySaveManifest()
+ if (saveManifestEx != null)
+ {
+ throw saveManifestEx;
+ }
+
+ // Restore files
+
+ if (filesToRestore.Any())
+ {
+ var restorableFiles = filesToRestore
+ .Where(f => f.DownloadFileValidated || f.DownloadResult?.IsSuccess == true)
+ .ToArray();
+
+ L.CallerInformation($"Restorable files: {restorableFiles.Length}/{filesToRestore.Length}");
+
+ if (restorableFiles.Any())
+ {
+ disabledTextProgress?.Report("Restoring...");
+
+ await RestoreFilesAsync(restorableFiles, parallelOptions.CancellationToken, progress).ConfigureAwait(false);
+ }
+ }
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Hash.cs b/PD2Shared/GameFileUpdate/Hash.cs
new file mode 100644
index 00000000..3e032b69
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Hash.cs
@@ -0,0 +1,126 @@
+namespace PD2Shared.GameFileUpdate
+{
+ public abstract class Hash
+ {
+ protected Hash()
+ {
+ this.Bytes = Array.Empty();
+ }
+
+ protected Hash(byte[] bytes)
+ {
+ if (bytes.Length != this.SizeInBytes)
+ {
+ throw new ArgumentException($"Invalid hash size for {this.SizeInBytes * 8} bit {this.Name}: {this.SizeInBytes} != {bytes.Length} bytes (actual)", nameof(bytes));
+ }
+
+ this.Bytes = bytes;
+ }
+
+ protected Hash(string hexString)
+ {
+ ArgumentNullException.ThrowIfNull(hexString, nameof(hexString));
+
+ byte[] bytes;
+
+ try
+ {
+ bytes = Convert.FromHexString(hexString);
+ }
+ catch (FormatException ex)
+ {
+ throw new ArgumentException($"Not a valid hash: '{hexString}'", nameof(hexString), ex);
+ }
+
+ if (bytes.Length != this.SizeInBytes)
+ {
+ throw new ArgumentException($"Not a valid {this.SizeInBytes * 8} bit {this.Name}: '{hexString}'", nameof(hexString));
+ }
+
+ this.Bytes = bytes;
+ }
+
+ public string ToHexString()
+ {
+ return Convert.ToHexString(Bytes).ToLowerInvariant();
+ }
+
+ public abstract string Name { get; }
+ public abstract int SizeInBytes { get; }
+
+ public byte[] Bytes { get; }
+
+ public static bool operator ==(Hash? left, Hash? right)
+ {
+ if (left is null && right is null) return true;
+ if (left is null || right is null) return false;
+
+ if (left.SizeInBytes != right.SizeInBytes)
+ {
+ return false;
+ }
+
+ if (left.Name != right.Name)
+ {
+ return false;
+ }
+
+ return left.Bytes.SequenceEqual(right.Bytes);
+ }
+ public static bool operator !=(Hash? left, Hash? right)
+ {
+ return !(left == right);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(this, obj))
+ {
+ return true;
+ }
+
+ if (ReferenceEquals(obj, null))
+ {
+ return false;
+ }
+
+ var objAsHash = obj as Hash;
+
+ if (objAsHash is null)
+ {
+ return false;
+ }
+
+ return this == objAsHash;
+ }
+
+ public override int GetHashCode()
+ {
+ return this.Bytes.GetHashCode();
+ }
+ }
+
+ // ...and all the available concrete classes
+
+ public class Md5Hash : Hash
+ {
+ public Md5Hash() : base() { }
+
+ public Md5Hash(byte[] bytes) : base(bytes) { }
+ public Md5Hash(string hexString) : base(hexString) { }
+
+ public override string Name { get => "MD5"; }
+ public override int SizeInBytes { get => 128 / 8; }
+ }
+
+ public class Xxh3Hash : Hash
+ {
+ public Xxh3Hash() : base() { }
+
+ public Xxh3Hash(byte[] bytes) : base(bytes) { }
+ public Xxh3Hash(string hexString) : base(hexString) { }
+
+ public override string Name { get => "XXH3"; }
+ public override int SizeInBytes { get => 64 / 8; }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/Context.cs b/PD2Shared/GameFileUpdate/Internal/Context.cs
new file mode 100644
index 00000000..aa657529
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/Context.cs
@@ -0,0 +1,8 @@
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal class Context
+ {
+ public ManifestEntry[] manifestEntries = Array.Empty();
+ public Dictionary partialDownloads = new();
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/Digest.cs b/PD2Shared/GameFileUpdate/Internal/Digest.cs
new file mode 100644
index 00000000..4ce8916d
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/Digest.cs
@@ -0,0 +1,250 @@
+using Org.BouncyCastle.Crypto.Digests;
+using System.IO.Hashing;
+using System.Security.Cryptography;
+
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal abstract class Digest
+ {
+ public static NonFinalizingDigest GetNonFinalizing(Hash hash)
+ {
+ if (hash is Md5Hash)
+ {
+ return new NonFinalizingMd5();
+ }
+ else if (hash is Xxh3Hash)
+ {
+ return new NonFinalizingXxh3();
+ }
+ else
+ {
+ throw new NotSupportedException($"Unable to create {nameof(NonFinalizingDigest)} for {nameof(Hash)} of type {hash.GetType()}.");
+ }
+ }
+
+ public static DisposableDigest GetDisposable(Hash hash)
+ {
+ if (hash is Md5Hash)
+ {
+ return new DisposableMd5();
+ }
+ else if (hash is Xxh3Hash)
+ {
+ return new DisposableXxh3();
+ }
+ else
+ {
+ throw new NotSupportedException($"Unable to create {nameof(DisposableDigest)} for {nameof(Hash)} of type {hash.GetType()}.");
+ }
+ }
+
+ private readonly bool _isFinalizing;
+ private Hash _finalizedHash = null!;
+ private bool _finalized = false;
+
+ protected Digest() : this(isFinalizing: true)
+ {
+ }
+
+ protected Digest(bool isFinalizing)
+ {
+ _isFinalizing = isFinalizing;
+ }
+
+ public abstract string HashName { get; }
+
+ public abstract bool IsHashType() where THash : Hash;
+
+ private void CheckIfFinalized()
+ {
+ if (_finalized)
+ {
+ throw new InvalidOperationException("Digest is finalized");
+ }
+ }
+
+ public async Task HashStream(Stream inputStream, CancellationToken cancellationToken)
+ {
+ CheckIfFinalized();
+
+ if (_isFinalizing)
+ {
+ _finalized = true;
+ _finalizedHash = await HashStreamInternal(inputStream, cancellationToken).ConfigureAwait(false);
+
+ return _finalizedHash;
+ }
+ else
+ {
+ return await HashStreamInternal(inputStream, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ protected abstract Task HashStreamInternal(Stream inputStream, CancellationToken cancellationToken);
+
+ public void Update(byte[] buffer, int offset, int count)
+ {
+ CheckIfFinalized();
+
+ UpdateInternal(buffer, offset, count);
+ }
+
+ protected abstract void UpdateInternal(byte[] buffer, int offset, int count);
+
+ public Hash GetHash()
+ {
+ CheckIfFinalized();
+
+ if (_isFinalizing)
+ {
+ _finalized = true;
+ _finalizedHash = GetHashInternal();
+
+ return _finalizedHash;
+ }
+ else
+ {
+ return GetHashInternal();
+ }
+ }
+
+ protected abstract Hash GetHashInternal();
+ }
+
+ internal abstract class NonFinalizingDigest : Digest
+ {
+ protected NonFinalizingDigest() : base(isFinalizing: false)
+ {
+ }
+ }
+
+ internal abstract class DisposableDigest : Digest, IDisposable
+ {
+ public abstract void Dispose();
+ }
+
+ internal abstract class StrongNonFinalizingDigest : NonFinalizingDigest where THash : Hash, new()
+ {
+ private static readonly THash _emptyHash = new();
+
+ public override string HashName => _emptyHash.Name;
+ public override bool IsHashType()
+ {
+ return typeof(THash) == typeof(TOtherHash);
+ }
+ }
+
+ internal abstract class StrongDisposableDigest : DisposableDigest where THash : Hash, new()
+ {
+ private static readonly THash _emptyHash = new();
+
+ public override string HashName => _emptyHash.Name;
+ public override bool IsHashType()
+ {
+ return typeof(THash) == typeof(TOtherHash);
+ }
+ }
+
+ // Concrete classes:
+
+ internal class DisposableMd5 : StrongDisposableDigest
+ {
+ private static readonly byte[] _emptyBuffer = Array.Empty();
+ private readonly MD5 _md5 = MD5.Create();
+
+ protected override async Task HashStreamInternal(Stream inputStream, CancellationToken cancellationToken)
+ {
+ return new Md5Hash(await _md5.ComputeHashAsync(inputStream, cancellationToken).ConfigureAwait(false));
+ }
+
+ protected override void UpdateInternal(byte[] buffer, int offset, int count)
+ {
+ _md5.TransformBlock(buffer, offset, count, outputBuffer: null, outputOffset: 0);
+ }
+
+ protected override Md5Hash GetHashInternal()
+ {
+ _md5.TransformFinalBlock(_emptyBuffer, inputOffset: 0, inputCount: 0);
+
+ return new Md5Hash(_md5.Hash!);
+ }
+
+ public override void Dispose()
+ {
+ ((IDisposable)_md5).Dispose();
+ }
+ }
+
+ // This is merely a thin wrapper around NonFinalizingXxh3 to provide a DisposableDigest counterpart to DisposableMd5 so the two could be used interchangeably
+ internal class DisposableXxh3 : StrongDisposableDigest
+ {
+ private readonly NonFinalizingXxh3 _xxh3 = new();
+
+ protected override async Task HashStreamInternal(Stream inputStream, CancellationToken cancellationToken)
+ {
+ return (Xxh3Hash)await _xxh3.HashStream(inputStream, cancellationToken).ConfigureAwait(false);
+ }
+
+ protected override void UpdateInternal(byte[] buffer, int offset, int count)
+ {
+ _xxh3.Update(buffer, offset, count);
+ }
+
+ protected override Xxh3Hash GetHashInternal()
+ {
+ return (Xxh3Hash)_xxh3.GetHash();
+ }
+
+ public override void Dispose()
+ {
+ // Nothing to do here
+ }
+ }
+
+ internal class NonFinalizingMd5 : StrongNonFinalizingDigest
+ {
+ private readonly MD5Digest _md5 = new();
+
+ protected override async Task HashStreamInternal(Stream inputStream, CancellationToken cancellationToken)
+ {
+ // This won't be needed anyway
+
+ throw new NotImplementedException();
+ }
+
+ protected override void UpdateInternal(byte[] buffer, int offset, int count)
+ {
+ _md5.BlockUpdate(buffer, offset, count);
+ }
+
+ protected override Md5Hash GetHashInternal()
+ {
+ var bytes = new byte[_md5.GetDigestSize()];
+
+ new MD5Digest(_md5).DoFinal(bytes, outOff: 0);
+
+ return new Md5Hash(bytes);
+ }
+ }
+
+ internal class NonFinalizingXxh3 : StrongNonFinalizingDigest
+ {
+ private readonly XxHash3 _xxh3 = new();
+
+ protected override async Task HashStreamInternal(Stream inputStream, CancellationToken cancellationToken)
+ {
+ await _xxh3.AppendAsync(inputStream, cancellationToken).ConfigureAwait(false);
+ return new Xxh3Hash(_xxh3.GetCurrentHash());
+ }
+
+ protected override void UpdateInternal(byte[] buffer, int offset, int count)
+ {
+ _xxh3.Append(new ReadOnlySpan(buffer, offset, count));
+ }
+
+ protected override Xxh3Hash GetHashInternal()
+ {
+ return new Xxh3Hash(_xxh3.GetCurrentHash());
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/DownloadResult.cs b/PD2Shared/GameFileUpdate/Internal/DownloadResult.cs
new file mode 100644
index 00000000..d6f4da1b
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/DownloadResult.cs
@@ -0,0 +1,41 @@
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal class DownloadResult
+ {
+ public DownloadResult(Exception exception)
+ {
+ this.Exception = exception;
+ }
+
+ public DownloadResult(Xxh3Hash xxh3Hash)
+ {
+ this.Xxh3Hash = xxh3Hash;
+ }
+
+ public DownloadResult(
+ Exception? exception,
+ long partialSize,
+ Xxh3Hash partialXxh3Hash,
+ NonFinalizingXxh3 xxh3Digest,
+ Md5Hash referenceMd5Hash,
+ NonFinalizingDigest digest,
+ Hash expectedHash)
+ {
+ if (partialSize <= 0)
+ {
+ throw new ArgumentException($"Valid '{nameof(partialSize)}' for {nameof(PartialDownload)} must be >0", nameof(partialSize));
+ }
+
+ this.Exception = exception;
+
+ this.PartialDownload = new PartialDownload(partialSize, partialXxh3Hash, xxh3Digest, referenceMd5Hash, digest, expectedHash);
+
+ }
+
+ public bool IsSuccess { get => Exception == null; }
+
+ public Exception? Exception { get; } = null;
+ public Xxh3Hash? Xxh3Hash { get; } = null;
+ public PartialDownload? PartialDownload { get; } = null;
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/FileUpdateModelEqualityComparer.cs b/PD2Shared/GameFileUpdate/Internal/FileUpdateModelEqualityComparer.cs
new file mode 100644
index 00000000..9493fabb
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/FileUpdateModelEqualityComparer.cs
@@ -0,0 +1,21 @@
+using PD2Shared.Models;
+using System.Diagnostics.CodeAnalysis;
+
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal class FileUpdateModelEqualityComparer : IEqualityComparer
+ {
+ public bool Equals(FileUpdateModel? x, FileUpdateModel? y)
+ {
+ return
+ x?.Client == y?.Client &&
+ x?.FilePath == y?.FilePath &&
+ x?.Other == y?.Other;
+ }
+
+ public int GetHashCode([DisallowNull] FileUpdateModel obj)
+ {
+ return HashCode.Combine(obj.Client, obj.FilePath, obj.Other);
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/ManifestEntry.cs b/PD2Shared/GameFileUpdate/Internal/ManifestEntry.cs
new file mode 100644
index 00000000..94f4f861
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/ManifestEntry.cs
@@ -0,0 +1,105 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal class ManifestEntry
+ {
+ // Set of files that are subject to "loose validation" (they need to merely exist and have a non-zero size) as InstallFiles.
+ // Treat contained keys as exact paths. If any globbing is needed -- use a proper library for that.
+ //
+ // The file list provided by Constants.excludedFiles might be too broad, but stick with it for now...
+ private static readonly HashSet LooseValidationFileSet = new(Constants.excludedFiles, StringComparer.OrdinalIgnoreCase);
+
+ public ManifestEntry(string path, string md5, long? size = null, string? xxh3 = null)
+ {
+ this.Path = path;
+ this.LooseValidation = LooseValidationFileSet.Contains(this.Path);
+ this.Md5Hash = new Md5Hash(md5);
+
+ // These implicitly set Dirty = true
+
+ this.Size = size;
+ this.Xxh3Hash = xxh3 == null ? (Xxh3Hash?)null : new Xxh3Hash(xxh3);
+ }
+
+ public ManifestEntry(string path, SerializableManifest.Entry entry) : this(path, entry.Md5, entry.Size, entry.Xxh3)
+ {
+ this.Dirty = false;
+ }
+
+ private string _path;
+ public string Path
+ {
+ get => _path;
+ [MemberNotNull(nameof(_path))]
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value, nameof(value));
+
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException($"Invalid empty path: '{value}'", nameof(value));
+ }
+
+ if (System.IO.Path.IsPathRooted(value))
+ {
+ throw new ArgumentException($"The path is rooted: '{value}'", nameof(value));
+ }
+
+ _path = value;
+ }
+ }
+ public bool LooseValidation { get; }
+
+ public Md5Hash Md5Hash { get; }
+
+ private long? _size;
+ public long? Size
+ {
+ get => _size;
+ set
+ {
+ if (_size == value)
+ {
+ return;
+ }
+
+ if (value != null && value <= 0)
+ {
+ throw new ArgumentException($"'{nameof(Size)}' must be > 0", nameof(value));
+ }
+
+ _size = value;
+ Dirty = true;
+ }
+ }
+
+ private Xxh3Hash? _xxh3Hash = null;
+ public Xxh3Hash? Xxh3Hash
+ {
+ get => _xxh3Hash;
+ set
+ {
+ if (_xxh3Hash == value)
+ {
+ return;
+ }
+
+ _xxh3Hash = value;
+ Dirty = true;
+ }
+ }
+
+ public Hash BestHash
+ {
+ get => Xxh3Hash != null ? Xxh3Hash : Md5Hash;
+ }
+
+ public bool Dirty { get; set; }
+
+ public SerializableManifest.Entry ToSerializable()
+ {
+ return new SerializableManifest.Entry(this.Md5Hash.ToHexString(), this.Size, this.Xxh3Hash?.ToHexString());
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/PartialDownload.cs b/PD2Shared/GameFileUpdate/Internal/PartialDownload.cs
new file mode 100644
index 00000000..d3ef9fc6
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/PartialDownload.cs
@@ -0,0 +1,28 @@
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal class PartialDownload
+ {
+ public PartialDownload(
+ long partialSize,
+ Xxh3Hash partialXxh3Hash,
+ NonFinalizingXxh3 xxh3Digest,
+ Md5Hash referenceMd5Hash,
+ NonFinalizingDigest digest,
+ Hash expectedHash)
+ {
+ this.PartialSize = partialSize;
+ this.PartialXxh3Hash = partialXxh3Hash;
+ this.Xxh3Digest = xxh3Digest;
+ this.ReferenceMd5Hash = referenceMd5Hash;
+ this.Digest = digest;
+ this.ExpectedHash = expectedHash;
+ }
+
+ public long PartialSize { get; }
+ public Xxh3Hash PartialXxh3Hash { get; }
+ public NonFinalizingXxh3 Xxh3Digest { get; }
+ public Md5Hash ReferenceMd5Hash { get; }
+ public NonFinalizingDigest Digest { get; }
+ public Hash ExpectedHash { get; }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/ProgressValuesEx.cs b/PD2Shared/GameFileUpdate/Internal/ProgressValuesEx.cs
new file mode 100644
index 00000000..cfd4044e
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/ProgressValuesEx.cs
@@ -0,0 +1,12 @@
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal static class ProgressValuesEx
+ {
+ private const long _invalidSize = -1;
+
+ // Since Int64 is commonly used to store file sizes, whenever using a Nullable is undesirable, resort to the special value of "InvalidSize".
+ public static long GetInvalidSize(this long _) => _invalidSize;
+
+ public static bool IsInvalidSize(this long value) => value == _invalidSize;
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/SerializableManifest.cs b/PD2Shared/GameFileUpdate/Internal/SerializableManifest.cs
new file mode 100644
index 00000000..fbcb22d8
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/SerializableManifest.cs
@@ -0,0 +1,40 @@
+using System.Collections.Immutable;
+using System.Text.Json.Serialization;
+
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal class SerializableManifest
+ {
+ internal class Entry
+ {
+ [JsonConstructor]
+ public Entry(string md5, long? size, string? xxh3)
+ {
+ Md5 = md5;
+ Size = size;
+ Xxh3 = xxh3;
+ }
+
+ public string Md5 { get; }
+ public long? Size { get; }
+ public string? Xxh3 { get; }
+ }
+
+ public SerializableManifest(ManifestEntry[] manifestEntries)
+ {
+ Entries = manifestEntries
+ .ToImmutableSortedDictionary(e => e.Path, e => e.ToSerializable(), StringComparer.OrdinalIgnoreCase);
+ Count = Entries.Count;
+ }
+
+ [JsonConstructor]
+ public SerializableManifest(ImmutableSortedDictionary entries, int count)
+ {
+ Entries = entries;
+ Count = count;
+ }
+
+ public ImmutableSortedDictionary Entries { get; }
+ public int Count { get; }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/ThroughputEstimatorSample.cs b/PD2Shared/GameFileUpdate/Internal/ThroughputEstimatorSample.cs
new file mode 100644
index 00000000..15f9930e
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/ThroughputEstimatorSample.cs
@@ -0,0 +1,14 @@
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal class ThroughputEstimatorSample
+ {
+ public long Bytes { get; }
+ public long TimePointMilliseconds { get; }
+
+ public ThroughputEstimatorSample(long bytes, long timePointMilliseconds)
+ {
+ Bytes = bytes;
+ TimePointMilliseconds = timePointMilliseconds;
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/ValidationKind.cs b/PD2Shared/GameFileUpdate/Internal/ValidationKind.cs
new file mode 100644
index 00000000..8e757c3e
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/ValidationKind.cs
@@ -0,0 +1,22 @@
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal enum ValidationKind
+ {
+ DownloadFiles,
+ InstallFiles,
+ }
+
+ // Sneaking in an extension class for convenience
+ internal static class ValidationKindEx
+ {
+ public static bool IsDownloadFiles(this ValidationKind validationKind)
+ {
+ return validationKind == ValidationKind.DownloadFiles;
+ }
+
+ public static bool IsInstallFiles(this ValidationKind validationKind)
+ {
+ return validationKind == ValidationKind.InstallFiles;
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Internal/WorkItem.cs b/PD2Shared/GameFileUpdate/Internal/WorkItem.cs
new file mode 100644
index 00000000..16446f5a
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Internal/WorkItem.cs
@@ -0,0 +1,24 @@
+namespace PD2Shared.GameFileUpdate.Internal
+{
+ internal class WorkItem
+ {
+ public WorkItem(ManifestEntry manifestEntry, string url, string downloadPath, string installPath, PartialDownload? partialDownload)
+ {
+ ManifestEntry = manifestEntry;
+ Url = url;
+ DownloadPath = downloadPath;
+ InstallPath = installPath;
+ PartialDownload = partialDownload;
+ }
+
+ public ManifestEntry ManifestEntry { get; }
+ public string Url { get; }
+ public string DownloadPath { get; }
+ public string InstallPath { get; }
+
+ public bool InstallFileValidated { get; set; } = false;
+ public bool DownloadFileValidated { get; set; } = false;
+ public PartialDownload? PartialDownload { get; set; } = null;
+ public DownloadResult? DownloadResult { get; set; } = null;
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/ProgressValues.Data.cs b/PD2Shared/GameFileUpdate/ProgressValues.Data.cs
new file mode 100644
index 00000000..61761acd
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/ProgressValues.Data.cs
@@ -0,0 +1,20 @@
+namespace PD2Shared.GameFileUpdate
+{
+ public partial class ProgressValues
+ {
+ private class Data : IData
+ {
+ public double? Total { get; set; } = null;
+ public bool TotalSet { get; set; } = false;
+
+ public FileCountProgress? FileCount { get; set; } = null;
+ public bool FileCountSet { get; set; } = false;
+
+ public BytesProgress? Bytes { get; set; } = null;
+ public bool BytesSet { get; set; } = false;
+
+ public BytesPerSecProgress? BytesPerSec { get; set; } = null;
+ public bool BytesPerSecSet { get; set; } = false;
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/ProgressValues.IData.cs b/PD2Shared/GameFileUpdate/ProgressValues.IData.cs
new file mode 100644
index 00000000..aeed7fa4
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/ProgressValues.IData.cs
@@ -0,0 +1,24 @@
+namespace PD2Shared.GameFileUpdate
+{
+ public partial class ProgressValues
+ {
+ public interface IData
+ {
+ // Main progress indicator (0..1)
+ public double? Total { get; }
+ public bool TotalSet { get; }
+
+ // / counter indicator
+ public FileCountProgress? FileCount { get; }
+ public bool FileCountSet { get; }
+
+ // "(/) MiB" indicator
+ public BytesProgress? Bytes { get; }
+ public bool BytesSet { get; }
+
+ // " MiB/s" indicator
+ public BytesPerSecProgress? BytesPerSec { get; }
+ public bool BytesPerSecSet { get; }
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/ProgressValues.cs b/PD2Shared/GameFileUpdate/ProgressValues.cs
new file mode 100644
index 00000000..f3b37b64
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/ProgressValues.cs
@@ -0,0 +1,179 @@
+using PD2Shared.GameFileUpdate.Internal;
+
+namespace PD2Shared.GameFileUpdate
+{
+ public partial class ProgressValues
+ {
+ public class FileCountProgress
+ {
+ public int Current { get; set; }
+ public int Total { get; set; }
+ }
+
+ public class BytesProgress
+ {
+ public long Current { get; set; }
+ public long? Total { get; set; }
+ }
+
+ public class BytesPerSecProgress
+ {
+ public long Bytes { get; set; }
+ public long ElapsedMilliseconds { get; set; }
+ }
+
+ private Data _data = new();
+
+ public ProgressValues SetTotal(double total)
+ {
+ CheckIfExtracted();
+
+ if (!double.IsFinite(total))
+ {
+ throw new ArithmeticException($"'{nameof(total)}' must be finite");
+ }
+
+ _data.Total = total;
+ _data.TotalSet = true;
+ return this;
+ }
+
+ public ProgressValues SetTotal(double current, double total)
+ {
+ CheckIfExtracted();
+
+ if (!double.IsFinite(current))
+ {
+ throw new ArithmeticException($"'{nameof(current)}' must be finite");
+ }
+
+ if (!double.IsFinite(total))
+ {
+ throw new ArithmeticException($"'{nameof(total)}' must be finite");
+ }
+
+ if (total == 0)
+ {
+ throw new ArithmeticException($"'{nameof(total)}' must not be 0");
+ }
+
+ _data.Total = (double)current / total;
+ _data.TotalSet = true;
+ return this;
+ }
+
+ public ProgressValues ClearTotal()
+ {
+ CheckIfExtracted();
+
+ _data.Total = null;
+ _data.TotalSet = true;
+ return this;
+ }
+
+ public ProgressValues SetFileCount(int current, int total)
+ {
+ CheckIfExtracted();
+
+ _data.FileCount = new FileCountProgress { Current = current, Total = total };
+ _data.FileCountSet = true;
+ return this;
+ }
+
+ public ProgressValues ClearFileCount()
+ {
+ CheckIfExtracted();
+
+ _data.FileCount = null;
+ _data.FileCountSet = true;
+ return this;
+ }
+
+ public ProgressValues SetBytes(long current, long? total)
+ {
+ CheckIfExtracted();
+
+ _data.Bytes = new BytesProgress { Current = current, Total = total };
+ _data.BytesSet = true;
+ return this;
+ }
+
+ public ProgressValues SetBytes(long current, long total)
+ {
+ CheckIfExtracted();
+
+ _data.Bytes = new BytesProgress { Current = current, Total = total.IsInvalidSize() ? null : total };
+ _data.BytesSet = true;
+ return this;
+ }
+
+ public ProgressValues SetBytes(long current)
+ {
+ CheckIfExtracted();
+
+ _data.Bytes = new BytesProgress { Current = current, Total = null };
+ _data.BytesSet = true;
+ return this;
+ }
+
+ public ProgressValues ClearBytes()
+ {
+ CheckIfExtracted();
+
+ _data.Bytes = null;
+ _data.BytesSet = true;
+ return this;
+ }
+
+ public ProgressValues SetBytesPerSec(long bytes, long elapsedMilliseconds)
+ {
+ CheckIfExtracted();
+
+ if (elapsedMilliseconds == 0)
+ {
+ throw new ArithmeticException($"'{nameof(elapsedMilliseconds)}' must not be 0");
+ }
+
+ _data.BytesPerSec = new BytesPerSecProgress { Bytes = bytes, ElapsedMilliseconds = elapsedMilliseconds };
+ _data.BytesPerSecSet = true;
+ return this;
+ }
+
+ public ProgressValues ClearBytesPerSec()
+ {
+ CheckIfExtracted();
+
+ _data.BytesPerSec = null;
+ _data.BytesPerSecSet = true;
+ return this;
+ }
+
+ public ProgressValues Clear()
+ {
+ CheckIfExtracted();
+
+ this.ClearTotal();
+ this.ClearFileCount();
+ this.ClearBytes();
+ this.ClearBytesPerSec();
+ return this;
+ }
+
+ private void CheckIfExtracted()
+ {
+ if (_data == null)
+ {
+ throw new InvalidOperationException($"{nameof(ProgressValues)} already extracted");
+ }
+ }
+
+ public IData Extract()
+ {
+ CheckIfExtracted();
+
+ var res = _data;
+ _data = null!;
+ return res;
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/UpdateMode.cs b/PD2Shared/GameFileUpdate/UpdateMode.cs
new file mode 100644
index 00000000..758eb870
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/UpdateMode.cs
@@ -0,0 +1,42 @@
+namespace PD2Shared.GameFileUpdate
+{
+ public enum UpdateMode
+ {
+ Normal, // (I1) FilesToRestore = [All files] -> [InstallFiles that failed Validation]
+ // (D1) FilesToDownload = [FilesToRestore] -> [DownloadFiles that failed Validation] + [DownloadFiles with PartialDownloads]
+
+ Restore, // (I2) FilesToRestore = [All files] -> [InstallFiles that failed Validation] + [InstallFiles loosely validated]
+ // (D1) FilesToDownload = [FilesToRestore] -> [DownloadFiles that failed Validation] + [DownloadFiles with PartialDownloads]
+
+ Download, // (I3) FilesToRestore = [None]
+ // (D2) FilesToDownload = [All files] -> [DownloadFiles that failed Validation] + [DownloadFiles with PartialDownloads]
+
+ Reset // (I4) FilesToRestore = [All files]
+ // (D3) FilesToDownload = [All files] (...but Validate PartialDownloads beforehand)
+ // ...additionally force-query all files for good measure
+ }
+
+ // Sneaking in an extension class for convenience
+ public static class UpdateModeEx
+ {
+ public static bool IsNormal(this UpdateMode updateMode)
+ {
+ return updateMode == UpdateMode.Normal;
+ }
+
+ public static bool IsRestore(this UpdateMode updateMode)
+ {
+ return updateMode == UpdateMode.Restore;
+ }
+
+ public static bool IsDownload(this UpdateMode updateMode)
+ {
+ return updateMode == UpdateMode.Download;
+ }
+
+ public static bool IsReset(this UpdateMode updateMode)
+ {
+ return updateMode == UpdateMode.Reset;
+ }
+ }
+}
diff --git a/PD2Shared/GameFileUpdate/Xxh3HashJsonConverter.cs b/PD2Shared/GameFileUpdate/Xxh3HashJsonConverter.cs
new file mode 100644
index 00000000..65ccca50
--- /dev/null
+++ b/PD2Shared/GameFileUpdate/Xxh3HashJsonConverter.cs
@@ -0,0 +1,18 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PD2Shared.GameFileUpdate
+{
+ internal class Xxh3HashJsonConverter : JsonConverter
+ {
+ public override Xxh3Hash Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ return new Xxh3Hash(reader.GetString()!);
+ }
+
+ public override void Write(Utf8JsonWriter writer, Xxh3Hash value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToHexString());
+ }
+ }
+}
diff --git a/PD2Shared/GameUpdateManager.cs b/PD2Shared/GameUpdateManager.cs
index 3714000a..2c8f23e8 100644
--- a/PD2Shared/GameUpdateManager.cs
+++ b/PD2Shared/GameUpdateManager.cs
@@ -32,8 +32,8 @@ public static async Task RunUpdateAndLaunchAsync(
}
}
- var launcherArgs = storage.LoadSection(StorageKey.LauncherArgs);
- if (launcherArgs != null && launcherArgs.disableAutoUpdate)
+ var launcherOptions = storage.LoadSection(StorageKey.LauncherOptions);
+ if (launcherOptions?.DisableAutoUpdate == true)
{
gameLauncher.LaunchGame(storage);
return;
diff --git a/PD2Shared/Helpers/GameFileUpdateHelpers.cs b/PD2Shared/Helpers/GameFileUpdateHelpers.cs
deleted file mode 100644
index a8bb6dd8..00000000
--- a/PD2Shared/Helpers/GameFileUpdateHelpers.cs
+++ /dev/null
@@ -1,118 +0,0 @@
-using PD2Shared.Models;
-using System.Diagnostics;
-using System.Security.Cryptography;
-using Newtonsoft.Json.Linq;
-using PD2Shared.Storage;
-using static PD2Shared.Constants;
-using PD2Shared.Interfaces;
-
-namespace PD2Shared.Helpers
-{
- public class GameFileUpdateHelpers
- {
- private readonly HttpClient _httpClient;
-
- public GameFileUpdateHelpers(HttpClient httpClient)
- {
- _httpClient = httpClient;
- _httpClient.Timeout = TimeSpan.FromMinutes(30);
- }
-
- public async Task UpdateFromShaMetadataAsync(ILocalStorage localStorage, IProgress progress, Action onComplete)
- {
- FileUpdateModel fileUpdateModel = localStorage.LoadSection(PD2Shared.Models.StorageKey.FileUpdateModel);
- string installPath = Directory.GetCurrentDirectory();
- string fullUpdatePath = Path.Combine(installPath, fileUpdateModel.FilePath);
- Directory.CreateDirectory(fullUpdatePath);
-
- string metadataUrl = $"{fileUpdateModel.Client.TrimEnd('/')}/metadata.json";
- string localMetaPath = Path.Combine(installPath, "local_metadata.json");
- string? localMetadataContent = File.Exists(localMetaPath) ? await File.ReadAllTextAsync(localMetaPath) : null;
-
- try
- {
- Debug.WriteLine($"\n================ metadata URL: {metadataUrl}\n");
- var response = await _httpClient.GetAsync(metadataUrl);
- response.EnsureSuccessStatusCode();
- string remoteMetadataContent = await response.Content.ReadAsStringAsync();
-
- if (localMetadataContent != null && localMetadataContent == remoteMetadataContent)
- {
- Debug.WriteLine("Metadata unchanged. Skipping SHA1 update.");
- return;
- }
-
- var parsed = JObject.Parse(remoteMetadataContent);
- var checksums = parsed["checksum"]?.ToObject>() ?? new();
-
- var shaFiles = checksums.Select(entry =>
- {
- var parts = entry.Split(" ", 2);
- if (parts.Length != 2) return null;
- return new ShaHashFileItem
- {
- Name = parts[1].Trim(),
- MediaLink = metadataUrl.Replace("metadata.json", "") + parts[1].Trim(),
- Hash = parts[0].Trim()
- };
- }).Where(x => x != null).ToList();
-
- int processed = 0;
-
- foreach (var file in shaFiles!)
- {
- Debug.WriteLine($"{file.Name}");
- if (file.Name.EndsWith("/")) continue;
-
- string localFile = Path.Combine(fullUpdatePath, file.Name.Replace("/", Path.DirectorySeparatorChar.ToString()));
- string destination = Path.Combine(installPath, file.Name.Replace("/", Path.DirectorySeparatorChar.ToString()));
-
- Directory.CreateDirectory(Path.GetDirectoryName(localFile)!);
- bool fileExists = File.Exists(localFile);
-
- if (!fileExists || !CompareSha1(localFile, file.Hash))
- {
- await DownloadFileAsync(file.MediaLink, localFile);
- File.Copy(localFile, destination, true);
- }
-
- processed++;
- progress?.Report((double)processed / shaFiles.Count);
- }
-
- await File.WriteAllTextAsync(localMetaPath, remoteMetadataContent);
- onComplete?.Invoke();
- }
- catch (Exception ex)
- {
- Debug.WriteLine($"[SHA Update] Failed: {ex.Message}");
- onComplete?.Invoke();
- }
- }
-
- public bool CompareSha1(string filePath, string expectedHash)
- {
- using var sha1 = SHA1.Create();
- using var stream = File.OpenRead(filePath);
- var hashBytes = sha1.ComputeHash(stream);
- var localHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
- return localHash == expectedHash.ToLowerInvariant();
- }
-
- private async Task DownloadFileAsync(string url, string destination)
- {
- using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
- response.EnsureSuccessStatusCode();
- await using var stream = await response.Content.ReadAsStreamAsync();
- await using var fs = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
- await stream.CopyToAsync(fs);
- }
- }
-
- public class ShaHashFileItem
- {
- public string Name { get; set; }
- public string MediaLink { get; set; }
- public string Hash { get; set; }
- }
-}
\ No newline at end of file
diff --git a/PD2Shared/Helpers/LaunchGameHelpers.cs b/PD2Shared/Helpers/LaunchGameHelpers.cs
index 297ade89..3b3a8846 100644
--- a/PD2Shared/Helpers/LaunchGameHelpers.cs
+++ b/PD2Shared/Helpers/LaunchGameHelpers.cs
@@ -1,38 +1,57 @@
-
-using PD2Shared.Interfaces;
+using PD2Shared.Interfaces;
using PD2Shared.Models;
+using PD2Shared.Logging;
+using static PD2Shared.Logging.LoggingStatic;
+using PD2Shared.Utils;
using System.Diagnostics;
-using System.IO;
-using System.Windows;
+using System.Runtime.InteropServices;
namespace PD2Shared.Helpers
{
public class LaunchGameHelpers : ILaunchGameHelpers
{
- public void LaunchGame(ILocalStorage localStorage)
+ private static class DllImports
{
- var fileUpdateModel = localStorage.LoadSection(PD2Shared.Models.StorageKey.FileUpdateModel);
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ public static extern IntPtr FindWindow([Optional] string? className, [Optional] string? windowName);
+ }
- string diabloIIExePath = Path.Combine(Directory.GetCurrentDirectory(), "Game.exe");
- if (!File.Exists(diabloIIExePath))
+ public static bool IsGameRunning
+ {
+ get
{
- Debug.WriteLine("Game.exe not found.");
- return;
+ // This is basically the same check the actual game uses
+ return DllImports.FindWindow(className: "Diablo II", windowName: null) != IntPtr.Zero;
}
+ }
- LauncherArgs launcherArgs = localStorage.LoadSection(PD2Shared.Models.StorageKey.LauncherArgs);
-
- string args = ConstructLaunchArguments(launcherArgs);
+ public Process LaunchGame(ILocalStorage localStorage, EventHandler? exitedEventHandler = null)
+ {
+ LauncherArgs launcherArgs = localStorage.LoadSection(StorageKey.LauncherArgs);
- // Launch the game with the specified arguments.
- var startInfo = new ProcessStartInfo
+ Process process = new()
{
- FileName = diabloIIExePath,
- Arguments = args,
- WorkingDirectory = Path.GetDirectoryName(diabloIIExePath)
+ EnableRaisingEvents = exitedEventHandler != null,
+ StartInfo = new()
+ {
+ WorkingDirectory = Env.GetCwd(),
+ FileName = Path.Combine(Env.GetCwd(), "Game.exe"),
+ Arguments = ConstructLaunchArguments(launcherArgs),
+ // Run via shell to prevent throwing a Win32Exception with 'The requested operation requires elevation'
+ // whenever the executable is marked to run as elevated.
+ UseShellExecute = true
+ }
};
- Process.Start(startInfo);
+ if (exitedEventHandler != null)
+ {
+ process.Exited += exitedEventHandler;
+ }
+
+ L.CallerInformation($"Launching: '\"{process.StartInfo.FileName}\" {process.StartInfo.Arguments}'...");
+
+ process.Start();
+ return process;
}
private string ConstructLaunchArguments(LauncherArgs launcherArgs)
diff --git a/PD2Shared/Interfaces/ILaunchGameHelpers.cs b/PD2Shared/Interfaces/ILaunchGameHelpers.cs
index e9c5cd92..b71a58c7 100644
--- a/PD2Shared/Interfaces/ILaunchGameHelpers.cs
+++ b/PD2Shared/Interfaces/ILaunchGameHelpers.cs
@@ -1,9 +1,9 @@
-
+using System.Diagnostics;
namespace PD2Shared.Interfaces
{
public interface ILaunchGameHelpers
{
- void LaunchGame(ILocalStorage storage);
+ Process LaunchGame(ILocalStorage storage, EventHandler? exitedEventHandler = null);
}
}
diff --git a/PD2Shared/Interfaces/ILocalStorage.cs b/PD2Shared/Interfaces/ILocalStorage.cs
index 62427860..7bf27e82 100644
--- a/PD2Shared/Interfaces/ILocalStorage.cs
+++ b/PD2Shared/Interfaces/ILocalStorage.cs
@@ -12,8 +12,10 @@ public interface ILocalStorage
//save a setting bucket by keyname
void Update(StorageKey key, T value) where T : class;
+ T? LoadSectionIfExists(StorageKey key) where T : class;
+
//load a setting bucket by keyname
- T LoadSection(StorageKey key) where T : class;
+ T LoadSection(StorageKey key) where T : class, new();
void InitializeIfNotExists(StorageKey key, T defaultValue) where T : class, new();
}
diff --git a/PD2Shared/Logging/ILoggerEx.cs b/PD2Shared/Logging/ILoggerEx.cs
new file mode 100644
index 00000000..ada31bfa
--- /dev/null
+++ b/PD2Shared/Logging/ILoggerEx.cs
@@ -0,0 +1,107 @@
+using Serilog;
+using System.Runtime.CompilerServices;
+
+namespace PD2Shared.Logging
+{
+ public static class ILoggerEx
+ {
+ private static readonly string SeparatorLine = "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -";
+
+ private static readonly object _lastExceptionStackTraceLock = new();
+ private static string? _lastExceptionStackTrace = null;
+
+ private static void CallerWrite(this ILogger logger, Serilog.Events.LogEventLevel logEventLevel, Exception? exception, string messageTemplate, string callerName, object?[]? propertyValues)
+ {
+ if (exception != null)
+ {
+ // This can have moderate impact on the logger's performance, but should help de-clutter the log.
+ lock (_lastExceptionStackTraceLock)
+ {
+ if (_lastExceptionStackTrace == exception.StackTrace)
+ {
+ messageTemplate = messageTemplate + Environment.NewLine +
+ exception.GetType() + ": " + exception.Message + Environment.NewLine +
+ " ";
+ exception = null;
+ }
+ else
+ {
+ _lastExceptionStackTrace = exception.StackTrace;
+ }
+ }
+ }
+
+ logger.Write(logEventLevel, exception, $"{(callerName + "()"),-30} " + messageTemplate, propertyValues);
+ }
+
+ public static void CallerWrite(this ILogger logger, Serilog.Events.LogEventLevel logEventLevel, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, logEventLevel, exception: null, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerVerbose(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Verbose, exception: null, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerVerbose(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Verbose, exception, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerDebug(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Debug, exception: null, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerDebug(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Debug, exception, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerInformation(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Information, exception: null, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerInformation(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Information, exception, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerWarning(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Warning, exception: null, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerWarning(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Warning, exception, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerError(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Error, exception: null, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerError(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Error, exception, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerFatal(this ILogger logger, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Fatal, exception: null, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void CallerFatal(this ILogger logger, Exception? exception, string messageTemplate, object?[]? propertyValues = null, [CallerMemberName] string callerName = "?")
+ {
+ CallerWrite(logger, Serilog.Events.LogEventLevel.Fatal, exception, messageTemplate, callerName, propertyValues);
+ }
+
+ public static void Separator(this ILogger logger, Serilog.Events.LogEventLevel logEventLevel = Serilog.Events.LogEventLevel.Information)
+ {
+ logger.Write(logEventLevel, SeparatorLine);
+ }
+ }
+}
diff --git a/PD2Shared/Logging/LoggedRoutine.cs b/PD2Shared/Logging/LoggedRoutine.cs
new file mode 100644
index 00000000..1341fe92
--- /dev/null
+++ b/PD2Shared/Logging/LoggedRoutine.cs
@@ -0,0 +1,23 @@
+using System.Runtime.CompilerServices;
+using PD2Shared.Utils;
+using static PD2Shared.Logging.LoggingStatic;
+
+namespace PD2Shared.Logging
+{
+ public class LoggedRoutine : TimedDisposable
+ {
+ public LoggedRoutine(
+ Serilog.Events.LogEventLevel logEventLevel = Serilog.Events.LogEventLevel.Information,
+ [CallerMemberName] string callerName = null!)
+ : base(() =>
+ {
+ L.CallerWrite(logEventLevel, $">>", propertyValues: null, callerName);
+ },
+ timeSpan =>
+ {
+ L.CallerWrite(logEventLevel, $"<< {timeSpan}", propertyValues: null, callerName);
+ })
+ {
+ }
+ }
+}
diff --git a/PD2Shared/Logging/LoggedScope.cs b/PD2Shared/Logging/LoggedScope.cs
new file mode 100644
index 00000000..c674eff5
--- /dev/null
+++ b/PD2Shared/Logging/LoggedScope.cs
@@ -0,0 +1,23 @@
+using System.Runtime.CompilerServices;
+using PD2Shared.Utils;
+using static PD2Shared.Logging.LoggingStatic;
+
+namespace PD2Shared.Logging
+{
+ public class LoggedScope : TimedDisposable
+ {
+ public LoggedScope(string message,
+ Serilog.Events.LogEventLevel logEventLevel = Serilog.Events.LogEventLevel.Information,
+ [CallerMemberName] string callerName = null!)
+ : base(() =>
+ {
+ L.CallerWrite(logEventLevel, $"> {message}", propertyValues: null, callerName);
+ },
+ timeSpan =>
+ {
+ L.CallerWrite(logEventLevel, $"< {message} Finished in {timeSpan}.", propertyValues: null, callerName);
+ })
+ {
+ }
+ }
+}
diff --git a/PD2Shared/Logging/Logging.cs b/PD2Shared/Logging/Logging.cs
new file mode 100644
index 00000000..54c6c30e
--- /dev/null
+++ b/PD2Shared/Logging/Logging.cs
@@ -0,0 +1,237 @@
+using System.Runtime.InteropServices;
+using System.Text;
+using Serilog;
+using PD2Shared.Utils;
+using static PD2Shared.Logging.LoggingStatic;
+
+namespace PD2Shared.Logging
+{
+ public static class Logging
+ {
+ private static class DllImports
+ {
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool AllocConsole();
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool FreeConsole();
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool SetConsoleOutputCP([In] uint wCodePageID);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool SetConsoleTitle(string lpConsoleTitle);
+
+ [DllImport("kernel32.dll")]
+ public static extern IntPtr GetConsoleWindow();
+
+ public enum ShowWindowCommand : int
+ {
+#pragma warning disable format
+ SW_HIDE = 0,
+ SW_SHOWNORMAL = 1,
+ SW_NORMAL = 1,
+ SW_SHOWMINIMIZED = 2,
+ SW_SHOWMAXIMIZED = 3,
+ SW_MAXIMIZE = 3,
+ SW_SHOWNOACTIVATE = 4,
+ SW_SHOW = 5,
+ SW_MINIMIZE = 6,
+ SW_SHOWMINNOACTIVE = 7,
+ SW_SHOWNA = 8,
+ SW_RESTORE = 9,
+ SW_SHOWDEFAULT = 10,
+ SW_FORCEMINIMIZE = 11,
+ SW_MAX = 11,
+#pragma warning restore format
+ }
+
+ [DllImport("user32.dll", ExactSpelling = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
+
+ public static bool ShowWindowAsync(IntPtr hWnd, ShowWindowCommand nCmdShow)
+ {
+ return ShowWindowAsync(hWnd, (int)nCmdShow);
+ }
+ }
+
+ private const string ConsoleOutputTemplate = "{Timestamp:HH:mm:ss.fff} {Level:u1}{Level:u1} {Message:lj}{NewLine}{Exception}";
+ private const string FileOutputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u1}{Level:u1} {Message:lj}{NewLine}{Exception}";
+
+ private static readonly object _logSyncObject = new();
+
+ private static readonly string _logFileName;
+ private static readonly string _logDirPath;
+ private static readonly string _logPath;
+
+ private static readonly string _previousLogFileName;
+ private static readonly string _previousLogPath;
+
+ private static bool _alreadySetUp = false;
+ private static bool _consoleCreated = false;
+
+ static Logging()
+ {
+ // Name the log file after the actual executable filename
+ var processFileName = Path.GetFileName(Env.ProcessPath);
+ var stem = Path.GetFileNameWithoutExtension(processFileName);
+
+ _logFileName = string.Concat(stem, ".log");
+ _previousLogFileName = string.Concat(stem, ".previous", ".log");
+
+ // Since GetLauncherFilesRootDirPath() depends on GetCwd(), cache this
+ _logDirPath = Env.GetLauncherFilesRootDirPath();
+
+ _logPath = Path.Combine(Env.GetLauncherFilesRootDirPath(), _logFileName);
+ _previousLogPath = Path.Combine(Env.GetLauncherFilesRootDirPath(), _previousLogFileName);
+ }
+
+ public static string LogPath { get => _logPath; }
+ public static string LogDirPath { get => _logDirPath; }
+ public static string LogFileName { get => _logFileName; }
+
+ public static void SetUp(bool createConsole)
+ {
+#if DEBUG
+ createConsole = true;
+#endif
+
+ if (_alreadySetUp)
+ {
+ throw new InvalidOperationException("The log has been already set up.");
+ }
+
+ if (createConsole)
+ {
+ if (!DllImports.AllocConsole())
+ {
+ throw Win32.GetLastException(nameof(DllImports.AllocConsole));
+ }
+
+ // Force using UTF-8 as the output code page
+ if (!DllImports.SetConsoleOutputCP((uint)Encoding.UTF8.CodePage))
+ {
+ throw Win32.GetLastException(nameof(DllImports.SetConsoleOutputCP), Encoding.UTF8.CodePage);
+ }
+
+ const string ConsoleTitle = "Log";
+
+ if (!DllImports.SetConsoleTitle(ConsoleTitle))
+ {
+ throw Win32.GetLastException(nameof(DllImports.SetConsoleTitle), ConsoleTitle);
+ }
+
+ IntPtr consoleHwnd = DllImports.GetConsoleWindow();
+
+ if (consoleHwnd == IntPtr.Zero)
+ {
+ throw Win32.GetLastException(nameof(DllImports.GetConsoleWindow));
+ }
+
+ if (!DllImports.ShowWindowAsync(consoleHwnd, DllImports.ShowWindowCommand.SW_SHOWMAXIMIZED))
+ {
+ throw Win32.GetLastException(nameof(DllImports.ShowWindowAsync), DllImports.ShowWindowCommand.SW_SHOWMAXIMIZED);
+ }
+
+ _consoleCreated = true;
+ }
+
+#if DEBUG
+ // Since ILoggingFailureListener doesn't help much on sink initialization failure as of Serilog 4.4.0, resort to this:
+ Serilog.Debugging.SelfLog.Enable(message =>
+ {
+ lock (_logSyncObject)
+ {
+ Console.Error.WriteLine(message);
+ }
+ });
+#endif
+
+ // Rotate the log file manually, *sigh*...
+ //
+ // (Serilog blindly appends to an existing file and doesn't support simple file rotation unless files reach their configured limits).
+ (var _, var logRotateEx) = TryRotateLogFile(_logPath, _previousLogPath);
+
+ var loggerConfiguration = new LoggerConfiguration()
+ .MinimumLevel.Verbose()
+ .WriteTo.Async(c => c.File(path: _logPath, outputTemplate: FileOutputTemplate, restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Debug));
+
+ if (createConsole)
+ {
+ loggerConfiguration.WriteTo.Async(c => c.Console(syncRoot: _logSyncObject, outputTemplate: ConsoleOutputTemplate));
+ }
+
+ Log.Logger = loggerConfiguration.CreateLogger();
+
+ // Should be able to tell here if the sink wasn't created successfully.
+ // Sadly, Serilog doesn't support any of that as of 4.4.0. Instead, it handles any exceptions internally and writes them to SelfLog, as per:
+ // https://github.com/serilog/serilog/wiki/Reliability#configuration.
+
+#if DEBUG
+ // And now disable self-logging past sink creation as this excessive output won't be of much use.
+ Serilog.Debugging.SelfLog.Disable();
+#endif
+
+ L.CallerInformation($"{Env.ProcessFileName} {Constants.VersionString}");
+ L.CallerInformation($"Process path: '{Env.ProcessPath}'");
+ L.CallerInformation($"Current working directory: '{Env.GetCwd()}'");
+ L.CallerInformation($"Log file path: '{_logPath}'");
+
+ if (logRotateEx != null)
+ {
+ L.CallerError(logRotateEx, $"{nameof(TryRotateLogFile)}() threw");
+ }
+
+ _alreadySetUp = true;
+ }
+
+ private static bool RotateLogFile(string logPath, string previousLogPath)
+ {
+ if (File.Exists(logPath))
+ {
+ File.Move(logPath, previousLogPath, overwrite: true);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static Tuple TryRotateLogFile(string logPath, string previousLogPath)
+ {
+ bool? res;
+
+ try
+ {
+ res = RotateLogFile(logPath, previousLogPath);
+ }
+ catch (Exception ex)
+ {
+ return Tuple.Create((bool?)null, (Exception?)ex);
+ }
+
+ return Tuple.Create(res, (Exception?)null);
+ }
+
+ public static async void ShutDown(int exitCode)
+ {
+ if (!_alreadySetUp)
+ {
+ return;
+ }
+
+ L.CallerInformation($"Exiting with {exitCode} exit code...");
+ L.CallerInformation("Closing logger...");
+ await Log.CloseAndFlushAsync();
+
+ if (_consoleCreated)
+ {
+ DllImports.FreeConsole();
+ }
+ }
+ }
+}
diff --git a/PD2Shared/Logging/LoggingStatic.cs b/PD2Shared/Logging/LoggingStatic.cs
new file mode 100644
index 00000000..9e1f29e5
--- /dev/null
+++ b/PD2Shared/Logging/LoggingStatic.cs
@@ -0,0 +1,14 @@
+namespace PD2Shared.Logging
+{
+ public static class LoggingStatic
+ {
+ // A convenience property to be combined with ILoggerEx
+ public static Serilog.ILogger L { get => Serilog.Log.Logger; }
+
+ // A convenience method for constructing arrays from "params" to be easily passed to ILoggerEx methods
+ public static object?[]? ExplicitArray(params object?[] args)
+ {
+ return args;
+ }
+ }
+}
diff --git a/PD2Shared/Models/AllSettings.cs b/PD2Shared/Models/AllSettings.cs
index a1f263cb..89370155 100644
--- a/PD2Shared/Models/AllSettings.cs
+++ b/PD2Shared/Models/AllSettings.cs
@@ -8,6 +8,7 @@ public class AllSettings
public SelectedAuthorAndFilter SelectedAuthorAndFilter { get; set; }
public Pd2AuthorList Pd2AuthorList { get; set; }
public LauncherArgs LauncherArgs { get; set; }
+ public LauncherOptions LauncherOptions { get; set; }
public News News { get; set; }
public WindowPositionModel WindowPosition { get; set; }
public ResetInfo ResetInfo { get; set; }
diff --git a/PD2Shared/Models/LauncherArgs.cs b/PD2Shared/Models/LauncherArgs.cs
index be281248..ce24ed86 100644
--- a/PD2Shared/Models/LauncherArgs.cs
+++ b/PD2Shared/Models/LauncherArgs.cs
@@ -5,6 +5,5 @@ public class LauncherArgs
public bool graphics { get; set; } = false;
public bool skiptobnet { get; set; } = true;
public bool sndbkg { get; set; } = false;
- public bool disableAutoUpdate { get; set; } = false;
}
}
\ No newline at end of file
diff --git a/PD2Shared/Models/LauncherOptions.cs b/PD2Shared/Models/LauncherOptions.cs
new file mode 100644
index 00000000..c56c2b6a
--- /dev/null
+++ b/PD2Shared/Models/LauncherOptions.cs
@@ -0,0 +1,10 @@
+namespace PD2Shared.Models
+{
+ public class LauncherOptions
+ {
+ public bool ForceSoftwareRenderer { get; set; } = false;
+ public bool UseHttp2 { get; set; } = false;
+ public bool DisableAutoUpdate { get; set; } = false;
+ public bool AutoCloseAfterLaunch { get; set; } = false;
+ }
+}
diff --git a/PD2Shared/Models/StorageKey.cs b/PD2Shared/Models/StorageKey.cs
index cbbec05a..8c2e96c5 100644
--- a/PD2Shared/Models/StorageKey.cs
+++ b/PD2Shared/Models/StorageKey.cs
@@ -4,6 +4,7 @@ namespace PD2Shared.Models
public enum StorageKey
{
LauncherArgs,
+ LauncherOptions,
DdrawOptions,
FileUpdateModel,
Pd2AuthorList,
diff --git a/PD2Shared/PD2Shared.csproj b/PD2Shared/PD2Shared.csproj
index 9963de93..40cf0216 100644
--- a/PD2Shared/PD2Shared.csproj
+++ b/PD2Shared/PD2Shared.csproj
@@ -9,10 +9,24 @@
win-x86
+
+ full
+
+
+
+ embedded
+
+
+
+
+
+
+
+
diff --git a/PD2Shared/Storage/LocalStorage.cs b/PD2Shared/Storage/LocalStorage.cs
index 051c1df6..3738bfaf 100644
--- a/PD2Shared/Storage/LocalStorage.cs
+++ b/PD2Shared/Storage/LocalStorage.cs
@@ -68,6 +68,9 @@ public void Update(StorageKey key, T value) where T : class
case StorageKey.LauncherArgs:
settings.LauncherArgs = value as LauncherArgs ?? new LauncherArgs();
break;
+ case StorageKey.LauncherOptions:
+ settings.LauncherOptions = value as LauncherOptions ?? new LauncherOptions();
+ break;
case StorageKey.DdrawOptions:
settings.DdrawOptions = value as DdrawOptions ?? new DdrawOptions();
break;
@@ -107,7 +110,7 @@ public void Update(StorageKey key, T value) where T : class
if (IsValidJson(json))
{
File.WriteAllText(tempFilePath, json);
- File.Replace(tempFilePath, filePath, null);
+ File.Move(tempFilePath, filePath, overwrite: true);
Debug.WriteLine("Settings updated successfully.");
}
else
@@ -121,24 +124,30 @@ public void Update(StorageKey key, T value) where T : class
}
}
- public T LoadSection(StorageKey key) where T : class
+ public T? LoadSectionIfExists(StorageKey key) where T : class
{
var settings = Load();
return key switch
{
- StorageKey.LauncherArgs => settings.LauncherArgs as T ?? Activator.CreateInstance(),
- StorageKey.DdrawOptions => settings.DdrawOptions as T ?? Activator.CreateInstance(),
- StorageKey.FileUpdateModel => settings.FileUpdateModel as T ?? Activator.CreateInstance(),
- StorageKey.Pd2AuthorList => settings.Pd2AuthorList as T ?? Activator.CreateInstance(),
- StorageKey.SelectedAuthorAndFilter => settings.SelectedAuthorAndFilter as T ?? Activator.CreateInstance(),
- StorageKey.WindowPosition => settings.WindowPosition as T ?? Activator.CreateInstance(),
- StorageKey.News => settings.News as T ?? Activator.CreateInstance(),
- StorageKey.ResetInfo => settings.ResetInfo as T ?? Activator.CreateInstance(),
- _ => Activator.CreateInstance()
+ StorageKey.LauncherArgs => settings.LauncherArgs as T,
+ StorageKey.LauncherOptions => settings.LauncherOptions as T,
+ StorageKey.DdrawOptions => settings.DdrawOptions as T,
+ StorageKey.FileUpdateModel => settings.FileUpdateModel as T,
+ StorageKey.Pd2AuthorList => settings.Pd2AuthorList as T,
+ StorageKey.SelectedAuthorAndFilter => settings.SelectedAuthorAndFilter as T,
+ StorageKey.WindowPosition => settings.WindowPosition as T,
+ StorageKey.News => settings.News as T,
+ StorageKey.ResetInfo => settings.ResetInfo as T,
+ _ => default
};
}
+ public T LoadSection(StorageKey key) where T : class, new()
+ {
+ return LoadSectionIfExists(key) ?? new();
+ }
+
public void InitializeIfNotExists(StorageKey key, T defaultValue) where T : class, new()
{
if (!Directory.Exists(_storageDirectory))
@@ -160,6 +169,9 @@ public T LoadSection(StorageKey key) where T : class
case StorageKey.LauncherArgs:
settings.LauncherArgs = defaultValue as LauncherArgs ?? new LauncherArgs();
break;
+ case StorageKey.LauncherOptions:
+ settings.LauncherOptions = defaultValue as LauncherOptions ?? new LauncherOptions();
+ break;
case StorageKey.DdrawOptions:
settings.DdrawOptions = defaultValue as DdrawOptions ?? new DdrawOptions();
break;
diff --git a/PD2Shared/Utils/DirectProgress.cs b/PD2Shared/Utils/DirectProgress.cs
new file mode 100644
index 00000000..212fa10d
--- /dev/null
+++ b/PD2Shared/Utils/DirectProgress.cs
@@ -0,0 +1,17 @@
+namespace PD2Shared.Utils
+{
+ public class DirectProgress : IProgress
+ {
+ private readonly Action _handler;
+
+ public DirectProgress(Action handler)
+ {
+ _handler = handler;
+ }
+
+ void IProgress.Report(T value)
+ {
+ this._handler(value);
+ }
+ }
+}
diff --git a/PD2Shared/Utils/Env.cs b/PD2Shared/Utils/Env.cs
new file mode 100644
index 00000000..d83b5cde
--- /dev/null
+++ b/PD2Shared/Utils/Env.cs
@@ -0,0 +1,110 @@
+using System.Text;
+
+namespace PD2Shared.Utils
+{
+ public static class Env
+ {
+ static Env()
+ {
+ // In theory this could return null (https://learn.microsoft.com/en-us/dotnet/api/system.environment.processpath#remarks),
+ // but it's unlikely on Windows as a running process should have an associated handle open, preventing the file from being altered.
+ ProcessPath = Environment.ProcessPath!;
+
+ ProcessFileName = Path.GetFileName(ProcessPath);
+ // ProcessPath will never be null, empty nor a root directory
+ ProcessDirPath = Path.GetDirectoryName(ProcessPath)!;
+
+ // Retrieves system-default ANSI encoding for non-Unicode programs. Will correctly return UTF-8 when forced system-wide.
+ // (Taken from https://stackoverflow.com/a/70258850)
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ AnsiEncoding = Encoding.GetEncoding(0);
+ }
+
+ public static string ProcessFileName { get; }
+ public static string ProcessPath { get; }
+ public static string ProcessDirPath { get; }
+
+ public static Encoding AnsiEncoding { get; }
+
+ public static string GetCwd()
+ {
+ // While this method isn't much on its own, it's used for consistency
+ return Environment.CurrentDirectory;
+ }
+
+ public static string GetLauncherFilesRootDirPath()
+ {
+ return Path.Combine(GetCwd(), "launcher.files");
+ }
+
+ public static Exception? CheckIfDirectoryIsWritable(string directoryPath)
+ {
+ var filePath = Path.Combine(directoryPath, Path.GetRandomFileName());
+
+ try
+ {
+ using var stream = new FileStream(
+ filePath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 0,
+ FileOptions.DeleteOnClose);
+ }
+ catch(Exception ex)
+ {
+ return ex;
+ }
+
+ return null;
+ }
+
+ public static void EnsureDirectoryExists(string fileFullPath)
+ {
+ ArgumentNullException.ThrowIfNull(fileFullPath, nameof(fileFullPath));
+
+ if (!Path.IsPathFullyQualified(fileFullPath))
+ {
+ throw new ArgumentException("Must be a fully qualified path", nameof(fileFullPath));
+ }
+
+ string? dirPath = Path.GetDirectoryName(fileFullPath);
+
+ // Skip when in root directory
+ if (dirPath != null)
+ {
+ Directory.CreateDirectory(dirPath);
+ }
+ }
+
+ public static async Task FileExistsAsync(string path)
+ {
+ return await Task.Run(() =>
+ {
+ return File.Exists(path);
+ }).ConfigureAwait(false);
+ }
+
+ public static Tuple TryGetFileSize(string path)
+ {
+ long? fileSize = null;
+ Exception? exception = null;
+
+ try
+ {
+ fileSize = new FileInfo(path).Length;
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+
+ return Tuple.Create(fileSize, exception);
+ }
+
+ public static async Task> TryGetFileSizeAsync(string path)
+ {
+ return await Task.Run(() => TryGetFileSize(path)).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/PD2Shared/Utils/Formatting.cs b/PD2Shared/Utils/Formatting.cs
new file mode 100644
index 00000000..b5b91ea1
--- /dev/null
+++ b/PD2Shared/Utils/Formatting.cs
@@ -0,0 +1,40 @@
+using System.Globalization;
+
+namespace PD2Shared.Utils
+{
+ public static class Formatting
+ {
+ private const long Mebibyte = 1024 * 1024;
+
+ private const int DefaultPrecision = 2;
+
+ private static string FormatSizeInMiB(double value, string unitsStr, int precision)
+ {
+ string format = $"{{0:N{precision}}}{{1}}";
+
+ return string.Format(CultureInfo.InvariantCulture, format, value, unitsStr);
+ }
+
+ // Don't attempt any sophisticated human-readable formatting for now
+ public static string FormatSizeInMiB(long bytes, bool appendUnits = true, int precision = DefaultPrecision)
+ {
+ return FormatSizeInMiB(bytes / (double)Mebibyte, appendUnits ? " MiB" : "", precision);
+ }
+
+ public static string FormatThroughputInMiB(long bytes, long elapsedMilliseconds, bool appendUnits = true, int precision = DefaultPrecision)
+ {
+ // var mebibytesPerSec = bytes / ((double)elapsedMilliseconds / 1000) / Mebibyte;
+ // This is a transformation of the above equation ^
+ var mebibytesPerSec = (bytes * 1000) / (elapsedMilliseconds * (double)Mebibyte);
+
+ return FormatSizeInMiB(mebibytesPerSec, appendUnits ? " MiB/s" : "", precision);
+ }
+
+ public static string FormatThroughputInMiB(long bytesPerSec, bool appendUnits = true, int precision = DefaultPrecision)
+ {
+ var mebibytesPerSec = bytesPerSec / (double)Mebibyte;
+
+ return FormatSizeInMiB(mebibytesPerSec, appendUnits ? " MiB/s" : "", precision);
+ }
+ }
+}
diff --git a/PD2Shared/Utils/SimpleTimer.cs b/PD2Shared/Utils/SimpleTimer.cs
new file mode 100644
index 00000000..829366cf
--- /dev/null
+++ b/PD2Shared/Utils/SimpleTimer.cs
@@ -0,0 +1,97 @@
+using System.Diagnostics;
+
+namespace PD2Shared.Utils
+{
+ // A simplistic timer class that runs an uninterruptible action sequentially, on a dedicated thread, with a given interval until Disposed.
+ //
+ // It's meant to perform quick actions, such as periodic UI updates, hence the default AboveNormal ThreadPriority.
+ public class SimpleTimer : IDisposable
+ {
+ private bool _disposed = false;
+
+ private readonly EventWaitHandle _interrupt;
+
+ private readonly TimeSpan _interval;
+ private readonly Action _handler;
+ private readonly Action? _onDispose;
+ private readonly Thread _thread;
+
+ public SimpleTimer(TimeSpan interval, Action handler, Action? onDispose = null, ThreadPriority priority = ThreadPriority.AboveNormal)
+ {
+ if (interval < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(interval), $"{nameof(interval)} must not be negative");
+ }
+
+ ArgumentNullException.ThrowIfNull(handler, nameof(handler));
+
+ _interrupt = new(initialState: false, EventResetMode.ManualReset);
+
+ _interval = interval;
+
+ _handler = handler;
+ _onDispose = onDispose;
+
+ _thread = new Thread(ThreadEntry)
+ {
+ Priority = priority
+ };
+ _thread.Start();
+ }
+
+ public SimpleTimer(Action handler, Action? onDispose = null, ThreadPriority priority = ThreadPriority.AboveNormal)
+ : this(interval: TimeSpan.FromMilliseconds(UpdateThrottle.DefaultIntervalMilliseconds), handler, onDispose, priority)
+ {
+ }
+
+ private void ThreadEntry()
+ {
+ Stopwatch stopwatch = new();
+ TimeSpan timeToWait;
+
+ while (true)
+ {
+ stopwatch.Restart();
+
+ _handler();
+
+ timeToWait = _interval - stopwatch.Elapsed;
+
+ if (timeToWait > TimeSpan.Zero)
+ {
+ if (_interrupt.WaitOne(timeToWait))
+ {
+ return;
+ }
+ }
+ }
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (disposing)
+ {
+ _interrupt.Set();
+ _thread.Join();
+
+ _interrupt.Dispose();
+
+ _onDispose?.Invoke();
+ }
+
+ _disposed = true;
+ }
+
+ public void Dispose()
+ {
+ // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git a/PD2Shared/Utils/TimedDisposable.cs b/PD2Shared/Utils/TimedDisposable.cs
new file mode 100644
index 00000000..b407e920
--- /dev/null
+++ b/PD2Shared/Utils/TimedDisposable.cs
@@ -0,0 +1,42 @@
+using System.Diagnostics;
+
+namespace PD2Shared.Utils
+{
+ public class TimedDisposable : IDisposable
+ {
+ private bool _disposed = false;
+
+ private readonly Stopwatch _stopwatch;
+ private readonly Action _onDisposeAction;
+
+ public TimedDisposable(Action onConstructAction, Action onDisposeAction)
+ {
+ _onDisposeAction = onDisposeAction;
+ _stopwatch = Stopwatch.StartNew();
+
+ onConstructAction();
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (disposing)
+ {
+ _onDisposeAction(_stopwatch.Elapsed);
+ }
+
+ _disposed = true;
+ }
+
+ public void Dispose()
+ {
+ // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git a/PD2Shared/Utils/UpdateThrottle.cs b/PD2Shared/Utils/UpdateThrottle.cs
new file mode 100644
index 00000000..cebdac4e
--- /dev/null
+++ b/PD2Shared/Utils/UpdateThrottle.cs
@@ -0,0 +1,62 @@
+using System.Diagnostics;
+
+namespace PD2Shared.Utils
+{
+ // Beware, this class isn't thread-safe
+ public class UpdateThrottle
+ {
+ public const int DefaultIntervalMilliseconds = 50;
+
+ private readonly Stopwatch _stopwatch;
+ private readonly int _interval;
+
+ public UpdateThrottle(int intervalMilliseconds = DefaultIntervalMilliseconds, bool waitInitialInterval = false)
+ {
+ _stopwatch = new Stopwatch();
+ _interval = intervalMilliseconds;
+
+ if (waitInitialInterval)
+ {
+ _stopwatch.Start();
+ }
+ }
+
+ public void Reset()
+ {
+ _stopwatch.Restart();
+ }
+
+ private bool CanUpdateInternal(out long elapsed)
+ {
+ if (!_stopwatch.IsRunning)
+ {
+ _stopwatch.Start();
+
+ elapsed = _interval;
+ return true;
+ }
+
+ elapsed = _stopwatch.ElapsedMilliseconds;
+ return elapsed >= _interval;
+ }
+
+ public void UpdateIfPossible(Action action)
+ {
+ if (CanUpdateInternal(out long elapsed))
+ {
+ Reset();
+ }
+ else
+ {
+ return;
+ }
+
+ action(elapsed);
+ }
+
+ public void UpdateIfPossible(Action action)
+ {
+ UpdateIfPossible((_) => action());
+ }
+ }
+}
diff --git a/PD2Shared/Utils/Win32.cs b/PD2Shared/Utils/Win32.cs
new file mode 100644
index 00000000..024f65a3
--- /dev/null
+++ b/PD2Shared/Utils/Win32.cs
@@ -0,0 +1,50 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+
+namespace PD2Shared.Utils
+{
+ public static class Win32
+ {
+ public const int ERROR_SUCCESS = 0;
+
+ public static string GetLastErrorMessage(int error)
+ {
+ return GetLastException(error).Message;
+ }
+
+ public static string GetLastErrorMessage()
+ {
+ return GetLastException().Message;
+ }
+
+ public static string GetLastErrorMessage(int error, string functionName, params object?[] args)
+ {
+ return GetLastException(error, functionName, args).Message;
+ }
+
+ public static string GetLastErrorMessage(string functionName, params object?[] args)
+ {
+ return GetLastException(functionName, args).Message;
+ }
+
+ public static Win32Exception GetLastException(int error)
+ {
+ return new Win32Exception(error);
+ }
+
+ public static Win32Exception GetLastException()
+ {
+ return new Win32Exception(Marshal.GetLastWin32Error());
+ }
+
+ public static Win32Exception GetLastException(int error, string functionName, params object?[] args)
+ {
+ return new Win32Exception(error, $"{functionName}({string.Join(", ", args.Select(a => a is null ? "NULL" : a is string ? $"\"{a}\"" : a))}) failed: {new Win32Exception(error).Message}");
+ }
+
+ public static Win32Exception GetLastException(string functionName, params object?[] args)
+ {
+ return GetLastException(Marshal.GetLastWin32Error(), functionName, args);
+ }
+ }
+}
diff --git a/PD2Shared/Utils/Wine.cs b/PD2Shared/Utils/Wine.cs
new file mode 100644
index 00000000..0de08b40
--- /dev/null
+++ b/PD2Shared/Utils/Wine.cs
@@ -0,0 +1,202 @@
+using System.Runtime.InteropServices;
+using Microsoft.Win32;
+using PD2Shared.Logging;
+using static PD2Shared.Logging.LoggingStatic;
+
+namespace PD2Shared.Utils
+{
+ public static class Wine
+ {
+ private static class DllImports
+ {
+ [DllImport("ntdll.dll", CallingConvention = CallingConvention.Cdecl)]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization",
+ "CA2101:Specify marshaling for P/Invoke string arguments",
+ Justification = "UTF-8 strings are expected, thus MarshalAs(UnmanagedType.LPUTF8Str) is the correct choice.")]
+ [return: MarshalAs(UnmanagedType.LPUTF8Str)]
+ public static extern string wine_get_version();
+
+ [DllImport("ntdll.dll", CallingConvention = CallingConvention.Cdecl)]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization",
+ "CA2101:Specify marshaling for P/Invoke string arguments",
+ Justification = "UTF-8 strings are expected, thus MarshalAs(UnmanagedType.LPUTF8Str) is the correct choice.")]
+ [return: MarshalAs(UnmanagedType.LPUTF8Str)]
+ public static extern string wine_get_build_id();
+
+ [DllImport("ntdll.dll", CallingConvention = CallingConvention.Cdecl)]
+ public static extern void wine_get_host_version(ref IntPtr sysname, ref IntPtr release);
+ }
+
+ const string HkcuExeKeyPath = @"Software\Wine\AppDefaults\Game.exe";
+ const string HkcuDllOverridesKeyPath = HkcuExeKeyPath + @"\DllOverrides";
+ const string HkcuX11KeyPath = HkcuExeKeyPath + @"\X11 Driver";
+
+ // MSVC2019 runtime
+ private static readonly string[] Msvc2019Libs = {
+ "api-ms-win-crt-conio-l1-1-0",
+ "api-ms-win-crt-heap-l1-1-0",
+ "api-ms-win-crt-locale-l1-1-0",
+ "api-ms-win-crt-math-l1-1-0",
+ "api-ms-win-crt-private-l1-1-0",
+ "api-ms-win-crt-runtime-l1-1-0",
+ "api-ms-win-crt-stdio-l1-1-0",
+ "api-ms-win-crt-time-l1-1-0",
+ "atl140",
+ "concrt140",
+ "msvcp140",
+ "msvcp140_1",
+ "msvcp140_2",
+ "msvcp140_atomic_wait",
+ "msvcp140_codecvt_ids",
+ "ucrtbase",
+ "vcamp140",
+ "vccorlib140",
+ "vcomp140",
+ "vcruntime140",
+ "vcruntime140_1"
+ };
+
+ public class WineException : Exception
+ {
+ public WineException(string? message, Exception? innerException = null) : base(message, innerException) { }
+ }
+
+ static Wine()
+ {
+ string? versionString;
+
+ try
+ {
+ versionString = DllImports.wine_get_version();
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return;
+ }
+
+ IsRunningUnderWine = true;
+
+ if (Version.TryParse(versionString, out Version? version))
+ {
+ Version = version;
+ }
+ else
+ {
+ L.CallerError($"Failed to parse output of {nameof(DllImports.wine_get_version)}(): '{versionString}'");
+ }
+
+ try
+ {
+ BuildId = DllImports.wine_get_build_id();
+ }
+ catch (EntryPointNotFoundException)
+ {
+ L.CallerWarning($"{nameof(DllImports.wine_get_build_id)}() entry point not found");
+ }
+
+ try
+ {
+ IntPtr sysnamePtr = IntPtr.Zero;
+ IntPtr releasePtr = IntPtr.Zero;
+
+ DllImports.wine_get_host_version(ref sysnamePtr, ref releasePtr);
+
+ OsName = Marshal.PtrToStringUTF8(sysnamePtr);
+ OsRelease = Marshal.PtrToStringUTF8(releasePtr);
+ }
+ catch (EntryPointNotFoundException)
+ {
+ L.CallerWarning($"{nameof(DllImports.wine_get_host_version)}() entry point not found");
+ }
+ }
+
+ public static bool IsRunningUnderWine { get; }
+ public static Version? Version { get; }
+ public static string? BuildId { get; }
+ public static string? OsName { get; }
+ public static string? OsRelease { get; }
+
+ public static void ApplyWineConfiguration()
+ {
+ // What needs to be set:
+ //
+ // [HKEY_CURRENT_USER\Software\Wine\AppDefaults\Game.exe]
+ // ; Workaround for InitializeCriticalSection() in Wine>=9.9 (won't help if running >=9.5, which introduced the unconditional API change)
+ // "Version"="win7"
+ //
+ // [HKEY_CURRENT_USER\Software\Wine\AppDefaults\Game.exe\DllOverrides]
+ // ; Provided DDraw wrapper
+ // "ddraw"="native"
+ // ; MSVC2019 runtime
+ // "*api-ms-win-crt-conio-l1-1-0"="native,builtin"
+ // "*api-ms-win-crt-heap-l1-1-0"="native,builtin"
+ // "*api-ms-win-crt-locale-l1-1-0"="native,builtin"
+ // "*api-ms-win-crt-math-l1-1-0"="native,builtin"
+ // "*api-ms-win-crt-private-l1-1-0"="native,builtin"
+ // "*api-ms-win-crt-runtime-l1-1-0"="native,builtin"
+ // "*api-ms-win-crt-stdio-l1-1-0"="native,builtin"
+ // "*api-ms-win-crt-time-l1-1-0"="native,builtin"
+ // "*atl140"="native,builtin"
+ // "*concrt140"="native,builtin"
+ // "*msvcp140"="native,builtin"
+ // "*msvcp140_1"="native,builtin"
+ // "*msvcp140_2"="native,builtin"
+ // "*msvcp140_atomic_wait"="native,builtin"
+ // "*msvcp140_codecvt_ids"="native,builtin"
+ // "*ucrtbase"="native,builtin"
+ // "*vcamp140"="native,builtin"
+ // "*vccorlib140"="native,builtin"
+ // "*vcomp140"="native,builtin"
+ // "*vcruntime140"="native,builtin"
+ // "*vcruntime140_1"="native,builtin"
+ //
+ // [HKEY_CURRENT_USER\Software\Wine\AppDefaults\Game.exe\X11 Driver]
+ // ; Emulate modesetting -- don't change the actual display resolution
+ // ; Available since Wine 9.22
+ // "EmulateModeset"="Y"
+
+ using (var key = Registry.CurrentUser.CreateSubKey(HkcuExeKeyPath, writable: true))
+ {
+ if (key == null)
+ {
+ throw new WineException($"Failed to create registry key: '{Registry.CurrentUser}\\{HkcuExeKeyPath}'");
+ }
+
+ key.SetValue("Version", "win7", RegistryValueKind.String);
+ }
+
+ using (var key = Registry.CurrentUser.CreateSubKey(HkcuDllOverridesKeyPath, writable: true))
+ {
+ if (key == null)
+ {
+ throw new WineException($"Failed to create registry key: '{Registry.CurrentUser}\\{HkcuDllOverridesKeyPath}'");
+ }
+
+ // Provided DDraw wrapper
+ key.SetValue("ddraw", "native", RegistryValueKind.String);
+
+ // MSVC2019 runtime libs
+ foreach (var libName in Msvc2019Libs)
+ {
+ key.SetValue($"*{libName}", "native,builtin", RegistryValueKind.String);
+ }
+ }
+
+ using (var key = Registry.CurrentUser.CreateSubKey(HkcuX11KeyPath, writable: true))
+ {
+ if (key == null)
+ {
+ throw new WineException($"Failed to create registry key: '{Registry.CurrentUser}\\{HkcuX11KeyPath}'");
+ }
+
+ // Emulate modesetting
+ key.SetValue("EmulateModeset", "Y", RegistryValueKind.String);
+ }
+ }
+
+ public static void RemoveWineConfiguration()
+ {
+ Registry.CurrentUser.DeleteSubKeyTree(HkcuExeKeyPath, throwOnMissingSubKey: false);
+ }
+ }
+}
diff --git a/SteamPD2/Program.cs b/SteamPD2/Program.cs
index 17368fa0..8936d986 100644
--- a/SteamPD2/Program.cs
+++ b/SteamPD2/Program.cs
@@ -36,8 +36,8 @@ static async Task Run(string[] args)
var filterHelpers = new FilterHelpers(new HttpClient(), localStorage);
var launchGameHelpers = new LaunchGameHelpers();
- var launcherArgs = localStorage.LoadSection(StorageKey.LauncherArgs);
- if (launcherArgs?.disableAutoUpdate == true)
+ var launcherOptions = localStorage.LoadSection(StorageKey.LauncherOptions);
+ if (launcherOptions?.DisableAutoUpdate == true)
{
Log("disableAutoUpdate is enabled. Skipping all update checks.");
launchGameHelpers.LaunchGame(localStorage);
diff --git a/eqpublish.ps1 b/eqpublish.ps1
index e85e70c4..5b173713 100644
--- a/eqpublish.ps1
+++ b/eqpublish.ps1
@@ -4,27 +4,27 @@ $buildFailed = $false
Push-Location $PSScriptRoot
try {
- $mainWindowPath = Join-Path `
+ $constantsPath = Join-Path `
$PSScriptRoot `
- "PD2Launcherv2\MainWindow.xaml"
+ "PD2Shared\Constants.cs"
- if (-not (Test-Path $mainWindowPath -PathType Leaf)) {
- throw "Could not find MainWindow.xaml at: $mainWindowPath"
+ if (-not (Test-Path $constantsPath -PathType Leaf)) {
+ throw "Could not find Constants.cs at: $constantsPath"
}
- $xamlContent = Get-Content $mainWindowPath -Raw
+ $csContent = Get-Content $constantsPath -Raw
# Accepts versions such as:
# v 2.14.1
# v 2.14.AWS1
# v 2.14.SUCCESS
$versionMatch = [regex]::Match(
- $xamlContent,
- 'Text="v\s*([^"]+)"'
+ $csContent,
+ 'VersionString\s*=\s*"([^"]+)"'
)
if (-not $versionMatch.Success) {
- throw "Could not find version text in MainWindow.xaml"
+ throw "Could not find version text in Constants.cs"
}
$version = $versionMatch.Groups[1].Value.Trim()