diff --git a/tools/installer/.gitignore b/tools/installer/.gitignore
new file mode 100644
index 000000000..641a9bade
--- /dev/null
+++ b/tools/installer/.gitignore
@@ -0,0 +1,16 @@
+*.suo
+*.o
+*.obj
+*.pdb
+*.lib
+*.exp
+[Dd]ebug/
+[Rr]elease/
+[Oo]bj/
+*.user
+*.ipch
+.vs/
+*.vcxproj
+*.filters
+*.pubxml
+[Oo]ut/
diff --git a/tools/installer/TRX_InstallerLib.sln b/tools/installer/TRX_InstallerLib.sln
new file mode 100644
index 000000000..087f2e568
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.11.35219.272
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TRX_InstallerLib", "TRX_InstallerLib\TRX_InstallerLib.csproj", "{27F08E8C-2910-4682-B8BC-96ED4C1ECE54}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {BA21B1D5-1CC7-4ED8-8C79-A1A5B0ACC840}
+ EndGlobalSection
+EndGlobal
diff --git a/tools/installer/TRX_InstallerLib/Controls/FinishStepControl.xaml b/tools/installer/TRX_InstallerLib/Controls/FinishStepControl.xaml
new file mode 100644
index 000000000..8c04ea48b
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/FinishStepControl.xaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+ Step 4: Done
+
+
+
+ Installation complete. To configure more advanced features, you can edit the JSON files in the cfg/ directory with a text editor.
+
+
+
+ Happy raiding :)
+
+
+
+
+
+
diff --git a/tools/installer/TRX_InstallerLib/Controls/FinishStepControl.xaml.cs b/tools/installer/TRX_InstallerLib/Controls/FinishStepControl.xaml.cs
new file mode 100644
index 000000000..02be4e3c8
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/FinishStepControl.xaml.cs
@@ -0,0 +1,11 @@
+using WC = System.Windows.Controls;
+
+namespace TRX_InstallerLib.Controls;
+
+public partial class FinishStepControl : WC.UserControl
+{
+ public FinishStepControl()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Controls/InstallSettingsStepControl.xaml b/tools/installer/TRX_InstallerLib/Controls/InstallSettingsStepControl.xaml
new file mode 100644
index 000000000..3336554fe
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/InstallSettingsStepControl.xaml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Step 2: Installation options
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Download music tracks
+
+
+
+ This option lets you download compatible music files for the game
+ automatically (60 MB). The legality of these files is disputable;
+ the most legal way to import the music to PC is to obtain them from
+ your own source - TR2 supports FLAC, OOG, MP3 and WAV files.
+
+
+
+
+
+
+
+
+
+
+ Download Unfinished Business expansion pack
+
+
+
+ The Unfinished Business expansion pack was made freeware. However, the Steam and GOG versions do not ship it. This option lets you download the expansion files automatically (6 MB).
+
+
+
+
+
+
+
+
+
+ Import saves
+
+
+ Imports existing savegame files. Only TombATI and TR1X savegame format is supported at this time.
+
+
+
+
+
+ Create desktop shortcut
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/installer/TRX_InstallerLib/Controls/InstallSettingsStepControl.xaml.cs b/tools/installer/TRX_InstallerLib/Controls/InstallSettingsStepControl.xaml.cs
new file mode 100644
index 000000000..ff8cc15a0
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/InstallSettingsStepControl.xaml.cs
@@ -0,0 +1,11 @@
+using WC = System.Windows.Controls;
+
+namespace TRX_InstallerLib.Controls;
+
+public partial class InstallSettingsStepControl : WC.UserControl
+{
+ public InstallSettingsStepControl()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Controls/InstallSourceControl.xaml b/tools/installer/TRX_InstallerLib/Controls/InstallSourceControl.xaml
new file mode 100644
index 000000000..843e4d23c
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/InstallSourceControl.xaml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (change)
+
+
+
+
diff --git a/tools/installer/TRX_InstallerLib/Controls/InstallSourceControl.xaml.cs b/tools/installer/TRX_InstallerLib/Controls/InstallSourceControl.xaml.cs
new file mode 100644
index 000000000..3af76d30a
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/InstallSourceControl.xaml.cs
@@ -0,0 +1,11 @@
+using WC = System.Windows.Controls;
+
+namespace TRX_InstallerLib.Controls;
+
+public partial class InstallSourceControl : WC.UserControl
+{
+ public InstallSourceControl()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Controls/InstallStepControl.xaml b/tools/installer/TRX_InstallerLib/Controls/InstallStepControl.xaml
new file mode 100644
index 000000000..2e43607ed
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/InstallStepControl.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Step 3: Installing
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/installer/TRX_InstallerLib/Controls/InstallStepControl.xaml.cs b/tools/installer/TRX_InstallerLib/Controls/InstallStepControl.xaml.cs
new file mode 100644
index 000000000..1dca0228e
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/InstallStepControl.xaml.cs
@@ -0,0 +1,41 @@
+using Installer.Models;
+using System.Windows;
+using TRX_InstallerLib.Models;
+using WC = System.Windows.Controls;
+
+namespace TRX_InstallerLib.Controls;
+
+public partial class InstallStepControl : WC.UserControl
+{
+ public InstallStepControl()
+ {
+ InitializeComponent();
+ DataContextChanged += (object sender, DependencyPropertyChangedEventArgs e) =>
+ {
+ var dataContext = DataContext as InstallStep;
+ if (dataContext is not null)
+ {
+ string? lastMessage = null;
+ dataContext.Logger.LogEvent += (object sender, LogEventArgs e) =>
+ {
+ if (e.Message != lastMessage)
+ {
+ lastMessage = e.Message;
+ AppendMessage(e.Message);
+ }
+ };
+ }
+ };
+ }
+
+ private void AppendMessage(string message)
+ {
+ _logTextBox.Dispatcher.Invoke(() =>
+ {
+ _logTextBox.AppendText(message + Environment.NewLine);
+ _logTextBox.Focus();
+ _logTextBox.CaretIndex = _logTextBox.Text.Length;
+ _logTextBox.ScrollToEnd();
+ });
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Controls/SourceStepControl.xaml b/tools/installer/TRX_InstallerLib/Controls/SourceStepControl.xaml
new file mode 100644
index 000000000..4da8c5eec
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/SourceStepControl.xaml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Step 1: Choose installation source
+
+
+
+ TR1X requires original game files to run.
+
+ Please choose the source location where to install the data files from.
+
+ If you're upgrading an existing installation, please choose TR1X.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/installer/TRX_InstallerLib/Controls/SourceStepControl.xaml.cs b/tools/installer/TRX_InstallerLib/Controls/SourceStepControl.xaml.cs
new file mode 100644
index 000000000..ed2bf125e
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/SourceStepControl.xaml.cs
@@ -0,0 +1,11 @@
+using WC = System.Windows.Controls;
+
+namespace TRX_InstallerLib.Controls;
+
+public partial class SourceStepControl : WC.UserControl
+{
+ public SourceStepControl()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Controls/TRXInstallWindow.xaml b/tools/installer/TRX_InstallerLib/Controls/TRXInstallWindow.xaml
new file mode 100644
index 000000000..289bb6b99
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/TRXInstallWindow.xaml
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/installer/TRX_InstallerLib/Controls/TRXInstallWindow.xaml.cs b/tools/installer/TRX_InstallerLib/Controls/TRXInstallWindow.xaml.cs
new file mode 100644
index 000000000..3e9bda9d8
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Controls/TRXInstallWindow.xaml.cs
@@ -0,0 +1,14 @@
+using System.Windows;
+using TRX_InstallerLib.Installers;
+using TRX_InstallerLib.Models;
+
+namespace TRX_InstallerLib.Controls;
+
+public partial class TRXInstallWindow : Window
+{
+ public TRXInstallWindow(IEnumerable installSources)
+ {
+ InitializeComponent();
+ DataContext = new MainWindowViewModel(installSources);
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Installers/BaseInstallSource.cs b/tools/installer/TRX_InstallerLib/Installers/BaseInstallSource.cs
new file mode 100644
index 000000000..fbeedeaa0
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Installers/BaseInstallSource.cs
@@ -0,0 +1,38 @@
+using System.IO;
+using TRX_InstallerLib.Models;
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Installers;
+
+public abstract class BaseInstallSource : IInstallSource
+{
+ public abstract IEnumerable DirectoriesToTry { get; }
+
+ public virtual string ImageSource
+ {
+ get => AssemblyUtils.GetEmbeddedResourcePath($"{SourceName}.png");
+ }
+
+ public abstract bool IsImportingSavesSupported { get; }
+ public abstract string SourceName { get; }
+
+ public virtual string SuggestedInstallationDirectory
+ {
+ get => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
+ TRXConstants.Instance.Game!);
+ }
+
+ public abstract Task CopyOriginalGameFiles(
+ string sourceDirectory,
+ string targetDirectory,
+ IProgress progress,
+ bool importSaves
+ );
+
+ public abstract bool IsDownloadingMusicNeeded(string sourceDirectory);
+
+ public abstract bool IsDownloadingExpansionNeeded(string sourceDirectory);
+
+ public abstract bool IsGameFound(string sourceDirectory);
+}
diff --git a/tools/installer/TRX_InstallerLib/Installers/IInstallSource.cs b/tools/installer/TRX_InstallerLib/Installers/IInstallSource.cs
new file mode 100644
index 000000000..78280fecc
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Installers/IInstallSource.cs
@@ -0,0 +1,29 @@
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Installers;
+
+public interface IInstallSource
+{
+ public IEnumerable DirectoriesToTry { get; }
+
+ public string ImageSource { get; }
+
+ public string SourceName { get; }
+
+ public string SuggestedInstallationDirectory { get; }
+
+ public Task CopyOriginalGameFiles(
+ string sourceDirectory,
+ string targetDirectory,
+ IProgress progress,
+ bool importSaves
+ );
+
+ bool IsDownloadingMusicNeeded(string sourceDirectory);
+
+ bool IsDownloadingExpansionNeeded(string sourceDirectory);
+
+ public bool IsGameFound(string sourceDirectory);
+
+ bool IsImportingSavesSupported { get; }
+}
diff --git a/tools/installer/TRX_InstallerLib/Installers/InstallExecutor.cs b/tools/installer/TRX_InstallerLib/Installers/InstallExecutor.cs
new file mode 100644
index 000000000..ee0dc57a7
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Installers/InstallExecutor.cs
@@ -0,0 +1,103 @@
+using System.IO;
+using TRX_InstallerLib.Models;
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Installers;
+
+public class InstallExecutor
+{
+ private static readonly string _resourceBaseURL;
+
+ static InstallExecutor()
+ {
+ _resourceBaseURL = $"https://lostartefacts.dev/aux/{TRXConstants.Instance.Game!.ToLower()}";
+ }
+
+ private readonly InstallSettings _settings;
+
+ public InstallExecutor(InstallSettings settings)
+ {
+ _settings = settings;
+ }
+
+ public IInstallSource? InstallSource
+ {
+ get => _settings.InstallSource;
+ }
+
+ public async Task ExecuteInstall(IProgress progress)
+ {
+ if (_settings.SourceDirectory is null)
+ {
+ throw new NullReferenceException();
+ }
+ if (_settings.TargetDirectory is null)
+ {
+ throw new NullReferenceException();
+ }
+
+ await CopyOriginalGameFiles(_settings.SourceDirectory, _settings.TargetDirectory, progress);
+ await CopyTRXFiles(_settings.TargetDirectory, progress);
+ if (_settings.DownloadMusic)
+ {
+ await DownloadMusicFiles(_settings.TargetDirectory, progress);
+ }
+
+ if (_settings.DownloadExpansionPack)
+ {
+ await DownloadExpansionFiles(_settings.TargetDirectory, _settings.ExpansionPackType, progress);
+ }
+ if (_settings.CreateDesktopShortcut)
+ {
+ CreateDesktopShortcut(_settings.TargetDirectory);
+ }
+
+ progress.Report(new InstallProgress { Description = "Finished", Finished = true });
+ }
+
+ protected async Task CopyOriginalGameFiles(string sourceDirectory, string targetDirectory, IProgress progress)
+ {
+ if (_settings.InstallSource is null)
+ {
+ throw new NullReferenceException();
+ }
+ await _settings.InstallSource.CopyOriginalGameFiles(sourceDirectory, targetDirectory, progress, _settings.ImportSaves);
+ }
+
+ protected static async Task CopyTRXFiles(string targetDirectory, IProgress progress)
+ {
+ InstallUtils.StoreInstallationPath(targetDirectory);
+
+ progress.Report(new InstallProgress
+ {
+ CurrentValue = 0,
+ MaximumValue = 1,
+ Description = "Opening embedded ZIP",
+ });
+
+ using var stream = AssemblyUtils.GetResourceStream("Resources.release.zip", false)
+ ?? throw new ApplicationException($"Could not open embedded ZIP.");
+ await InstallUtils.ExtractZip(stream, targetDirectory, progress, overwrite: true);
+ }
+
+ protected static void CreateDesktopShortcut(string targetDirectory)
+ {
+ InstallUtils.CreateDesktopShortcut("TR1X", Path.Combine(targetDirectory, "TR1X.exe"));
+ if (File.Exists(Path.Combine(targetDirectory, "data", "cat.phd")))
+ {
+ InstallUtils.CreateDesktopShortcut("TR1X - UB", Path.Combine(targetDirectory, "TR1X.exe"), new[] { "-gold" });
+ }
+ }
+
+ protected static async Task DownloadMusicFiles(string targetDirectory, IProgress progress)
+ {
+ await InstallUtils.DownloadZip($"{_resourceBaseURL}/music.zip", targetDirectory, progress);
+ }
+
+ protected static async Task DownloadExpansionFiles(string targetDirectory, ExpansionPackType type, IProgress progress)
+ {
+ await InstallUtils.DownloadZip(
+ $"{_resourceBaseURL}/trub-{type.ToString().ToLower()}.zip",
+ targetDirectory, progress);
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Installers/InstallUtils.cs b/tools/installer/TRX_InstallerLib/Installers/InstallUtils.cs
new file mode 100644
index 000000000..99ef117a7
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Installers/InstallUtils.cs
@@ -0,0 +1,207 @@
+using Microsoft.Win32;
+using System.IO;
+using System.IO.Compression;
+using System.Text.RegularExpressions;
+using TRX_InstallerLib.Models;
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Installers;
+
+public static class InstallUtils
+{
+ private static readonly string _registryStorageKey;
+
+ static InstallUtils()
+ {
+ _registryStorageKey = $@"Software\{TRXConstants.Instance.Game}";
+ }
+
+ public static async Task CopyDirectoryTree(
+ string sourceDirectory,
+ string targetDirectory,
+ IProgress progress,
+ Func? filterCallback = null,
+ Func? overwriteCallback = null
+ )
+ {
+ try
+ {
+ progress.Report(new InstallProgress { Description = "Scanning directory" });
+ var files = Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories);
+ var currentProgress = 0;
+ var maximumProgress = files.Length;
+ foreach (var sourcePath in files)
+ {
+ if (filterCallback is not null && !filterCallback(sourcePath))
+ {
+ continue;
+ }
+ var relPath = Path.GetRelativePath(sourceDirectory, sourcePath);
+ var targetPath = Path.Combine(targetDirectory, relPath);
+ var isSamePath = string.Equals(Path.GetFullPath(sourcePath), Path.GetFullPath(targetPath), StringComparison.OrdinalIgnoreCase);
+ if (!File.Exists(targetPath) || (overwriteCallback is not null && overwriteCallback(sourcePath) && !isSamePath))
+ {
+ progress.Report(new InstallProgress
+ {
+ CurrentValue = currentProgress,
+ MaximumValue = maximumProgress,
+ Description = $"Copying {relPath}",
+ });
+ Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
+ await Task.Run(() => File.Copy(sourcePath, targetPath, true));
+ }
+ else
+ {
+ progress.Report(new InstallProgress
+ {
+ CurrentValue = currentProgress,
+ MaximumValue = maximumProgress,
+ Description = $"Copying {relPath} - skipped",
+ });
+ }
+
+ currentProgress++;
+ }
+ }
+ catch (Exception e)
+ {
+ throw new ApplicationException($"Could not extract ZIP:\n{e.Message}");
+ }
+ }
+
+ public static void CreateDesktopShortcut(string name, string targetPath, string[]? args = null)
+ {
+ var shortcutPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), $"{name}.lnk");
+ // TODO: pass extra arg for this
+ ShortcutUtils.CreateShortcut(shortcutPath, targetPath, "Tomb Raider I: Community Edition", args);
+ }
+
+ public static async Task DownloadFile(string url, IProgress progress)
+ {
+ HttpProgressClient wc = new();
+ progress.Report(new InstallProgress { Description = $"Initializing download of {url}" });
+ wc.DownloadProgressChanged += (totalBytesToReceive, bytesReceived) =>
+ {
+ progress.Report(new InstallProgress
+ {
+ CurrentValue = (int)bytesReceived,
+ MaximumValue = (int)totalBytesToReceive,
+ Description = $"Downloading {url}",
+ });
+ };
+ return await wc.DownloadDataTaskAsync(new Uri(url));
+ }
+
+ public static async Task DownloadZip(
+ string url,
+ string targetDirectory,
+ IProgress progress
+ )
+ {
+ var response = await DownloadFile(url, progress);
+ using var stream = new MemoryStream(response);
+ await ExtractZip(stream, targetDirectory, progress);
+ }
+
+ public static async Task ExtractZip(
+ Stream stream,
+ string targetDirectory,
+ IProgress progress,
+ Func? filterCallback = null,
+ bool overwrite = false
+ )
+ {
+ try
+ {
+ using var zip = new ZipArchive(stream);
+ progress.Report(new InstallProgress
+ {
+ Description = "Scanning ZIP",
+ });
+ var currentProgress = 0;
+ var maximumProgress = zip.Entries.Count;
+ foreach (var entry in zip.Entries)
+ {
+ if (new Regex(@"[\\/]$").IsMatch(entry.FullName))
+ {
+ continue;
+ }
+ if (filterCallback is not null && !filterCallback(entry.FullName))
+ {
+ continue;
+ }
+ var targetPath = Path.Combine(
+ targetDirectory,
+ new Regex(@"[\\/]").Replace(entry.FullName, Path.DirectorySeparatorChar.ToString()));
+
+ if (!File.Exists(targetPath) || overwrite)
+ {
+ progress.Report(new InstallProgress
+ {
+ CurrentValue = currentProgress,
+ MaximumValue = maximumProgress,
+ Description = $"Extracting {entry.FullName}",
+ });
+
+ Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
+ await Task.Run(() => entry.ExtractToFile(targetPath, true));
+ }
+ else
+ {
+ progress.Report(new InstallProgress
+ {
+ CurrentValue = currentProgress,
+ MaximumValue = maximumProgress,
+ Description = $"Extracting {entry.FullName} - skipped",
+ });
+ }
+
+ currentProgress++;
+ }
+ }
+ catch (Exception e)
+ {
+ throw new ApplicationException($"Could not extract ZIP:\n{e.Message}");
+ }
+ }
+
+ public static IEnumerable GetDesktopShortcutDirectories()
+ {
+ foreach (
+ var shortcutPath in Directory.EnumerateFiles(
+ Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), "*.lnk"
+ )
+ )
+ {
+ string? lnkPath;
+ try
+ {
+ lnkPath = ShortcutUtils.GetLnkTargetPath(shortcutPath);
+ }
+ catch (Exception)
+ {
+ continue;
+ }
+ if (lnkPath is not null)
+ {
+ var dirName = Path.GetDirectoryName(lnkPath);
+ if (dirName is not null)
+ {
+ yield return dirName;
+ }
+ }
+ }
+ }
+
+ public static void StoreInstallationPath(string installPath)
+ {
+ using var key = Registry.CurrentUser.CreateSubKey(_registryStorageKey);
+ key?.SetValue("InstallPath", installPath);
+ }
+
+ public static string? GetPreviousInstallationPath()
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(_registryStorageKey);
+ return key?.GetValue("InstallPath")?.ToString();
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/BaseLanguageViewModel.cs b/tools/installer/TRX_InstallerLib/Models/BaseLanguageViewModel.cs
new file mode 100644
index 000000000..436db1559
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/BaseLanguageViewModel.cs
@@ -0,0 +1,11 @@
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class BaseLanguageViewModel : BaseNotifyPropertyChanged
+{
+ public static Dictionary ViewText
+ {
+ get => Language.Instance.Controls ?? new();
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/ExpansionPackType.cs b/tools/installer/TRX_InstallerLib/Models/ExpansionPackType.cs
new file mode 100644
index 000000000..6b072667c
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/ExpansionPackType.cs
@@ -0,0 +1,7 @@
+namespace TRX_InstallerLib.Models;
+
+public enum ExpansionPackType
+{
+ Music,
+ Vanilla,
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/FinishSettings.cs b/tools/installer/TRX_InstallerLib/Models/FinishSettings.cs
new file mode 100644
index 000000000..d80740942
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/FinishSettings.cs
@@ -0,0 +1,35 @@
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class FinishSettings : BaseNotifyPropertyChanged
+{
+ public bool LaunchGame
+ {
+ get => _launchGame;
+ set
+ {
+ if (value != _launchGame)
+ {
+ _launchGame = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public bool OpenGameDirectory
+ {
+ get => _openGameDirectory;
+ set
+ {
+ if (value != _openGameDirectory)
+ {
+ _openGameDirectory = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ private bool _launchGame = false;
+ private bool _openGameDirectory = true;
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/FinishStep.cs b/tools/installer/TRX_InstallerLib/Models/FinishStep.cs
new file mode 100644
index 000000000..417f1222b
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/FinishStep.cs
@@ -0,0 +1,16 @@
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class FinishStep : BaseNotifyPropertyChanged, IStep
+{
+ public FinishStep(FinishSettings finishSettings)
+ {
+ FinishSettings = finishSettings;
+ }
+
+ public bool CanProceedToNextStep => false;
+ public bool CanProceedToPreviousStep => false;
+ public FinishSettings FinishSettings { get; }
+ public string SidebarImage => AssemblyUtils.GetEmbeddedResourcePath("side4.jpg");
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/IStep.cs b/tools/installer/TRX_InstallerLib/Models/IStep.cs
new file mode 100644
index 000000000..e2fe5ed1d
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/IStep.cs
@@ -0,0 +1,10 @@
+using System.ComponentModel;
+
+namespace TRX_InstallerLib.Models;
+
+public interface IStep : INotifyPropertyChanged
+{
+ bool CanProceedToNextStep { get; }
+ bool CanProceedToPreviousStep { get; }
+ string SidebarImage { get; }
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/InstallSettings.cs b/tools/installer/TRX_InstallerLib/Models/InstallSettings.cs
new file mode 100644
index 000000000..91201944f
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/InstallSettings.cs
@@ -0,0 +1,155 @@
+using TRX_InstallerLib.Installers;
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class InstallSettings : BaseNotifyPropertyChanged
+{
+ public bool CreateDesktopShortcut
+ {
+ get => _createDesktopShortcut;
+ set
+ {
+ if (value != _createDesktopShortcut)
+ {
+ _createDesktopShortcut = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public bool DownloadMusic
+ {
+ get => _downloadMusic;
+ set
+ {
+ if (value != _downloadMusic)
+ {
+ _downloadMusic = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public bool DownloadExpansionPack
+ {
+ get => _downloadExpansionPack;
+ set
+ {
+ if (value != _downloadExpansionPack)
+ {
+ _downloadExpansionPack = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public bool AllowExpansionTypeSelection
+ {
+ get => _allowExpansionPackSelection;
+ private set
+ {
+ if (value != _allowExpansionPackSelection)
+ {
+ _allowExpansionPackSelection = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public ExpansionPackType ExpansionPackType
+ {
+ get => _expansionPackType;
+ set
+ {
+ if (value != _expansionPackType)
+ {
+ _expansionPackType = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public bool ImportSaves
+ {
+ get => _importSaves;
+ set
+ {
+ if (value != _importSaves)
+ {
+ _importSaves = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public IInstallSource? InstallSource
+ {
+ get => _installSource;
+ set
+ {
+ if (value != _installSource)
+ {
+ _installSource = value;
+ DownloadMusic = SourceDirectory is not null && (_installSource?.IsDownloadingMusicNeeded(SourceDirectory) ?? false);
+ AllowExpansionTypeSelection = TRXConstants.Instance.AllowExpansionTypeSelection ?? false;
+ DownloadExpansionPack = SourceDirectory is not null && (_installSource?.IsDownloadingExpansionNeeded(SourceDirectory) ?? false);
+ ImportSaves = _installSource?.IsImportingSavesSupported ?? false;
+ TargetDirectory = _installSource?.SuggestedInstallationDirectory;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public bool IsDownloadingMusicNeeded
+ {
+ get
+ {
+ return SourceDirectory is not null && (InstallSource?.IsDownloadingMusicNeeded(SourceDirectory) ?? false);
+ }
+ }
+
+ public bool IsDownloadingExpansionNeeded
+ {
+ get
+ {
+ return SourceDirectory is not null && (InstallSource?.IsDownloadingExpansionNeeded(SourceDirectory) ?? false);
+ }
+ }
+
+ public string? SourceDirectory
+ {
+ get => _sourceDirectory;
+ set
+ {
+ if (value != _sourceDirectory)
+ {
+ _sourceDirectory = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public string? TargetDirectory
+ {
+ get => _targetDirectory;
+ set
+ {
+ if (value != _targetDirectory)
+ {
+ _targetDirectory = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ private bool _createDesktopShortcut = true;
+ private bool _downloadMusic;
+ private bool _downloadExpansionPack;
+ private bool _allowExpansionPackSelection;
+ private ExpansionPackType _expansionPackType;
+ private bool _importSaves;
+ private IInstallSource? _installSource;
+ private string? _sourceDirectory;
+ private string? _targetDirectory;
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/InstallSettingsStep.cs b/tools/installer/TRX_InstallerLib/Models/InstallSettingsStep.cs
new file mode 100644
index 000000000..161595ced
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/InstallSettingsStep.cs
@@ -0,0 +1,37 @@
+using System.Windows.Input;
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class InstallSettingsStep : BaseNotifyPropertyChanged, IStep
+{
+ public InstallSettingsStep(InstallSettings installSettings)
+ {
+ InstallSettings = installSettings;
+ InstallSettings.PropertyChanged += (sender, e) =>
+ {
+ NotifyPropertyChanged(nameof(CanProceedToNextStep));
+ };
+ }
+
+ public bool CanProceedToNextStep => InstallSettings.TargetDirectory != null;
+ public bool CanProceedToPreviousStep => true;
+ public InstallSettings InstallSettings { get; }
+ public string SidebarImage => AssemblyUtils.GetEmbeddedResourcePath("side2.jpg");
+
+
+ private RelayCommand? _chooseLocationCommand;
+ public ICommand ChooseLocationCommand
+ {
+ get => _chooseLocationCommand ??= new RelayCommand(ChooseLocation);
+ }
+
+ private void ChooseLocation()
+ {
+ var result = FileBrowser.Browse(InstallSettings.TargetDirectory);
+ if (result is not null)
+ {
+ InstallSettings.TargetDirectory = result;
+ }
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/InstallSourceViewModel.cs b/tools/installer/TRX_InstallerLib/Models/InstallSourceViewModel.cs
new file mode 100644
index 000000000..992d6bff2
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/InstallSourceViewModel.cs
@@ -0,0 +1,66 @@
+using System.Windows.Input;
+using TRX_InstallerLib.Installers;
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class InstallSourceViewModel : BaseNotifyPropertyChanged
+{
+ public InstallSourceViewModel(IInstallSource source)
+ {
+ InstallSource = source;
+
+ foreach (var directory in source.DirectoriesToTry)
+ {
+ if (InstallSource.IsGameFound(directory))
+ {
+ SourceDirectory = directory;
+ break;
+ }
+ }
+ }
+
+ public ICommand ChooseLocationCommand
+ {
+ get
+ {
+ return _chooseLocationCommand ??= new RelayCommand(ChooseLocation);
+ }
+ }
+
+ public IInstallSource InstallSource { get; private set; }
+
+ public bool IsAvailable
+ {
+ get
+ {
+ return SourceDirectory != null && InstallSource.IsGameFound(SourceDirectory);
+ }
+ }
+
+ public string? SourceDirectory
+ {
+ get => _sourceDirectory;
+ set
+ {
+ if (value != _sourceDirectory)
+ {
+ _sourceDirectory = value;
+ NotifyPropertyChanged();
+ NotifyPropertyChanged(nameof(IsAvailable));
+ }
+ }
+ }
+
+ private RelayCommand? _chooseLocationCommand;
+ private string? _sourceDirectory;
+
+ private void ChooseLocation()
+ {
+ var result = FileBrowser.Browse(SourceDirectory);
+ if (result is not null)
+ {
+ SourceDirectory = result;
+ }
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/InstallStep.cs b/tools/installer/TRX_InstallerLib/Models/InstallStep.cs
new file mode 100644
index 000000000..1fcd7a56d
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/InstallStep.cs
@@ -0,0 +1,119 @@
+using Installer.Models;
+using TRX_InstallerLib.Installers;
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class InstallStep : BaseNotifyPropertyChanged, IStep
+{
+ public InstallStep(InstallSettings installSettings)
+ {
+ Logger = new Logger();
+ InstallSettings = installSettings;
+ }
+
+ public bool CanProceedToNextStep
+ {
+ get => _canProceedToNextStep;
+ set
+ {
+ if (value != _canProceedToNextStep)
+ {
+ _canProceedToNextStep = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public bool CanProceedToPreviousStep => false;
+
+ public int CurrentProgress
+ {
+ get { return _currentProgress; }
+ set
+ {
+ if (value != _currentProgress)
+ {
+ _currentProgress = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public string? Description
+ {
+ get => _description;
+ set
+ {
+ if (value != _description)
+ {
+ _description = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public InstallSettings InstallSettings { get; }
+ public Logger Logger { get; }
+
+ public int MaximumProgress
+ {
+ get { return _maximumProgress; }
+ set
+ {
+ if (value != _maximumProgress)
+ {
+ _maximumProgress = value;
+ NotifyPropertyChanged();
+ NotifyPropertyChanged(nameof(CanProceedToNextStep));
+ }
+ }
+ }
+
+ public string SidebarImage => AssemblyUtils.GetEmbeddedResourcePath("side3.jpg");
+
+ public void RunInstall()
+ {
+ var progress = new Progress();
+ progress.ProgressChanged += (sender, progress) =>
+ {
+ if (progress.CurrentValue is not null && progress.MaximumValue is not null)
+ {
+ CurrentProgress = progress.CurrentValue.Value;
+ MaximumProgress = progress.MaximumValue.Value;
+ }
+ else
+ {
+ CurrentProgress = progress.Finished ? 1 : 0;
+ MaximumProgress = 1;
+ }
+ Description = progress.Description;
+ if (progress.Description is not null)
+ {
+ Logger.RaiseLogEvent(progress.Description);
+ }
+ if (progress.Finished)
+ {
+ CanProceedToNextStep = true;
+ }
+ };
+
+ Task.Run(async () =>
+ {
+ try
+ {
+ var executor = new InstallExecutor(InstallSettings);
+ await executor.ExecuteInstall(progress);
+ }
+ catch (Exception ex)
+ {
+ Logger.RaiseLogEvent(ex.ToString());
+ }
+ });
+ }
+
+ private bool _canProceedToNextStep;
+ private int _currentProgress = 0;
+ private string? _description;
+ private int _maximumProgress = 1;
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/Language.cs b/tools/installer/TRX_InstallerLib/Models/Language.cs
new file mode 100644
index 000000000..d39ae2880
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/Language.cs
@@ -0,0 +1,46 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using System.Globalization;
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class Language
+{
+ private static readonly string _langPathFormat = "Resources.Lang.{0}.json";
+ private static readonly string _defaultCulture = "en-US";
+
+ public static Language Instance { get; private set; }
+
+ public Dictionary? Controls { get; set; }
+
+ static Language()
+ {
+ CultureInfo defaultCulture = CultureInfo.GetCultureInfo(_defaultCulture);
+ JObject defaultData = ReadLanguage(defaultCulture.TwoLetterISOLanguageName);
+
+ if (CultureInfo.CurrentCulture != defaultCulture)
+ {
+ // Merge the main language first if it exists, and then the country specific if that exists.
+ // e.g. fr.json would load first, then fr-BE.json.
+ MergeLanguage(defaultData, CultureInfo.CurrentCulture.TwoLetterISOLanguageName);
+ MergeLanguage(defaultData, CultureInfo.CurrentCulture.Name);
+ }
+
+ Instance = JsonConvert.DeserializeObject(defaultData.ToString())!;
+ }
+
+ private static JObject ReadLanguage(string tag)
+ {
+ return JsonUtils.LoadEmbeddedResource(string.Format(_langPathFormat, tag)) ?? new();
+ }
+
+ private static void MergeLanguage(JObject data, string tag)
+ {
+ JObject cultureData = ReadLanguage(tag);
+ if (cultureData != null)
+ {
+ data.Merge(cultureData);
+ }
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/Logger.cs b/tools/installer/TRX_InstallerLib/Models/Logger.cs
new file mode 100644
index 000000000..0c25c9318
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/Logger.cs
@@ -0,0 +1,23 @@
+namespace Installer.Models;
+
+public class LogEventArgs
+{
+ public LogEventArgs(string message)
+ {
+ Message = message;
+ }
+
+ public string Message { get; }
+}
+
+public class Logger
+{
+ public delegate void LogEventHandler(object sender, LogEventArgs e);
+
+ public event LogEventHandler? LogEvent;
+
+ public void RaiseLogEvent(string message)
+ {
+ LogEvent?.Invoke(this, new LogEventArgs(message));
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/MainWindowViewModel.cs b/tools/installer/TRX_InstallerLib/Models/MainWindowViewModel.cs
new file mode 100644
index 000000000..704210c3a
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/MainWindowViewModel.cs
@@ -0,0 +1,152 @@
+using System.Diagnostics;
+using System.IO;
+using System.Windows;
+using System.Windows.Input;
+using TRX_InstallerLib.Installers;
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class MainWindowViewModel : BaseLanguageViewModel
+{
+ public MainWindowViewModel(IEnumerable installSources)
+ {
+ _sourceStep = new SourceStep(installSources);
+ _currentStep = _sourceStep;
+ _installSettings = new InstallSettings();
+ }
+
+ public ICommand CloseWindowCommand
+ {
+ get => _closeWindowCommand ??= new RelayCommand(CloseWindow);
+ }
+
+ public IStep CurrentStep
+ {
+ get => _currentStep;
+ set
+ {
+ _currentStep = value;
+ _goToPreviousStepCommand?.RaiseCanExecuteChanged();
+ _goToNextStepCommand?.RaiseCanExecuteChanged();
+ _currentStep.PropertyChanged += (sender, e) =>
+ {
+ _goToPreviousStepCommand?.RaiseCanExecuteChanged();
+ _goToNextStepCommand?.RaiseCanExecuteChanged();
+ };
+ NotifyPropertyChanged();
+ NotifyPropertyChanged(nameof(IsFinalStep));
+ }
+ }
+
+ public ICommand GoToNextStepCommand
+ {
+ get => _goToNextStepCommand ??= new RelayCommand(GoToNextStep, CanGoToNextStep);
+ }
+
+ public ICommand GoToPreviousStepCommand
+ {
+ get => _goToPreviousStepCommand ??= new RelayCommand(GoToPreviousStep, CanGoToPreviousStep);
+ }
+
+ public bool IsFinalStep
+ {
+ get => CurrentStep is FinishStep;
+ }
+
+ public bool IsSidebarVisible
+ {
+ get => WindowWidth >= 500;
+ }
+
+ public int WindowWidth
+ {
+ get => _windowWidth;
+ set
+ {
+ if (value != _windowWidth)
+ {
+ _windowWidth = value;
+ NotifyPropertyChanged(nameof(IsSidebarVisible));
+ }
+ }
+ }
+
+ private const bool _autoFinishInstallStep = false;
+
+ private RelayCommand? _closeWindowCommand;
+
+ private IStep _currentStep;
+ private FinishSettings? _finishSettings;
+ private RelayCommand? _goToNextStepCommand;
+ private RelayCommand? _goToPreviousStepCommand;
+ private readonly InstallSettings _installSettings;
+ private readonly IStep _sourceStep;
+ private int _windowWidth;
+
+ private bool CanGoToNextStep()
+ {
+ return CurrentStep.CanProceedToNextStep;
+ }
+
+ private bool CanGoToPreviousStep()
+ {
+ return CurrentStep.CanProceedToPreviousStep;
+ }
+
+ private void CloseWindow(Window? window)
+ {
+ if (_finishSettings is not null && _finishSettings.LaunchGame)
+ {
+ if (_installSettings.TargetDirectory is null)
+ {
+ throw new NullReferenceException();
+ }
+ Process.Start(Path.Combine(_installSettings.TargetDirectory, "TR1X.exe"));
+ }
+ if (_finishSettings is not null && _finishSettings.OpenGameDirectory)
+ {
+ if (_installSettings.TargetDirectory is null)
+ {
+ throw new NullReferenceException();
+ }
+ Process.Start("explorer.exe", _installSettings.TargetDirectory);
+ }
+ window?.Close();
+ }
+
+ private void GoToNextStep()
+ {
+ if (CurrentStep is SourceStep sourceStep)
+ {
+ var installSource = sourceStep.SelectedInstallationSource!.InstallSource;
+ _installSettings.InstallSource = installSource;
+ _installSettings.SourceDirectory = sourceStep.SelectedInstallationSource.SourceDirectory;
+ CurrentStep = new InstallSettingsStep(_installSettings);
+ }
+ else if (CurrentStep is InstallSettingsStep targetStep)
+ {
+ var installStep = new InstallStep(targetStep.InstallSettings);
+ installStep.RunInstall();
+ installStep.PropertyChanged += (sender, e) =>
+ {
+ if (_autoFinishInstallStep && installStep.CanProceedToNextStep)
+ {
+ _finishSettings = new FinishSettings();
+ CurrentStep = new FinishStep(_finishSettings);
+ }
+ };
+ CurrentStep = installStep;
+ }
+ else if (CurrentStep is InstallStep)
+ {
+ _finishSettings = new FinishSettings();
+ CurrentStep = new FinishStep(_finishSettings);
+ }
+ }
+
+ private void GoToPreviousStep()
+ {
+ CurrentStep = _sourceStep;
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/SourceStep.cs b/tools/installer/TRX_InstallerLib/Models/SourceStep.cs
new file mode 100644
index 000000000..b41b313bc
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/SourceStep.cs
@@ -0,0 +1,60 @@
+using System.Collections.ObjectModel;
+using TRX_InstallerLib.Installers;
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class SourceStep : BaseNotifyPropertyChanged, IStep
+{
+ public SourceStep(IEnumerable installSources)
+ {
+ // NOTE: the order also decides which installation source will be selected by default
+ InstallationSources = new ObservableCollection
+ (installSources.Select(i => new InstallSourceViewModel(i)));
+
+ foreach (var installationSource in InstallationSources)
+ {
+ installationSource.PropertyChanged += (sender, e) =>
+ {
+ NotifyPropertyChanged(nameof(InstallationSources));
+ if (installationSource == selectedInstallationSource)
+ {
+ NotifyPropertyChanged(nameof(SelectedInstallationSource));
+ }
+ };
+ }
+
+ foreach (var source in InstallationSources)
+ {
+ if (source.IsAvailable)
+ {
+ SelectedInstallationSource = source;
+ }
+ }
+ }
+
+ public bool CanProceedToNextStep
+ {
+ get => SelectedInstallationSource != null && SelectedInstallationSource.IsAvailable;
+ }
+
+ public bool CanProceedToPreviousStep => false;
+ public IEnumerable InstallationSources { get; private set; }
+
+ public InstallSourceViewModel? SelectedInstallationSource
+ {
+ get => selectedInstallationSource;
+ set
+ {
+ if (value != selectedInstallationSource)
+ {
+ selectedInstallationSource = value;
+ NotifyPropertyChanged();
+ NotifyPropertyChanged(nameof(CanProceedToNextStep));
+ }
+ }
+ }
+
+ public string SidebarImage => AssemblyUtils.GetEmbeddedResourcePath("side1.jpg");
+ private InstallSourceViewModel? selectedInstallationSource;
+}
diff --git a/tools/installer/TRX_InstallerLib/Models/TRXConstants.cs b/tools/installer/TRX_InstallerLib/Models/TRXConstants.cs
new file mode 100644
index 000000000..d3054be84
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Models/TRXConstants.cs
@@ -0,0 +1,19 @@
+using TRX_InstallerLib.Utils;
+
+namespace TRX_InstallerLib.Models;
+
+public class TRXConstants
+{
+ private static readonly string _constConfigPath = "Resources.const.json";
+
+ public static TRXConstants Instance { get; private set; }
+
+ static TRXConstants()
+ {
+ Instance = JsonUtils.LoadEmbeddedResource(_constConfigPath)?.ToObject() ?? new();
+ }
+
+ public string? Game { get; set; }
+ public bool? AllowExpansionTypeSelection { get; set; }
+
+}
diff --git a/tools/installer/TRX_InstallerLib/Resources/Lang/en.json b/tools/installer/TRX_InstallerLib/Resources/Lang/en.json
new file mode 100644
index 000000000..040e6b1c9
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Resources/Lang/en.json
@@ -0,0 +1,5 @@
+{
+ "Controls": {
+
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Resources/const.json b/tools/installer/TRX_InstallerLib/Resources/const.json
new file mode 100644
index 000000000..2adf8e045
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Resources/const.json
@@ -0,0 +1,4 @@
+{
+ "Game": "TRX",
+ "AllowExpansionTypeSelection": false
+}
diff --git a/tools/installer/TRX_InstallerLib/Resources/styles.xaml b/tools/installer/TRX_InstallerLib/Resources/styles.xaml
new file mode 100644
index 000000000..d59569f5d
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Resources/styles.xaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/installer/TRX_InstallerLib/TRX_InstallerLib.csproj b/tools/installer/TRX_InstallerLib/TRX_InstallerLib.csproj
new file mode 100644
index 000000000..79e08d5a3
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/TRX_InstallerLib.csproj
@@ -0,0 +1,24 @@
+
+
+ net6.0-windows
+ enable
+ true
+ true
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+ MSBuild:Compile
+
+
+
diff --git a/tools/installer/TRX_InstallerLib/Utils/AssemblyUtils.cs b/tools/installer/TRX_InstallerLib/Utils/AssemblyUtils.cs
new file mode 100644
index 000000000..0ea0cdb03
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Utils/AssemblyUtils.cs
@@ -0,0 +1,35 @@
+using System.IO;
+using System.Reflection;
+
+namespace TRX_InstallerLib.Utils;
+
+public static class AssemblyUtils
+{
+ public static readonly string _resourcePathFormat = "pack://application:,,,/{0};component/Resources/{1}";
+
+ private static Assembly? GetReferencedAssembly(bool local)
+ {
+ return local ? Assembly.GetExecutingAssembly() : Assembly.GetEntryAssembly();
+ }
+
+ public static Stream GetResourceStream(string relativePath, bool local)
+ {
+ return GetReferencedAssembly(local)?.GetManifestResourceStream(GetAbsolutePath(relativePath, local))!;
+ }
+
+ public static bool ResourceExists(string relativePath, bool local)
+ {
+ return GetReferencedAssembly(local)?.GetManifestResourceNames()
+ .Contains(GetAbsolutePath(relativePath, local)) ?? false;
+ }
+
+ public static string GetAbsolutePath(string relativePath, bool local)
+ {
+ return $"{GetReferencedAssembly(local)!.GetName().Name}.{relativePath}";
+ }
+
+ public static string GetEmbeddedResourcePath(string resource)
+ {
+ return string.Format(_resourcePathFormat, Assembly.GetEntryAssembly()!.GetName().Name, resource);
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Utils/BaseNotifyPropertyChanged.cs b/tools/installer/TRX_InstallerLib/Utils/BaseNotifyPropertyChanged.cs
new file mode 100644
index 000000000..e3da70628
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Utils/BaseNotifyPropertyChanged.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace TRX_InstallerLib.Utils;
+
+public abstract class BaseNotifyPropertyChanged : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Utils/BinaryReaderExtensions.cs b/tools/installer/TRX_InstallerLib/Utils/BinaryReaderExtensions.cs
new file mode 100644
index 000000000..11b3bd09a
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Utils/BinaryReaderExtensions.cs
@@ -0,0 +1,30 @@
+using System.IO;
+using System.Text;
+
+namespace TRX_InstallerLib.Utils;
+
+public static class BinaryReaderExtensions
+{
+ public static string ReadNullTerminatedString(this BinaryReader stream)
+ {
+ string str = "";
+ char ch;
+ while ((int)(ch = stream.ReadChar()) != 0)
+ {
+ str += ch;
+ }
+ return str;
+ }
+
+ public static string ReadSystemCodepageString(this BinaryReader stream)
+ {
+ var length = stream.ReadUInt16();
+ return Encoding.Default.GetString(stream.ReadBytes(length));
+ }
+
+ public static string ReadUtf16String(this BinaryReader stream)
+ {
+ var length = stream.ReadUInt16();
+ return Encoding.Unicode.GetString(stream.ReadBytes(length * 2));
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Utils/BoolToVisibilityConverter.cs b/tools/installer/TRX_InstallerLib/Utils/BoolToVisibilityConverter.cs
new file mode 100644
index 000000000..c4cfaa0ce
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Utils/BoolToVisibilityConverter.cs
@@ -0,0 +1,28 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace TRX_InstallerLib.Utils;
+
+[ValueConversion(typeof(bool), typeof(Visibility))]
+public class BoolToVisibilityConverter : IValueConverter
+{
+ public BoolToVisibilityConverter()
+ {
+ FalseValue = Visibility.Hidden;
+ TrueValue = Visibility.Visible;
+ }
+
+ public Visibility FalseValue { get; set; }
+ public Visibility TrueValue { get; set; }
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return (bool)value ? TrueValue : FalseValue;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Utils/ComparisonConverter.cs b/tools/installer/TRX_InstallerLib/Utils/ComparisonConverter.cs
new file mode 100644
index 000000000..c8af42d72
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Utils/ComparisonConverter.cs
@@ -0,0 +1,17 @@
+using System.Globalization;
+using WD = System.Windows.Data;
+
+namespace TRX_InstallerLib.Utils;
+
+public class ComparisonConverter : WD.IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value.Equals(parameter);
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return (bool)value ? parameter : WD.Binding.DoNothing;
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Utils/ConditionalMarkupConverter.cs b/tools/installer/TRX_InstallerLib/Utils/ConditionalMarkupConverter.cs
new file mode 100644
index 000000000..8e5e5cb7c
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Utils/ConditionalMarkupConverter.cs
@@ -0,0 +1,26 @@
+using System.Globalization;
+using System.Windows.Data;
+using System.Windows.Markup;
+
+namespace TRX_InstallerLib.Utils;
+
+public sealed class ConditionalMarkupConverter : MarkupExtension, IValueConverter
+{
+ public object FalseValue { get; set; } = new();
+ public object TrueValue { get; set; } = new();
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value is true ? TrueValue : FalseValue;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override object ProvideValue(IServiceProvider serviceProvider)
+ {
+ return this;
+ }
+}
diff --git a/tools/installer/TRX_InstallerLib/Utils/CueFile.cs b/tools/installer/TRX_InstallerLib/Utils/CueFile.cs
new file mode 100644
index 000000000..f4ec01d76
--- /dev/null
+++ b/tools/installer/TRX_InstallerLib/Utils/CueFile.cs
@@ -0,0 +1,89 @@
+using System.IO;
+using System.Text.RegularExpressions;
+
+namespace TRX_InstallerLib.Utils;
+
+public class CueFile
+{
+ public readonly List TrackList = new();
+
+ public CueFile(string cueFilePath)
+ {
+ _cueFilePath = cueFilePath;
+ string cueFileContent;
+ using (TextReader cueReader = new StreamReader(cueFilePath))
+ {
+ cueFileContent = cueReader.ReadToEnd();
+ }
+
+ MatchCollection fileMatches = _fileGroupRegex.Matches(cueFileContent);
+ if (fileMatches.Count == 0)
+ {
+ throw new ApplicationException($"Could not parse {cueFilePath}: no tracks were found");
+ }
+
+ foreach (Match fileMatch in fileMatches.Cast())
+ {
+ var binFilePath = GetBinFilePath(fileMatch.Groups["name"].Value.Trim('"'));
+ var matches = _trackRegex.Matches(fileMatch.Groups["content"].Value);
+
+ if (matches.Count == 0)
+ {
+ throw new ApplicationException($"Could not parse {cueFilePath}: no tracks were found");
+ }
+
+ CueTrack? track = null;
+ CueTrack? prevTrack = null;
+ foreach (Match trackMatch in matches.Cast())
+ {
+ track = new CueTrack(
+ binFilePath,
+ int.Parse(trackMatch.Groups["track"].Value),
+ trackMatch.Groups["mode"].Value,
+ trackMatch.Groups["time"].Value);
+
+ if (prevTrack != null)
+ {
+ prevTrack.Stop = track.StartPosition - 1;
+ prevTrack.StopSector = track.StartSector;
+ }
+ TrackList.Add(track);
+ prevTrack = track;
+ }
+
+ if (track == null)
+ {
+ return;
+ }
+
+ track.Stop = GetBinFileLength(binFilePath);
+ track.StopSector = track.Stop / CueTrack.SectorLength;
+ }
+ }
+
+ private static readonly Regex _fileGroupRegex = new(
+ @"^file\s+(?""[^""]+""|[^""\s]+)\s+(?\w+)\s+(?(.(?!^file))*)",
+ RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Singleline);
+
+ private static readonly Regex _trackRegex = new(@"track\s+?(?