From 6fd073f73dd3b608df01dd9631539c3f8c6b24f4 Mon Sep 17 00:00:00 2001
From: Tin <263944625+tinfoil766@users.noreply.github.com>
Date: Tue, 14 Apr 2026 19:56:36 +0200
Subject: [PATCH 01/24] Add match-based X-axis toggle for VR History Graph
---
.../Views/Patterns/VrHistoryGraph.axaml | 11 +++-
.../Views/Patterns/VrHistoryGraph.axaml.cs | 63 +++++++++++++++----
2 files changed, 61 insertions(+), 13 deletions(-)
diff --git a/WheelWizard/Views/Patterns/VrHistoryGraph.axaml b/WheelWizard/Views/Patterns/VrHistoryGraph.axaml
index d55b65b0..041822db 100644
--- a/WheelWizard/Views/Patterns/VrHistoryGraph.axaml
+++ b/WheelWizard/Views/Patterns/VrHistoryGraph.axaml
@@ -65,11 +65,18 @@
-
+
+
+
+
diff --git a/WheelWizard/Views/Patterns/VrHistoryGraph.axaml.cs b/WheelWizard/Views/Patterns/VrHistoryGraph.axaml.cs
index cd1cdde8..f21eda93 100644
--- a/WheelWizard/Views/Patterns/VrHistoryGraph.axaml.cs
+++ b/WheelWizard/Views/Patterns/VrHistoryGraph.axaml.cs
@@ -36,6 +36,8 @@ public partial class VrHistoryGraph : UserControlBase, INotifyPropertyChanged
private int _reloadVersion;
private bool _isInitializingDaysDropdown;
private int _selectedHistoryDays = DefaultHistoryDays;
+ private bool _useMatchesAsXAxis;
+ private RwfcPlayerVrHistoryResponse? _lastHistoryResponse;
private Geometry? _graphPath;
private Geometry? _graphAreaPath;
private string _graphStartLabel = string.Empty;
@@ -54,6 +56,22 @@ static VrHistoryGraph()
FriendCodeProperty.Changed.AddClassHandler((graph, _) => graph.TriggerHistoryReload());
}
+ public bool UseMatchesAsXAxis
+ {
+ get => _useMatchesAsXAxis;
+ set
+ {
+ if (_useMatchesAsXAxis == value)
+ return;
+
+ _useMatchesAsXAxis = value;
+ OnPropertyChanged(nameof(UseMatchesAsXAxis));
+
+ if (_lastHistoryResponse != null)
+ ApplyHistoryData(_lastHistoryResponse, _selectedHistoryDays);
+ }
+ }
+
public string? FriendCode
{
get => GetValue(FriendCodeProperty);
@@ -372,6 +390,7 @@ private void SetNoFriendCodeState()
private void ApplyHistoryData(RwfcPlayerVrHistoryResponse historyResponse, int days)
{
+ _lastHistoryResponse = historyResponse;
EmptyStateText = "No VR history found for this range yet.";
StartingVr = historyResponse.StartingVr;
EndingVr = historyResponse.EndingVr;
@@ -408,19 +427,40 @@ private void ApplyHistoryData(RwfcPlayerVrHistoryResponse historyResponse, int d
GraphMinVrText = minVr.ToString("N0");
GraphMaxVrText = maxVr.ToString("N0");
- var labelFormat = days >= 7 ? "MMM d" : "MMM d HH:mm";
- GraphStartLabel = graphStart.ToLocalTime().ToString(labelFormat);
- GraphMidLabel = graphStart.AddSeconds(totalSeconds / 2).ToLocalTime().ToString(labelFormat);
- GraphEndLabel = graphEnd.ToLocalTime().ToString(labelFormat);
+
+ if (UseMatchesAsXAxis)
+ {
+ GraphStartLabel = "Match 1";
+ GraphMidLabel = $"Match {(orderedByDate.Count / 2) + 1}";
+ GraphEndLabel = $"Match {orderedByDate.Count}";
+ }
+ else
+ {
+ var labelFormat = days >= 7 ? "MMM d" : "MMM d HH:mm";
+ GraphStartLabel = graphStart.ToLocalTime().ToString(labelFormat);
+ GraphMidLabel = graphStart.AddSeconds(totalSeconds / 2).ToLocalTime().ToString(labelFormat);
+ GraphEndLabel = graphEnd.ToLocalTime().ToString(labelFormat);
+ }
var points = orderedByDate
- .Select(entry =>
- {
- var seconds = (entry.Date - graphStart).TotalSeconds;
- var x = seconds / totalSeconds * GraphWidth;
- var y = GraphHeight - (entry.TotalVr - minVr) / (double)vrRange * GraphHeight;
- return new Point(x, y);
- })
+ .Select(
+ (entry, i) =>
+ {
+ double x;
+ if (UseMatchesAsXAxis)
+ {
+ x = orderedByDate.Count > 1 ? (double)i / (orderedByDate.Count - 1) * GraphWidth : 0;
+ }
+ else
+ {
+ var seconds = (entry.Date - graphStart).TotalSeconds;
+ x = seconds / totalSeconds * GraphWidth;
+ }
+
+ var y = GraphHeight - (entry.TotalVr - minVr) / (double)vrRange * GraphHeight;
+ return new Point(x, y);
+ }
+ )
.ToList();
GraphPath = BuildLineGeometry(points);
@@ -430,6 +470,7 @@ private void ApplyHistoryData(RwfcPlayerVrHistoryResponse historyResponse, int d
private void ResetGraph()
{
+ _lastHistoryResponse = null;
GraphPath = null;
GraphAreaPath = null;
GraphStartLabel = string.Empty;
From a4e3efc57291117371732d0143ef8a23d2f63959 Mon Sep 17 00:00:00 2001
From: Patchzy <64382339+patchzyy@users.noreply.github.com>
Date: Mon, 27 Apr 2026 23:06:33 +0200
Subject: [PATCH 02/24] Full Patches Implementation
---
.../Features/Archives/ArchivesExtensions.cs | 10 +
.../Archives/Domain/DecodedArchive.cs | 3 +
.../Features/Archives/Domain/U8Node.cs | 3 +
.../Features/Archives/ISzsArchiveDecoder.cs | 10 +
.../Features/Archives/SzsArchiveDecoder.cs | 149 ++++
.../GameBanana/Domain/GameBananaModPreview.cs | 14 +-
.../Features/Mods/ModInstallationService.cs | 237 +++++++
WheelWizard/Features/Mods/ModManager.cs | 659 ++++++++++++++++++
WheelWizard/Features/Mods/ModsExtensions.cs | 12 +
.../Features/Mods/ModsLaunchService.cs | 138 ++++
.../Features/Patches/BrsarPatchConverter.cs | 526 ++++++++++++++
.../Patches/Domain/GameBaselineModels.cs | 81 +++
.../Features/Patches/GameBaselineStore.cs | 45 ++
.../Features/Patches/ISzsPatchConverter.cs | 8 +
.../Features/Patches/KartSzsAllowList.cs | 24 +
.../Patches/ModPatchConversionService.cs | 279 ++++++++
.../Patches/PatchConversionHelpers.cs | 43 ++
.../Features/Patches/PatchesExtensions.cs | 11 +
.../Patches/Resources/allowed-kart-szs.json | 1 +
.../Patches/Resources/game-baseline-data.json | 1 +
.../Resources/game-baseline-index.json | 1 +
.../Features/Patches/SzsPatchConverter.cs | 175 +++++
WheelWizard/Helpers/BigEndianBinaryHelper.cs | 8 +
WheelWizard/Helpers/BinaryStringHelper.cs | 25 +
WheelWizard/Helpers/FileHelper.cs | 37 +-
WheelWizard/Models/Mods/Mod.cs | 15 +
WheelWizard/Program.cs | 21 +
.../Services/Installation/ModInstallation.cs | 285 --------
.../Services/Launcher/GoogleLauncher.cs | 11 +-
.../Launcher/Helpers/ModsLaunchHelper.cs | 201 ------
WheelWizard/Services/Launcher/ILauncher.cs | 6 +-
.../Services/Launcher/RrBetaLauncher.cs | 86 +--
WheelWizard/Services/Launcher/RrLauncher.cs | 89 +--
WheelWizard/Services/ModManager.cs | 552 ---------------
WheelWizard/Services/ModStorageSystem.cs | 59 --
WheelWizard/Services/PathManager.cs | 5 +-
.../Storage/CustomFilePickerFileType.cs | 12 +-
WheelWizard/SetupExtensions.cs | 6 +
.../MessageTranslationHelper.cs | 24 +-
.../Shared/OperationResult/OperationResult.cs | 5 +
WheelWizard/Views/App.axaml.cs | 29 +-
WheelWizard/Views/Layout.axaml | 2 +-
WheelWizard/Views/Layout.axaml.cs | 34 +-
WheelWizard/Views/Pages/HomePage.axaml.cs | 16 +-
WheelWizard/Views/Pages/ModsPage.axaml | 48 +-
WheelWizard/Views/Pages/ModsPage.axaml.cs | 191 +++--
WheelWizard/Views/Pages/TestingPage.axaml.cs | 9 +-
WheelWizard/Views/Patterns/GridModPanel.axaml | 22 +
.../Views/Patterns/SidebarRadioButton.axaml | 10 +
.../Patterns/SidebarRadioButton.axaml.cs | 20 +
.../Popups/ModManagement/ModContent.axaml.cs | 198 ++++--
WheelWizard/WheelWizard.csproj | 4 +
52 files changed, 3097 insertions(+), 1363 deletions(-)
create mode 100644 WheelWizard/Features/Archives/ArchivesExtensions.cs
create mode 100644 WheelWizard/Features/Archives/Domain/DecodedArchive.cs
create mode 100644 WheelWizard/Features/Archives/Domain/U8Node.cs
create mode 100644 WheelWizard/Features/Archives/ISzsArchiveDecoder.cs
create mode 100644 WheelWizard/Features/Archives/SzsArchiveDecoder.cs
create mode 100644 WheelWizard/Features/Mods/ModInstallationService.cs
create mode 100644 WheelWizard/Features/Mods/ModManager.cs
create mode 100644 WheelWizard/Features/Mods/ModsExtensions.cs
create mode 100644 WheelWizard/Features/Mods/ModsLaunchService.cs
create mode 100644 WheelWizard/Features/Patches/BrsarPatchConverter.cs
create mode 100644 WheelWizard/Features/Patches/Domain/GameBaselineModels.cs
create mode 100644 WheelWizard/Features/Patches/GameBaselineStore.cs
create mode 100644 WheelWizard/Features/Patches/ISzsPatchConverter.cs
create mode 100644 WheelWizard/Features/Patches/KartSzsAllowList.cs
create mode 100644 WheelWizard/Features/Patches/ModPatchConversionService.cs
create mode 100644 WheelWizard/Features/Patches/PatchConversionHelpers.cs
create mode 100644 WheelWizard/Features/Patches/PatchesExtensions.cs
create mode 100644 WheelWizard/Features/Patches/Resources/allowed-kart-szs.json
create mode 100644 WheelWizard/Features/Patches/Resources/game-baseline-data.json
create mode 100644 WheelWizard/Features/Patches/Resources/game-baseline-index.json
create mode 100644 WheelWizard/Features/Patches/SzsPatchConverter.cs
create mode 100644 WheelWizard/Helpers/BinaryStringHelper.cs
delete mode 100644 WheelWizard/Services/Installation/ModInstallation.cs
delete mode 100644 WheelWizard/Services/Launcher/Helpers/ModsLaunchHelper.cs
delete mode 100644 WheelWizard/Services/ModManager.cs
delete mode 100644 WheelWizard/Services/ModStorageSystem.cs
diff --git a/WheelWizard/Features/Archives/ArchivesExtensions.cs b/WheelWizard/Features/Archives/ArchivesExtensions.cs
new file mode 100644
index 00000000..e6bfd90b
--- /dev/null
+++ b/WheelWizard/Features/Archives/ArchivesExtensions.cs
@@ -0,0 +1,10 @@
+namespace WheelWizard.Features.Archives;
+
+public static class ArchivesExtensions
+{
+ public static IServiceCollection AddArchives(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ return services;
+ }
+}
diff --git a/WheelWizard/Features/Archives/Domain/DecodedArchive.cs b/WheelWizard/Features/Archives/Domain/DecodedArchive.cs
new file mode 100644
index 00000000..b82ad556
--- /dev/null
+++ b/WheelWizard/Features/Archives/Domain/DecodedArchive.cs
@@ -0,0 +1,3 @@
+namespace WheelWizard.Features.Archives;
+
+public sealed record DecodedArchive(IReadOnlyDictionary Files);
diff --git a/WheelWizard/Features/Archives/Domain/U8Node.cs b/WheelWizard/Features/Archives/Domain/U8Node.cs
new file mode 100644
index 00000000..7d0faa1b
--- /dev/null
+++ b/WheelWizard/Features/Archives/Domain/U8Node.cs
@@ -0,0 +1,3 @@
+namespace WheelWizard.Features.Archives;
+
+public sealed record U8Node(int Type, int NameOffset, int DataOffset, int Size);
diff --git a/WheelWizard/Features/Archives/ISzsArchiveDecoder.cs b/WheelWizard/Features/Archives/ISzsArchiveDecoder.cs
new file mode 100644
index 00000000..e744786a
--- /dev/null
+++ b/WheelWizard/Features/Archives/ISzsArchiveDecoder.cs
@@ -0,0 +1,10 @@
+namespace WheelWizard.Features.Archives;
+
+// normally this would be in the Domain folder but its just 1 sealed record
+
+public interface ISzsArchiveDecoder
+{
+ DecodedArchive? TryDecodeU8Archive(byte[] bytes);
+
+ byte[] DecompressYaz0IfNeeded(byte[] bytes);
+}
diff --git a/WheelWizard/Features/Archives/SzsArchiveDecoder.cs b/WheelWizard/Features/Archives/SzsArchiveDecoder.cs
new file mode 100644
index 00000000..9d897b52
--- /dev/null
+++ b/WheelWizard/Features/Archives/SzsArchiveDecoder.cs
@@ -0,0 +1,149 @@
+using WheelWizard.Helpers;
+
+namespace WheelWizard.Features.Archives;
+
+public sealed class SzsArchiveDecoder : ISzsArchiveDecoder
+{
+ private const uint U8Magic = 0x55aa382d;
+
+ public DecodedArchive? TryDecodeU8Archive(byte[] bytes)
+ {
+ var raw = DecompressYaz0IfNeeded(bytes);
+ if (BigEndianBinaryHelper.BufferToUint32(raw, 0) != U8Magic)
+ return null;
+
+ return ParseU8Archive(raw);
+ }
+
+ public byte[] DecompressYaz0IfNeeded(byte[] bytes)
+ {
+ if (BinaryStringHelper.ReadAscii(bytes, 0, 4) != "Yaz0")
+ return bytes;
+
+ var outputSize = checked((int)BigEndianBinaryHelper.BufferToUint32(bytes, 4));
+ var output = new byte[outputSize];
+ var src = 0x10;
+ var dst = 0;
+ var groupHeader = 0;
+ var bitsRemaining = 0;
+
+ while (dst < output.Length)
+ {
+ if (bitsRemaining == 0)
+ {
+ if (src >= bytes.Length)
+ throw new InvalidDataException("Yaz0 stream ended before decompression finished.");
+ groupHeader = bytes[src++];
+ bitsRemaining = 8;
+ }
+
+ if ((groupHeader & 0x80) != 0)
+ {
+ if (src >= bytes.Length)
+ throw new InvalidDataException("Yaz0 literal chunk is truncated.");
+ output[dst++] = bytes[src++];
+ }
+ else
+ {
+ if (src + 1 >= bytes.Length)
+ throw new InvalidDataException("Yaz0 backreference chunk is truncated.");
+
+ var b1 = bytes[src++];
+ var b2 = bytes[src++];
+ var backOffset = (((b1 & 0x0f) << 8) | b2) + 1;
+ var length = b1 >> 4;
+ if (length == 0)
+ {
+ if (src >= bytes.Length)
+ throw new InvalidDataException("Yaz0 extended backreference is truncated.");
+ length = bytes[src++] + 0x12;
+ }
+ else
+ {
+ length += 2;
+ }
+
+ if (backOffset > dst)
+ throw new InvalidDataException("Yaz0 backreference points before the output buffer.");
+
+ var copySrc = dst - backOffset;
+ for (var index = 0; index < length && dst < output.Length; index++)
+ output[dst++] = output[copySrc++];
+ }
+
+ groupHeader <<= 1;
+ bitsRemaining--;
+ }
+
+ return output;
+ }
+
+ private static DecodedArchive ParseU8Archive(byte[] bytes)
+ {
+ var rootOffset = (int)BigEndianBinaryHelper.BufferToUint32(bytes, 4);
+ var rootNode = ReadU8Node(bytes, rootOffset);
+ var nodeCount = rootNode.Size;
+ var stringTableOffset = rootOffset + nodeCount * 12;
+ var files = new Dictionary(StringComparer.Ordinal);
+
+ if (rootNode.Type != 1)
+ throw new InvalidDataException("U8 root node is not a directory.");
+ if (nodeCount == 0 || stringTableOffset > bytes.Length)
+ throw new InvalidDataException("U8 node table is invalid or truncated.");
+
+ void Walk(int dirIndex, string prefix, int parentEndIndex)
+ {
+ var nodeIndex = dirIndex + 1;
+ var directoryNode = ReadU8Node(bytes, rootOffset + dirIndex * 12);
+ var endIndex = Math.Min(directoryNode.Size, parentEndIndex);
+
+ if (endIndex <= dirIndex)
+ return;
+
+ while (nodeIndex < endIndex)
+ {
+ var nodeOffset = rootOffset + nodeIndex * 12;
+ var node = ReadU8Node(bytes, nodeOffset);
+ var nameOffset = stringTableOffset + node.NameOffset;
+
+ if (nameOffset < stringTableOffset || nameOffset >= bytes.Length)
+ {
+ nodeIndex++;
+ continue;
+ }
+
+ var name = BinaryStringHelper.ReadNullTerminatedAscii(bytes, nameOffset);
+ var logicalPath = string.IsNullOrEmpty(prefix) ? name : $"{prefix}/{name}";
+
+ if (node.Type == 1)
+ {
+ Walk(nodeIndex, logicalPath, endIndex);
+ nodeIndex = Math.Min(Math.Max(node.Size, nodeIndex + 1), endIndex);
+ }
+ else
+ {
+ var endOffset = node.DataOffset + node.Size;
+ if (endOffset <= bytes.Length && endOffset >= node.DataOffset)
+ files[logicalPath] = bytes[node.DataOffset..endOffset];
+ nodeIndex++;
+ }
+ }
+ }
+
+ Walk(0, string.Empty, nodeCount);
+ return new(files);
+ }
+
+ private static U8Node ReadU8Node(byte[] bytes, int offset)
+ {
+ if (offset + 12 > bytes.Length)
+ throw new InvalidDataException("U8 node table is truncated.");
+
+ return new(
+ bytes[offset] == 0 ? 0 : 1,
+ (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3],
+ (int)BigEndianBinaryHelper.BufferToUint32(bytes, offset + 4),
+ (int)BigEndianBinaryHelper.BufferToUint32(bytes, offset + 8)
+ );
+ }
+}
diff --git a/WheelWizard/Features/GameBanana/Domain/GameBananaModPreview.cs b/WheelWizard/Features/GameBanana/Domain/GameBananaModPreview.cs
index 61fb6dfd..ed38982e 100644
--- a/WheelWizard/Features/GameBanana/Domain/GameBananaModPreview.cs
+++ b/WheelWizard/Features/GameBanana/Domain/GameBananaModPreview.cs
@@ -1,5 +1,4 @@
using System.Text.Json.Serialization;
-using WheelWizard.Services;
namespace WheelWizard.GameBanana.Domain;
@@ -56,5 +55,16 @@ public class GameBananaModPreview
public required string ModelName { get; set; }
[JsonIgnore]
- public bool UsesPatches => ModStorageSystemHelper.UsesPatches(Tags);
+ public bool UsesPatches => Tags.Any(tag => IsPatchesTag(tag.Title));
+
+ private static bool IsPatchesTag(string? tagTitle)
+ {
+ var normalizedTitle = tagTitle?.Trim();
+ if (string.IsNullOrWhiteSpace(normalizedTitle))
+ return false;
+
+ var titleOnly = normalizedTitle.Split(':', 2)[0].Trim();
+ return titleOnly.Equals("patch", StringComparison.OrdinalIgnoreCase)
+ || titleOnly.Equals("patches", StringComparison.OrdinalIgnoreCase);
+ }
}
diff --git a/WheelWizard/Features/Mods/ModInstallationService.cs b/WheelWizard/Features/Mods/ModInstallationService.cs
new file mode 100644
index 00000000..694553c2
--- /dev/null
+++ b/WheelWizard/Features/Mods/ModInstallationService.cs
@@ -0,0 +1,237 @@
+using System.Collections.ObjectModel;
+using Avalonia.Threading;
+using SharpCompress.Archives;
+using WheelWizard.Helpers;
+using WheelWizard.Models.Mods;
+using WheelWizard.Services;
+using WheelWizard.Views.Popups.Generic;
+
+namespace WheelWizard.Mods;
+
+public interface IModInstallationService
+{
+ Task>> LoadModsAsync();
+
+ Task SaveModsAsync(ObservableCollection mods);
+
+ bool ContainsModByTitle(IEnumerable mods, string modName);
+
+ Task> InstallModFromFileAsync(
+ string filePath,
+ string givenModName,
+ int priority,
+ string author = "-1",
+ int modID = -1
+ );
+}
+
+public sealed class ModInstallationService : IModInstallationService
+{
+ private readonly string _modsFolderPath = PathManager.ModsFolderPath;
+
+ public async Task>> LoadModsAsync()
+ {
+ var modsFolderResult = FileHelper.EnsureDirectory(_modsFolderPath);
+ if (modsFolderResult.IsFailure)
+ return modsFolderResult.Error;
+
+ var iniFilesResult = FileHelper.FindFilesByExtension(_modsFolderPath, "*.ini");
+ if (iniFilesResult.IsFailure)
+ return iniFilesResult.Error;
+
+ var mods = new ObservableCollection();
+ foreach (var iniFile in iniFilesResult.Value)
+ {
+ var modResult = await LoadModFromIniAsync(iniFile);
+ if (modResult.IsFailure)
+ return modResult.Error;
+
+ if (!string.IsNullOrWhiteSpace(modResult.Value.Title))
+ mods.Add(modResult.Value);
+ }
+
+ return new ObservableCollection(mods.OrderBy(m => m.Priority));
+ }
+
+ public async Task SaveModsAsync(ObservableCollection mods)
+ {
+ foreach (var mod in mods)
+ {
+ var modDirectory = PathManager.GetModDirectoryPath(mod.Title);
+ var directoryResult = FileHelper.EnsureDirectory(modDirectory);
+ if (directoryResult.IsFailure)
+ return directoryResult.Error;
+
+ var iniFilePath = Path.Combine(modDirectory, $"{mod.Title}.ini");
+ var saveResult = await SaveModToIniAsync(mod, iniFilePath);
+ if (saveResult.IsFailure)
+ return saveResult.Error;
+ }
+
+ return Ok();
+ }
+
+ public bool ContainsModByTitle(IEnumerable mods, string modName) =>
+ mods.Any(mod => mod.Title.Equals(modName, StringComparison.OrdinalIgnoreCase));
+
+ private static async Task> LoadModFromIniAsync(string iniFile)
+ {
+ try
+ {
+ return await Mod.LoadFromIniAsync(iniFile);
+ }
+ catch (Exception ex)
+ {
+ return new OperationError { Message = $"Failed to load mod metadata '{iniFile}': {ex.Message}", Exception = ex };
+ }
+ }
+
+ private static async Task SaveModToIniAsync(Mod mod, string iniFilePath)
+ {
+ try
+ {
+ await mod.SaveToIniAsync(iniFilePath);
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ return new OperationError { Message = $"Failed to save mod metadata '{iniFilePath}': {ex.Message}", Exception = ex };
+ }
+ }
+
+ private static OperationResult ExtractModArchive(string file, string destinationDirectory, ProgressWindow progressWindow)
+ {
+ var extension = Path.GetExtension(file).ToLowerInvariant();
+
+ if (!Directory.Exists(destinationDirectory))
+ Directory.CreateDirectory(destinationDirectory);
+
+ var archiveResult = OpenArchive(file, extension);
+ if (archiveResult.IsFailure)
+ return archiveResult.Error;
+
+ try
+ {
+ using var archive = archiveResult.Value;
+ var totalEntries = archive.Entries.Count(entry => !entry.IsDirectory);
+ var processedEntries = 0;
+
+ foreach (var entry in archive.Entries.Where(entry => !entry.IsDirectory))
+ {
+ processedEntries++;
+
+ var progress = (int)(processedEntries / (double)totalEntries * 100);
+ Dispatcher.UIThread.Post(() =>
+ {
+ progressWindow.UpdateProgress(progress);
+ });
+
+ var entryKey = entry.Key ?? string.Empty;
+ var sanitizedKey = string.Join(
+ Path.DirectorySeparatorChar.ToString(),
+ entryKey
+ .Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ .Where(segment => !string.IsNullOrWhiteSpace(segment))
+ );
+
+ var entryDestinationPath = Path.Combine(destinationDirectory, sanitizedKey);
+
+ if (
+ !Path.GetFullPath(entryDestinationPath)
+ .StartsWith(Path.GetFullPath(destinationDirectory), StringComparison.OrdinalIgnoreCase)
+ )
+ {
+ return Fail("Entry is attempting to extract outside of the destination directory.");
+ }
+
+ var directoryPath = Path.GetDirectoryName(entryDestinationPath);
+ if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath))
+ Directory.CreateDirectory(directoryPath);
+
+ using var stream = entry.OpenEntryStream();
+ using var fileStream = File.Create(entryDestinationPath);
+ stream.CopyTo(fileStream);
+ }
+
+ return Ok();
+ }
+ catch (IOException ex)
+ {
+ return new IOException("You already have a mod with this name. " + ex.Message, ex);
+ }
+ catch (Exception ex)
+ {
+ return new OperationError { Message = $"Failed to extract archive file: {ex.Message}", Exception = ex };
+ }
+ }
+
+ private static OperationResult OpenArchive(string filePath, string extension)
+ {
+ if (extension is not (".zip" or ".7z" or ".rar"))
+ return Fail($"Unsupported archive format: {extension}");
+
+ try
+ {
+ return Ok(ArchiveFactory.Open(filePath));
+ }
+ catch (Exception ex)
+ {
+ return new OperationError { Message = $"Failed to open archive file: {ex.Message}", Exception = ex };
+ }
+ }
+
+ public async Task> InstallModFromFileAsync(
+ string filePath,
+ string givenModName,
+ int priority,
+ string author = "-1",
+ int modID = -1
+ )
+ {
+ if (!File.Exists(filePath))
+ return new FileNotFoundException("File not found.", filePath);
+
+ var extension = Path.GetExtension(filePath).ToLowerInvariant();
+ if (extension is not (".zip" or ".7z" or ".rar"))
+ return Fail($"Unsupported file type: {extension}. Only .zip, .7z, and .rar files are supported.");
+
+ if (string.IsNullOrWhiteSpace(givenModName))
+ return Fail("Mod name cannot be empty.");
+
+ ProgressWindow? progressWindow = null;
+ try
+ {
+ progressWindow = new("Installing Mod");
+ progressWindow.SetGoal("Extracting files...");
+ progressWindow.Show();
+
+ var modDirectory = PathManager.GetModDirectoryPath(givenModName);
+ if (!Directory.Exists(modDirectory))
+ Directory.CreateDirectory(modDirectory);
+
+ var extractResult = await Task.Run(() => ExtractModArchive(filePath, modDirectory, progressWindow));
+ if (extractResult.IsFailure)
+ return extractResult.Error;
+
+ var newMod = new Mod
+ {
+ IsEnabled = true,
+ Title = givenModName,
+ Author = author,
+ ModID = modID,
+ Priority = priority,
+ };
+
+ var iniFilePath = Path.Combine(modDirectory, $"{givenModName}.ini");
+ var saveResult = await SaveModToIniAsync(newMod, iniFilePath);
+ if (saveResult.IsFailure)
+ return saveResult.Error;
+
+ return newMod;
+ }
+ finally
+ {
+ progressWindow?.Close();
+ }
+ }
+}
diff --git a/WheelWizard/Features/Mods/ModManager.cs b/WheelWizard/Features/Mods/ModManager.cs
new file mode 100644
index 00000000..928218e8
--- /dev/null
+++ b/WheelWizard/Features/Mods/ModManager.cs
@@ -0,0 +1,659 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO.Compression;
+using Avalonia.Threading;
+using IniParser;
+using Serilog;
+using WheelWizard.Features.Patches;
+using WheelWizard.Helpers;
+using WheelWizard.Models.Mods;
+using WheelWizard.Resources.Languages;
+using WheelWizard.Services;
+using WheelWizard.Views.Popups.Generic;
+
+namespace WheelWizard.Mods;
+
+public interface IModManager : INotifyPropertyChanged
+{
+ ObservableCollection Mods { get; }
+
+ bool IsModInstalled(int modID);
+
+ Task ReloadAsync();
+
+ Task SaveModsAsync();
+
+ Task AddModAsync(Mod mod);
+
+ Task RemoveModAsync(Mod mod);
+
+ Task ImportModFilesAsync(string[] filePaths, string modName);
+
+ Task ToggleAllModsAsync(bool enable);
+
+ OperationResult ValidateModName(string? oldName, string newName);
+
+ OperationResult ValidateRenameModName(string? oldName, string newName);
+
+ Task RenameModAsync(Mod selectedMod, string newTitle);
+
+ Task DeleteModAsync(Mod selectedMod);
+
+ OperationResult OpenModFolder(Mod selectedMod);
+
+ Task InstallModFromFileAsync(string filePath, string givenModName, string author = "-1", int modID = -1);
+
+ Task DeleteModByIdAsync(int modId);
+
+ Task MoveModAsync(Mod movedMod, int newIndex);
+
+ Task SetPriorityAsync(Mod mod, int priority);
+
+ Task DecreasePriorityAsync(Mod mod);
+
+ Task IncreasePriorityAsync(Mod mod);
+
+ int GetLowestActivePriority();
+
+ int GetHighestActivePriority();
+
+ Task MoveModToIndexAsync(Mod mod, int gapIndex);
+}
+
+public sealed class ModManager : IModManager
+{
+ private static readonly char[] _illegalChars = new[] { '.', '/', '~', '\\' };
+ private readonly IModInstallationService _modInstallationService;
+ private readonly IModPatchConversionService _modPatchConversionService;
+
+ private ObservableCollection _mods;
+
+ public ObservableCollection Mods
+ {
+ get => _mods;
+ private set
+ {
+ _mods = value;
+ OnPropertyChanged(nameof(Mods));
+ }
+ }
+
+ private bool _isBatchUpdating;
+
+ public ModManager(IModInstallationService modInstallationService, IModPatchConversionService modPatchConversionService)
+ {
+ _modInstallationService = modInstallationService;
+ _modPatchConversionService = modPatchConversionService;
+ _mods = [];
+ }
+
+ public bool IsModInstalled(int modID)
+ {
+ return Mods.Any(mod => mod.ModID == modID);
+ }
+
+ public async Task ReloadAsync() => await LoadModsAsync();
+
+ private async Task LoadModsAsync()
+ {
+ var newModsResult = await _modInstallationService.LoadModsAsync();
+ if (newModsResult.IsFailure)
+ {
+ return newModsResult;
+ }
+
+ foreach (var mod in Mods)
+ mod.PropertyChanged -= Mod_PropertyChanged;
+
+ foreach (var mod in newModsResult.Value)
+ {
+ _modPatchConversionService.RefreshCompatibility(mod);
+ mod.PropertyChanged += Mod_PropertyChanged;
+ }
+
+ Mods = newModsResult.Value;
+ return Ok();
+ }
+
+ public async Task SaveModsAsync()
+ {
+ var saveResult = await _modInstallationService.SaveModsAsync(Mods);
+ if (saveResult.IsFailure)
+ return saveResult;
+
+ return Ok();
+ }
+
+ public async Task AddModAsync(Mod mod)
+ {
+ if (_modInstallationService.ContainsModByTitle(Mods, mod.Title))
+ return Fail($"Mod with name '{mod.Title}' already exists.");
+
+ mod.PropertyChanged += Mod_PropertyChanged;
+ _modPatchConversionService.RefreshCompatibility(mod);
+ Mods.Add(mod);
+ SortModsByPriority();
+ return await SaveModsAsync();
+ }
+
+ public async Task RemoveModAsync(Mod mod)
+ {
+ if (!Mods.Contains(mod))
+ return Ok();
+
+ Mods.Remove(mod);
+ OnPropertyChanged(nameof(Mods));
+ return await SaveModsAsync();
+ }
+
+ private void Mod_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (_isBatchUpdating)
+ return;
+
+ if (e.PropertyName == nameof(Mod.Priority))
+ {
+ SortModsByPriority();
+ QueueSaveMods();
+ return;
+ }
+
+ if (
+ e.PropertyName is nameof(Mod.IsEnabled) or nameof(Mod.Title)
+ || e.PropertyName == nameof(Mod.Author)
+ || e.PropertyName == nameof(Mod.ModID)
+ || e.PropertyName == nameof(Mod.HasIncompatibleFiles)
+ )
+ {
+ OnPropertyChanged(nameof(Mods));
+ QueueSaveMods();
+ }
+ }
+
+ private void SortModsByPriority()
+ {
+ var sortedMods = new ObservableCollection(Mods.OrderBy(m => m.Priority));
+ Mods = sortedMods;
+ }
+
+ public async Task ImportModFilesAsync(string[] filePaths, string modName)
+ {
+ var validationResult = ValidateModName(null, modName);
+ if (validationResult.IsFailure)
+ return validationResult.Error;
+
+ var tempZipPath = Path.Combine(Path.GetTempPath(), $"{modName}.zip");
+ ProgressWindow? progressWindow = null;
+
+ try
+ {
+ var totalFiles = filePaths.Length;
+ //todo: this is supposed to be backend only, ProgressWindow should not be created here.
+ progressWindow = new ProgressWindow("Combining files")
+ .SetGoal($"Preparing {totalFiles} file{(totalFiles == 1 ? "" : "s")}")
+ .SetExtraText(Common.State_Loading);
+ progressWindow.Show();
+
+ var zipResult = await CreateModArchiveAsync(tempZipPath, filePaths, totalFiles, progressWindow);
+ if (zipResult.IsFailure)
+ return zipResult.Error;
+
+ progressWindow.Close();
+
+ return await InstallModFromFileAsync(tempZipPath, modName, author: "-1", modID: -1);
+ }
+ finally
+ {
+ progressWindow?.Close();
+ var result = FileHelper.TryDeleteFile(tempZipPath);
+ //todo: result should be returned and bubbled up
+ if (result.IsFailure)
+ Log.Warning(result.Error.Exception, "Failed to delete temporary mod archive: {Message}", result.Error.Message);
+ }
+ }
+
+ public async Task ToggleAllModsAsync(bool enable)
+ {
+ _isBatchUpdating = true;
+ try
+ {
+ foreach (var mod in Mods)
+ mod.IsEnabled = enable;
+ }
+ finally
+ {
+ _isBatchUpdating = false;
+ }
+
+ OnPropertyChanged(nameof(Mods));
+ return await SaveModsAsync();
+ }
+
+ public OperationResult ValidateModName(string? oldName, string newName)
+ {
+ newName = newName.Trim();
+ if (string.IsNullOrWhiteSpace(newName))
+ return Fail("Mod name cannot be empty.");
+
+ if (_modInstallationService.ContainsModByTitle(Mods, newName))
+ return Fail("Mod name already exists.");
+
+ if (newName.IndexOfAny(Path.GetInvalidFileNameChars()) != -1)
+ return Fail("Mod name contains illegal characters.");
+
+ if (newName.Any(x => _illegalChars.Contains(x)))
+ return Fail("Mod name contains illegal characters.");
+
+ return Ok();
+ }
+
+ public OperationResult ValidateRenameModName(string? oldName, string newName)
+ {
+ return oldName == newName ? Ok() : ValidateModName(oldName, newName);
+ }
+
+ public async Task RenameModAsync(Mod selectedMod, string newTitle)
+ {
+ var oldTitle = selectedMod.Title;
+ if (oldTitle == newTitle)
+ return Ok();
+
+ var validationResult = ValidateRenameModName(oldTitle, newTitle);
+ if (validationResult.IsFailure)
+ return validationResult.Error;
+
+ var oldDirectoryName = PathManager.GetModDirectoryPath(oldTitle);
+ var newDirectoryName = PathManager.GetModDirectoryPath(newTitle);
+
+ if (!Directory.Exists(oldDirectoryName))
+ return Fail("The mod folder could not be found.");
+
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+
+ var renameResult = await RenameModDirectoryAsync(selectedMod, oldDirectoryName, newDirectoryName, oldTitle, newTitle);
+ if (renameResult.IsFailure)
+ return renameResult.Error;
+
+ return await ReloadAsync();
+ }
+
+ public async Task DeleteModAsync(Mod selectedMod)
+ {
+ var modDirectory = Path.GetFullPath(PathManager.GetModDirectoryPath(selectedMod.Title));
+
+ if (!Directory.Exists(modDirectory))
+ return await RemoveModAsync(selectedMod);
+
+ var deleteResult = DeleteModDirectory(modDirectory);
+ if (deleteResult.IsFailure)
+ return deleteResult.Error;
+
+ return await RemoveModAsync(selectedMod);
+ }
+
+ public OperationResult OpenModFolder(Mod selectedMod)
+ {
+ var modDirectory = PathManager.GetModDirectoryPath(selectedMod.Title);
+ if (Directory.Exists(modDirectory))
+ return OpenFolder(modDirectory);
+
+ return Fail(Phrases.MessageError_NoModFolder_Extra);
+ }
+
+ public async Task InstallModFromFileAsync(string filePath, string givenModName, string author = "-1", int modID = -1)
+ {
+ if (_modInstallationService.ContainsModByTitle(Mods, givenModName))
+ return Fail($"Mod with name '{givenModName}' already exists.");
+
+ var priority = Mods.Count > 0 ? Mods.Max(m => m.Priority) + 1 : 1;
+ var modResult = await _modInstallationService.InstallModFromFileAsync(filePath, givenModName, priority, author, modID);
+ if (modResult.IsFailure)
+ return modResult.Error;
+
+ return await AddModAsync(modResult.Value);
+ }
+
+ public async Task DeleteModByIdAsync(int modId)
+ {
+ var modToDelete = Mods.FirstOrDefault(mod => mod.ModID == modId);
+
+ if (modToDelete == null)
+ return Fail($"No mod found with ID: {modId}");
+
+ return await DeleteModAsync(modToDelete);
+ }
+
+ public async Task MoveModAsync(Mod movedMod, int newIndex)
+ {
+ var oldIndex = Mods.IndexOf(movedMod);
+ if (oldIndex < 0 || newIndex < 0 || newIndex >= Mods.Count)
+ return Ok();
+
+ Mods.Move(oldIndex, newIndex);
+ _isBatchUpdating = true;
+ try
+ {
+ for (var i = 0; i < Mods.Count; i++)
+ {
+ Mods[i].Priority = i;
+ }
+ }
+ finally
+ {
+ _isBatchUpdating = false;
+ }
+
+ OnPropertyChanged(nameof(Mods));
+ return await SaveModsAsync();
+ }
+
+ public async Task SetPriorityAsync(Mod mod, int priority)
+ {
+ if (!Mods.Contains(mod))
+ return Fail("Cannot find mod.");
+
+ _isBatchUpdating = true;
+ try
+ {
+ mod.Priority = priority;
+ }
+ finally
+ {
+ _isBatchUpdating = false;
+ }
+
+ SortModsByPriority();
+ return await SaveModsAsync();
+ }
+
+ private static async Task CreateModArchiveAsync(
+ string tempZipPath,
+ string[] filePaths,
+ int totalFiles,
+ ProgressWindow progressWindow
+ )
+ {
+ try
+ {
+ await Task.Run(() =>
+ {
+ using var zipArchive = ZipFile.Open(tempZipPath, ZipArchiveMode.Create);
+ var processed = 0;
+ foreach (var filePath in filePaths)
+ {
+ var entryName = Path.GetFileName(filePath);
+ zipArchive.CreateEntryFromFile(filePath, entryName, CompressionLevel.Optimal);
+ processed++;
+
+ var progress = (int)(processed / (double)totalFiles * 100);
+ Dispatcher.UIThread.Post(() =>
+ {
+ progressWindow.UpdateProgress(progress);
+ progressWindow.SetExtraText($"{Common.State_Installing} {entryName}");
+ });
+ }
+ });
+
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ return new OperationError { Message = $"Failed to combine files into a mod archive: {ex.Message}", Exception = ex };
+ }
+ }
+
+ //todo: move to FileHelper (or the DI version if that gets made)
+ private static OperationResult OpenFolder(string modDirectory)
+ {
+ try
+ {
+ Process.Start(
+ new ProcessStartInfo
+ {
+ FileName = modDirectory,
+ UseShellExecute = true,
+ Verb = "open",
+ }
+ );
+
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ return new OperationError { Message = $"Failed to open mod folder: {ex.Message}", Exception = ex };
+ }
+ }
+
+ private async Task RenameModDirectoryAsync(
+ Mod selectedMod,
+ string oldDirectoryName,
+ string newDirectoryName,
+ string oldTitle,
+ string newTitle
+ )
+ {
+ var renameResult = RenameDirectoryAndMetadataFile(oldDirectoryName, newDirectoryName, oldTitle, newTitle);
+ if (renameResult.IsFailure)
+ return renameResult;
+
+ _isBatchUpdating = true;
+ try
+ {
+ selectedMod.Title = newTitle;
+ _modPatchConversionService.RefreshCompatibility(selectedMod);
+ }
+ finally
+ {
+ _isBatchUpdating = false;
+ }
+
+ var newIniPath = Path.Combine(newDirectoryName, $"{newTitle}.ini");
+ var saveResult = await SaveRenamedModMetadataAsync(selectedMod, newIniPath);
+ if (saveResult.IsFailure)
+ return saveResult;
+
+ var saveModsResult = await SaveModsAsync();
+ if (saveModsResult.IsFailure)
+ return saveModsResult;
+
+ OnPropertyChanged(nameof(Mods));
+
+ return FileHelper.DeleteDirectoryIfExists(oldDirectoryName);
+ }
+
+ private static OperationResult RenameDirectoryAndMetadataFile(
+ string oldDirectoryName,
+ string newDirectoryName,
+ string oldTitle,
+ string newTitle
+ )
+ {
+ try
+ {
+ Directory.Move(oldDirectoryName, newDirectoryName);
+ var newIniPath = Path.Combine(newDirectoryName, $"{newTitle}.ini");
+ var updatedOldIniPath = Path.Combine(newDirectoryName, $"{oldTitle}.ini");
+ if (File.Exists(updatedOldIniPath))
+ {
+ File.Move(updatedOldIniPath, newIniPath);
+ var parser = new FileIniDataParser();
+ var data = parser.ReadFile(newIniPath);
+ data["Mod"]["Name"] = newTitle;
+ parser.WriteFile(newIniPath, data);
+ }
+
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ return new OperationError { Message = $"Failed to rename mod directory: {ex.Message}", Exception = ex };
+ }
+ }
+
+ private static async Task SaveRenamedModMetadataAsync(Mod selectedMod, string newIniPath)
+ {
+ try
+ {
+ await selectedMod.SaveToIniAsync(newIniPath);
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ return new OperationError { Message = $"Failed to save renamed mod metadata: {ex.Message}", Exception = ex };
+ }
+ }
+
+ private static OperationResult DeleteModDirectory(string modDirectory)
+ {
+ var normalizedModsFolder = Path.GetFullPath(PathManager.ModsFolderPath);
+ if (!modDirectory.StartsWith(normalizedModsFolder, StringComparison.OrdinalIgnoreCase))
+ return Fail("Invalid mod directory.");
+
+ try
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+
+ var di = new DirectoryInfo(modDirectory);
+ di.Attributes &= ~FileAttributes.ReadOnly;
+ foreach (var file in di.EnumerateFiles("*", SearchOption.AllDirectories))
+ file.Attributes &= ~FileAttributes.ReadOnly;
+
+ Directory.Delete(modDirectory, true);
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ return new OperationError { Message = $"Failed to delete mod directory: {ex.Message}", Exception = ex };
+ }
+ }
+
+ private void QueueSaveMods()
+ {
+ _ = SaveModsAndLogAsync();
+ }
+
+ private async Task SaveModsAndLogAsync()
+ {
+ var saveResult = await SaveModsAsync();
+ if (saveResult.IsFailure)
+ Log.Error(saveResult.Error.Exception, "Failed to save mods: {Message}", saveResult.Error.Message);
+ }
+
+ #region PropertyChanged
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void OnPropertyChanged(string propertyName)
+ {
+ PropertyChanged?.Invoke(this, new(propertyName));
+ }
+
+ #endregion
+
+ public async Task DecreasePriorityAsync(Mod mod)
+ {
+ if (Mods.IndexOf(mod) == -1)
+ return Fail("Cannot find the mod to decrease priority.");
+
+ if (mod.Priority == GetLowestActivePriority() || Mods.Count == 1)
+ return Fail("Cannot decrease priority of the first mod.");
+
+ // Find mod with next lower priority value
+ var modAbove = Mods.Where(m => m.Priority < mod.Priority).OrderByDescending(m => m.Priority).FirstOrDefault();
+ if (modAbove == null)
+ return Ok();
+
+ _isBatchUpdating = true;
+ try
+ {
+ (modAbove.Priority, mod.Priority) = (mod.Priority, modAbove.Priority);
+ }
+ finally
+ {
+ _isBatchUpdating = false;
+ }
+
+ SortModsByPriority();
+ return await SaveModsAsync();
+ }
+
+ public async Task IncreasePriorityAsync(Mod mod)
+ {
+ if (Mods.IndexOf(mod) == -1)
+ return Fail("Cannot find mod.");
+
+ if (mod.Priority == GetHighestActivePriority() || Mods.Count == 1)
+ return Fail("Cannot increase priority of the last mod.");
+
+ // Find mod with next higher priority value
+ var modBelow = Mods.Where(m => m.Priority > mod.Priority).OrderBy(m => m.Priority).FirstOrDefault();
+
+ if (modBelow == null)
+ return Ok(); // Should not happen but just in case
+
+ // Swap priorities
+ _isBatchUpdating = true;
+ try
+ {
+ (modBelow.Priority, mod.Priority) = (mod.Priority, modBelow.Priority);
+ }
+ finally
+ {
+ _isBatchUpdating = false;
+ }
+
+ SortModsByPriority();
+ return await SaveModsAsync();
+ }
+
+ public int GetLowestActivePriority() => Mods.Count == 0 ? 0 : Mods.Min(m => m.Priority);
+
+ public int GetHighestActivePriority() => Mods.Count == 0 ? 0 : Mods.Max(m => m.Priority);
+
+ ///
+ /// Moves a mod to a new position in the list using gap-based indexing.
+ /// Gap 0 = before first item, gap Count = after last item.
+ ///
+ public async Task MoveModToIndexAsync(Mod mod, int gapIndex)
+ {
+ var sortedMods = Mods.OrderBy(m => m.Priority).ToList();
+ var currentIndex = sortedMods.IndexOf(mod);
+
+ if (currentIndex == -1)
+ return Ok();
+
+ // Convert gap index to target index after removal
+ int targetIndex;
+ if (gapIndex <= currentIndex)
+ targetIndex = gapIndex;
+ else if (gapIndex > currentIndex + 1)
+ targetIndex = gapIndex - 1;
+ else
+ return Ok(); // No change needed (dropped in same position)
+
+ targetIndex = Math.Clamp(targetIndex, 0, sortedMods.Count - 1);
+
+ if (currentIndex == targetIndex)
+ return Ok();
+
+ _isBatchUpdating = true;
+ try
+ {
+ sortedMods.RemoveAt(currentIndex);
+ sortedMods.Insert(targetIndex, mod);
+
+ for (var i = 0; i < sortedMods.Count; i++)
+ sortedMods[i].Priority = i;
+ }
+ finally
+ {
+ _isBatchUpdating = false;
+ }
+
+ SortModsByPriority();
+ return await SaveModsAsync();
+ }
+}
diff --git a/WheelWizard/Features/Mods/ModsExtensions.cs b/WheelWizard/Features/Mods/ModsExtensions.cs
new file mode 100644
index 00000000..a066cbba
--- /dev/null
+++ b/WheelWizard/Features/Mods/ModsExtensions.cs
@@ -0,0 +1,12 @@
+namespace WheelWizard.Mods;
+
+public static class ModsExtensions
+{
+ public static IServiceCollection AddMods(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ return services;
+ }
+}
diff --git a/WheelWizard/Features/Mods/ModsLaunchService.cs b/WheelWizard/Features/Mods/ModsLaunchService.cs
new file mode 100644
index 00000000..d5def49c
--- /dev/null
+++ b/WheelWizard/Features/Mods/ModsLaunchService.cs
@@ -0,0 +1,138 @@
+using Avalonia.Threading;
+using WheelWizard.Helpers;
+using WheelWizard.Models.Mods;
+using WheelWizard.Resources.Languages;
+using WheelWizard.Services;
+using WheelWizard.Views.Popups.Generic;
+
+namespace WheelWizard.Mods;
+
+public interface IModsLaunchService
+{
+ bool ShouldAskToClearTargetFolder(string targetFolderPath);
+
+ Task PrepareModsForLaunch(string targetFolderPath, bool clearTargetFolderWhenNoEnabledMods = false);
+}
+
+public sealed class ModsLaunchService(IModManager modManager) : IModsLaunchService
+{
+ private static readonly string ModsFolderPath = PathManager.ModsFolderPath;
+
+ public async Task PrepareModsForLaunch(string targetFolderPath, bool clearTargetFolderWhenNoEnabledMods = false)
+ {
+ var mods = modManager.Mods.Where(mod => mod.IsEnabled).ToArray();
+ if (mods.Length == 0)
+ {
+ if (clearTargetFolderWhenNoEnabledMods && ShouldAskToClearTargetFolder(targetFolderPath))
+ return FileHelper.DeleteDirectoryIfExists(targetFolderPath);
+
+ return Ok();
+ }
+ var reversedMods = modManager.Mods.Reverse().ToArray();
+
+ // Build the final file list
+ var finalFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); // relative path -> source file path
+
+ foreach (var mod in reversedMods)
+ {
+ if (!mod.IsEnabled)
+ continue;
+
+ var modFolder = Path.Combine(ModsFolderPath, mod.Title);
+ if (!Directory.Exists(modFolder))
+ continue;
+
+ foreach (var file in Directory.GetFiles(modFolder, "*", SearchOption.AllDirectories))
+ {
+ if (!ShouldCopyFile(mod, file))
+ continue;
+
+ var relativePath = Path.GetFileName(file);
+ // Since higher priority mods overwrite lower ones, we can overwrite entries in the dictionary
+ finalFiles[relativePath] = file;
+ }
+ }
+
+ Directory.CreateDirectory(targetFolderPath);
+
+ var totalFiles = finalFiles.Count;
+ var progressWindow = new ProgressWindow(Phrases.Progress_InstallingMods).SetGoal(
+ Humanizer.ReplaceDynamic(Phrases.Progress_InstallingModsCount, totalFiles)!
+ );
+ progressWindow.Show();
+
+ var copyResult = await Task.Run(() => CopyFinalFiles(targetFolderPath, finalFiles, progressWindow));
+
+ progressWindow.Close();
+ return copyResult;
+ }
+
+ public bool ShouldAskToClearTargetFolder(string targetFolderPath) =>
+ !modManager.Mods.Any(mod => mod.IsEnabled)
+ && Directory.Exists(targetFolderPath)
+ && Directory.EnumerateFiles(targetFolderPath).Any();
+
+ private static OperationResult CopyFinalFiles(
+ string targetFolderPath,
+ Dictionary finalFiles,
+ ProgressWindow progressWindow
+ )
+ {
+ try
+ {
+ var totalFiles = finalFiles.Count;
+ var processedFiles = 0;
+ if (Directory.Exists(targetFolderPath))
+ {
+ var files = Directory.GetFiles(targetFolderPath, "*.*", SearchOption.TopDirectoryOnly);
+ foreach (var file in files)
+ {
+ var relativePath = Path.GetFileName(file);
+ if (!finalFiles.ContainsKey(relativePath))
+ File.Delete(file);
+ }
+ }
+
+ foreach (var kvp in finalFiles)
+ {
+ var relativePath = kvp.Key;
+ var sourceFile = kvp.Value;
+ var destinationFile = Path.Combine(targetFolderPath, relativePath);
+
+ processedFiles++;
+ var progress = (int)(processedFiles / (double)totalFiles * 100);
+ Dispatcher.UIThread.Post(() =>
+ {
+ progressWindow.UpdateProgress(progress);
+ progressWindow.SetExtraText($"{Common.State_Installing} {relativePath}");
+ });
+
+ if (File.Exists(destinationFile))
+ {
+ var sourceInfo = new FileInfo(sourceFile);
+ var destInfo = new FileInfo(destinationFile);
+
+ if (sourceInfo.Length == destInfo.Length && sourceInfo.LastWriteTimeUtc == destInfo.LastWriteTimeUtc)
+ continue;
+ }
+
+ File.Copy(sourceFile, destinationFile, true);
+ }
+
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ return new OperationError { Message = $"Failed to prepare mods for launch: {ex.Message}", Exception = ex };
+ }
+ }
+
+ private static bool ShouldCopyFile(Mod mod, string filePath)
+ {
+ var modMetadataFile = Path.Combine(ModsFolderPath, mod.Title, $"{mod.Title}.ini");
+ if (Path.GetFullPath(filePath).Equals(Path.GetFullPath(modMetadataFile), StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return true;
+ }
+}
diff --git a/WheelWizard/Features/Patches/BrsarPatchConverter.cs b/WheelWizard/Features/Patches/BrsarPatchConverter.cs
new file mode 100644
index 00000000..33a892f6
--- /dev/null
+++ b/WheelWizard/Features/Patches/BrsarPatchConverter.cs
@@ -0,0 +1,526 @@
+using System.Text;
+using WheelWizard.Helpers;
+using static WheelWizard.Features.Patches.PatchConversionHelpers;
+
+namespace WheelWizard.Features.Patches;
+
+public static class BrsarPatchConverter
+{
+ private const int HeaderScanWindow = 0x4000;
+ private static readonly Encoding Utf8 = Encoding.UTF8;
+
+ public static PatchConversionAnalysis AnalyzeAgainstBaseline(BaselineEntry baseline, string moddedName, byte[] moddedBytes)
+ {
+ if (!string.Equals(baseline.Kind, "brsar", StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("The selected baseline is not a BRSAR file.");
+
+ var warnings = new List();
+ var skipped = new List();
+ var moddedParse = ParseBrsar(moddedBytes);
+ var baselineEntries = BuildBaselineEntries(baseline);
+ var allFileIds = baselineEntries.Keys.Concat(moddedParse.Entries.Keys).Distinct().Order().ToArray();
+ var entries = new List();
+
+ foreach (var fileId in allFileIds)
+ {
+ baselineEntries.TryGetValue(fileId, out var baselineEntry);
+ moddedParse.Entries.TryGetValue(fileId, out var moddedEntry);
+
+ if (baselineEntry == null || moddedEntry == null)
+ continue;
+
+ var moddedKind = GetBaselineKindCode(moddedEntry.Kind);
+ var moddedMagic = moddedEntry.Magic;
+ var moddedHash = HashBytes64(moddedEntry.CompareBytes);
+ if (
+ baselineEntry.Kind == moddedKind
+ && baselineEntry.Magic == moddedMagic
+ && baselineEntry.Size == moddedEntry.CompareBytes.Length
+ && baselineEntry.Hash == moddedHash
+ )
+ {
+ continue;
+ }
+
+ if (moddedEntry is { Kind: BrsarEntryKind.Supported, ExportBytes: not null })
+ {
+ var extension = GetBrsarEntryExtension(moddedEntry.Magic);
+ entries.Add(
+ new($"{fileId}{extension}", $"revo_kart.brsar:{fileId}", moddedEntry.ExportBytes.ToArray(), moddedEntry.Detail)
+ );
+ continue;
+ }
+
+ if (moddedEntry.Kind == BrsarEntryKind.Unsupported)
+ {
+ skipped.Add(
+ $"BRSAR fileId {fileId} changed, but it resolves to {moddedEntry.Magic} and Pulsar only supports RBNK/RSEQ/RWSD loose overrides."
+ );
+ continue;
+ }
+
+ if (moddedEntry.Kind == BrsarEntryKind.External)
+ {
+ skipped.Add($"BRSAR fileId {fileId} is an external file reference and cannot be exported.");
+ continue;
+ }
+
+ skipped.Add($"BRSAR fileId {fileId} could not be resolved to a supported embedded sound file.");
+ }
+
+ var unsupportedSummary = SummarizeUnsupportedBrsarCounts(moddedParse.UnsupportedCounts);
+ if (unsupportedSummary != null)
+ warnings.Add($"Only RBNK/RSEQ/RWSD entries can be exported. The scanned archive also contains {unsupportedSummary}.");
+ if (moddedParse.ExternalCount > 0)
+ warnings.Add($"{moddedParse.ExternalCount} BRSAR file entries are external references and cannot be exported.");
+ if (moddedParse.UnresolvedCount > 0)
+ warnings.Add($"{moddedParse.UnresolvedCount} embedded BRSAR file entries could not be resolved confidently.");
+ if (entries.Count == 0 && skipped.Count == 0)
+ warnings.Add("No supported RBNK/RSEQ/RWSD differences were found against the built-in game baseline.");
+
+ return new()
+ {
+ CleanName = baseline.RelativePath,
+ ModdedName = moddedName,
+ Mode = "brsar",
+ Entries = entries.OrderBy(entry => entry.ExportPath, StringComparer.OrdinalIgnoreCase).ToArray(),
+ Warnings = warnings,
+ Skipped = skipped,
+ };
+ }
+
+ public static int EstimateDifference(BaselineEntry baseline, byte[] moddedBytes)
+ {
+ BrsarParseResult moddedParse;
+
+ try
+ {
+ moddedParse = ParseBrsar(moddedBytes);
+ }
+ catch
+ {
+ return 1;
+ }
+
+ var baselineEntries = BuildBaselineEntries(baseline);
+ var allFileIds = baselineEntries.Keys.Concat(moddedParse.Entries.Keys).Distinct();
+ var differences = 0;
+
+ foreach (var fileId in allFileIds)
+ {
+ baselineEntries.TryGetValue(fileId, out var baselineEntry);
+ moddedParse.Entries.TryGetValue(fileId, out var moddedEntry);
+ if (baselineEntry == null || moddedEntry == null)
+ continue;
+
+ var moddedKind = GetBaselineKindCode(moddedEntry.Kind);
+ var moddedMagic = moddedEntry.Magic;
+ var moddedHash = HashBytes64(moddedEntry.CompareBytes);
+ if (
+ baselineEntry.Kind != moddedKind
+ || baselineEntry.Magic != moddedMagic
+ || baselineEntry.Size != moddedEntry.CompareBytes.Length
+ || baselineEntry.Hash != moddedHash
+ )
+ {
+ differences++;
+ }
+ }
+
+ return differences;
+ }
+
+ private static Dictionary BuildBaselineEntries(BaselineEntry baseline)
+ {
+ var entries = new Dictionary();
+ if (baseline.Entries == null)
+ return entries;
+
+ foreach (var entry in baseline.Entries)
+ {
+ if (entry.Length < 5)
+ continue;
+
+ if (!TryGetJsonElement(entry[0], out var fileIdElement) || !fileIdElement.TryGetInt32(out var fileId))
+ continue;
+
+ var kind = ReadJsonString(entry[1]);
+ var magic = ReadJsonString(entry[2]);
+
+ if (!TryGetJsonElement(entry[3], out var sizeElement) || !sizeElement.TryGetInt32(out var size))
+ continue;
+
+ var hash = ReadJsonString(entry[4]);
+
+ if (!string.IsNullOrEmpty(kind) && !string.IsNullOrEmpty(hash))
+ entries[fileId] = new(kind, magic, size, hash);
+ }
+
+ return entries;
+ }
+
+ private static BrsarParseResult ParseBrsar(byte[] bytes)
+ {
+ if (BinaryStringHelper.ReadAscii(bytes, 0, 4) != "RSAR")
+ throw new InvalidDataException("The selected BRSAR file does not start with the RSAR header.");
+
+ var infoOffset = BigEndianBinaryHelper.BufferToInt32(bytes, 0x18);
+ var infoBase = infoOffset + 0x08;
+ var fileTableOffset = ResolveDataRef(bytes, infoBase + 0x18, infoBase);
+ var groupTableOffset = ResolveDataRef(bytes, infoBase + 0x20, infoBase);
+
+ if (fileTableOffset == null || groupTableOffset == null)
+ throw new InvalidDataException("The BRSAR file is missing the INFO file/group tables.");
+
+ var (knownHeaders, rwarHeaders) = ScanBrsarHeaders(bytes);
+ var groupEntryOffsets = ParseReferenceTable(bytes, groupTableOffset.Value, infoBase);
+ var groups = groupEntryOffsets.Select((offset, groupIndex) => ParseGroupInfo(bytes, offset, infoBase, groupIndex)).ToArray();
+ var fileEntryOffsets = ParseReferenceTable(bytes, fileTableOffset.Value, infoBase);
+ var entries = new Dictionary();
+ var unsupportedCounts = new Dictionary(StringComparer.Ordinal);
+ var externalCount = 0;
+ var unresolvedCount = 0;
+
+ for (var fileId = 0; fileId < fileEntryOffsets.Count; fileId++)
+ {
+ var entryOffset = fileEntryOffsets[fileId];
+ var declaredFileSize = BigEndianBinaryHelper.BufferToInt32(bytes, entryOffset);
+ var declaredWaveSize = BigEndianBinaryHelper.BufferToInt32(bytes, entryOffset + 0x04);
+ var externalNameRef = ResolveDataRef(bytes, entryOffset + 0x0c, infoBase);
+ if (externalNameRef != null)
+ {
+ externalCount++;
+ var externalPath = BinaryStringHelper.ReadNullTerminatedAscii(bytes, externalNameRef.Value);
+ entries[fileId] = new(
+ BrsarEntryKind.External,
+ Utf8.GetBytes(externalPath),
+ null,
+ null,
+ externalPath.Length > 0 ? $"External reference: {externalPath}" : "External reference"
+ );
+ continue;
+ }
+
+ var positionTableOffset = ResolveDataRef(bytes, entryOffset + 0x14, infoBase);
+ if (positionTableOffset == null)
+ {
+ unresolvedCount++;
+ entries[fileId] = BrsarEntry.Unresolved("Missing file-position table");
+ continue;
+ }
+
+ var positions = ParseReferenceTable(bytes, positionTableOffset.Value, infoBase);
+ if (positions.Count == 0)
+ {
+ unresolvedCount++;
+ entries[fileId] = BrsarEntry.Unresolved("Empty file-position table");
+ continue;
+ }
+
+ var firstPosition = positions[0];
+ var groupIndex = BigEndianBinaryHelper.BufferToInt32(bytes, firstPosition);
+ var itemIndex = BigEndianBinaryHelper.BufferToInt32(bytes, firstPosition + 4);
+ var group = groupIndex >= 0 && groupIndex < groups.Length ? groups[groupIndex] : null;
+
+ if (group == null || itemIndex < 0 || itemIndex >= group.ItemOffsets.Count)
+ {
+ unresolvedCount++;
+ entries[fileId] = BrsarEntry.Unresolved("Invalid group/item reference");
+ continue;
+ }
+
+ var itemOffset = group.ItemOffsets[itemIndex];
+ var fileRelativeOffset = BigEndianBinaryHelper.BufferToInt32(bytes, itemOffset + 0x04);
+ var audioRelativeOffset = BigEndianBinaryHelper.BufferToInt32(bytes, itemOffset + 0x0c);
+ var mainGuess = group.FileDataOffset + fileRelativeOffset;
+ var waveGuess = group.AudioDataOffset + audioRelativeOffset;
+ var mainHeader = FindNearestBrsarHeader(knownHeaders, mainGuess, declaredFileSize);
+
+ if (mainHeader == null)
+ {
+ unresolvedCount++;
+ entries[fileId] = BrsarEntry.Unresolved("No nearby embedded sound header was found");
+ continue;
+ }
+
+ var mainBytes = SliceBytes(bytes, mainHeader.Offset, mainHeader.Offset + mainHeader.Size);
+ if (!IsSupportedBrsarMagic(mainHeader.Magic))
+ {
+ unsupportedCounts[mainHeader.Magic] = unsupportedCounts.GetValueOrDefault(mainHeader.Magic) + 1;
+ entries[fileId] = new(BrsarEntryKind.Unsupported, mainBytes, null, mainHeader.Magic, $"{mainHeader.Magic} entry {fileId}");
+ continue;
+ }
+
+ var exportBytes = mainBytes;
+ var detail = $"{mainHeader.Magic} entry {fileId}";
+ var waveHeader = FindNearestRwarHeader(rwarHeaders, waveGuess, declaredWaveSize, mainHeader.Offset - mainGuess);
+ if (declaredWaveSize > 0 && waveHeader != null)
+ {
+ var waveBytes = SliceBytes(bytes, waveHeader.Offset, waveHeader.Offset + waveHeader.Size);
+ exportBytes = JoinWithAlignment(mainBytes, waveBytes, 0x20);
+ detail += " + RWAR";
+ }
+
+ entries[fileId] = new(BrsarEntryKind.Supported, exportBytes, exportBytes, mainHeader.Magic, detail);
+ }
+
+ return new(entries, externalCount, unresolvedCount, unsupportedCounts);
+ }
+
+ private static GroupInfo ParseGroupInfo(byte[] bytes, int entryOffset, int infoOffset, int groupIndex)
+ {
+ var itemTableOffset = ResolveDataRef(bytes, entryOffset + 0x20, infoOffset);
+ if (itemTableOffset == null)
+ throw new InvalidDataException($"Group {groupIndex} does not contain an item table.");
+
+ return new(
+ BigEndianBinaryHelper.BufferToInt32(bytes, entryOffset + 0x10),
+ BigEndianBinaryHelper.BufferToInt32(bytes, entryOffset + 0x18),
+ ParseReferenceTable(bytes, itemTableOffset.Value, infoOffset)
+ );
+ }
+
+ private static List ParseReferenceTable(byte[] bytes, int tableOffset, int baseAddress)
+ {
+ var count = BigEndianBinaryHelper.BufferToInt32(bytes, tableOffset);
+ var offsets = new List();
+
+ for (var index = 0; index < count; index++)
+ {
+ var target = ResolveDataRef(bytes, tableOffset + 4 + index * 8, baseAddress);
+ if (target != null)
+ offsets.Add(target.Value);
+ }
+
+ return offsets;
+ }
+
+ private static int? ResolveDataRef(byte[] bytes, int refOffset, int baseAddress)
+ {
+ if (refOffset < 0 || refOffset + 8 > bytes.Length)
+ return null;
+
+ var refType = bytes[refOffset];
+ var value = BigEndianBinaryHelper.BufferToInt32(bytes, refOffset + 4);
+ if (value == 0)
+ return null;
+ if (refType == 0)
+ return value;
+ if (refType == 1)
+ return baseAddress + value;
+
+ throw new InvalidDataException($"Unsupported BRSAR data reference type {refType}.");
+ }
+
+ private static (List KnownHeaders, List RwarHeaders) ScanBrsarHeaders(byte[] bytes)
+ {
+ var knownHeaders = new List();
+ var rwarHeaders = new List();
+
+ for (var offset = 0; offset + 0x10 <= bytes.Length; offset++)
+ {
+ if (bytes[offset] != 0x52)
+ continue;
+
+ var magic = BinaryStringHelper.ReadAscii(bytes, offset, 4);
+ if (magic is not ("RWSD" or "RBNK" or "RWAV" or "RSEQ" or "RSTM" or "RWAR"))
+ continue;
+
+ var size = BigEndianBinaryHelper.BufferToInt32(bytes, offset + 0x08);
+ if (size < 0x20 || offset + size > bytes.Length)
+ continue;
+
+ if (magic == "RWAR")
+ rwarHeaders.Add(new(offset, size));
+ else
+ knownHeaders.Add(new(magic, offset, size));
+ }
+
+ return (knownHeaders, rwarHeaders);
+ }
+
+ private static KnownBrsarHeader? FindNearestBrsarHeader(List headers, int guessOffset, int expectedSize)
+ {
+ if (headers.Count == 0)
+ return null;
+
+ KnownBrsarHeader? bestHeader = null;
+ var bestScore = double.PositiveInfinity;
+ var startIndex = LowerBoundHeaderOffset(headers, guessOffset);
+
+ for (var left = startIndex - 1; left >= 0; left--)
+ {
+ var header = headers[left];
+ var distance = guessOffset - header.Offset;
+ if (distance > HeaderScanWindow)
+ break;
+
+ var score = distance + Math.Min(Math.Abs(header.Size - expectedSize), 0x10000) / 16.0;
+ if (score < bestScore)
+ {
+ bestScore = score;
+ bestHeader = header;
+ }
+ }
+
+ for (var right = startIndex; right < headers.Count; right++)
+ {
+ var header = headers[right];
+ var distance = header.Offset - guessOffset;
+ if (distance > HeaderScanWindow)
+ break;
+
+ var score = distance + Math.Min(Math.Abs(header.Size - expectedSize), 0x10000) / 16.0;
+ if (score < bestScore)
+ {
+ bestScore = score;
+ bestHeader = header;
+ }
+ }
+
+ return bestHeader;
+ }
+
+ private static RwarHeader? FindNearestRwarHeader(List headers, int guessOffset, int expectedSize, int preferredDelta)
+ {
+ if (headers.Count == 0)
+ return null;
+
+ RwarHeader? bestHeader = null;
+ var bestScore = double.PositiveInfinity;
+ var startIndex = LowerBoundHeaderOffset(headers, guessOffset);
+
+ for (var left = startIndex - 1; left >= 0; left--)
+ {
+ var header = headers[left];
+ var distance = guessOffset - header.Offset;
+ if (distance > HeaderScanWindow)
+ break;
+
+ var relativePenalty = Math.Abs((header.Offset - guessOffset) - preferredDelta) / 2.0;
+ var sizePenalty = Math.Min(Math.Abs(header.Size - expectedSize), 0x20000) / 32.0;
+ var score = distance + relativePenalty + sizePenalty;
+ if (score < bestScore)
+ {
+ bestScore = score;
+ bestHeader = header;
+ }
+ }
+
+ for (var right = startIndex; right < headers.Count; right++)
+ {
+ var header = headers[right];
+ var distance = header.Offset - guessOffset;
+ if (distance > HeaderScanWindow)
+ break;
+
+ var relativePenalty = Math.Abs((header.Offset - guessOffset) - preferredDelta) / 2.0;
+ var sizePenalty = Math.Min(Math.Abs(header.Size - expectedSize), 0x20000) / 32.0;
+ var score = distance + relativePenalty + sizePenalty;
+ if (score < bestScore)
+ {
+ bestScore = score;
+ bestHeader = header;
+ }
+ }
+
+ return bestHeader;
+ }
+
+ private static int LowerBoundHeaderOffset(List headers, int guessOffset)
+ where T : IHeaderOffset
+ {
+ var low = 0;
+ var high = headers.Count;
+
+ while (low < high)
+ {
+ var middle = (low + high) >> 1;
+ if (headers[middle].Offset < guessOffset)
+ low = middle + 1;
+ else
+ high = middle;
+ }
+
+ return low;
+ }
+
+ private static string? SummarizeUnsupportedBrsarCounts(Dictionary counts)
+ {
+ var parts = counts
+ .Where(entry => entry.Value > 0)
+ .OrderByDescending(entry => entry.Value)
+ .Select(entry => $"{entry.Value} {entry.Key}")
+ .ToArray();
+
+ return parts.Length == 0 ? null : string.Join(", ", parts);
+ }
+
+ private static string GetBaselineKindCode(BrsarEntryKind kind) =>
+ kind switch
+ {
+ BrsarEntryKind.Supported => "s",
+ BrsarEntryKind.Unsupported => "u",
+ BrsarEntryKind.External => "e",
+ _ => "n",
+ };
+
+ private static bool IsSupportedBrsarMagic(string magic) => magic is "RBNK" or "RSEQ" or "RWSD";
+
+ private static string GetBrsarEntryExtension(string? magic) =>
+ magic switch
+ {
+ "RBNK" => ".brbnk",
+ "RSEQ" => ".brseq",
+ "RWSD" => ".brwsd",
+ _ => throw new InvalidOperationException("Cannot export a BRSAR entry without a supported magic."),
+ };
+
+ private static byte[] JoinWithAlignment(byte[] first, byte[] second, int alignment)
+ {
+ var padding = (alignment - (first.Length % alignment)) % alignment;
+ var output = new byte[first.Length + padding + second.Length];
+ Buffer.BlockCopy(first, 0, output, 0, first.Length);
+ Buffer.BlockCopy(second, 0, output, first.Length + padding, second.Length);
+ return output;
+ }
+
+ private static byte[] SliceBytes(byte[] bytes, int start, int end)
+ {
+ if (start < 0 || end < start || end > bytes.Length)
+ throw new InvalidDataException("Tried to read outside the selected file.");
+
+ return bytes[start..end];
+ }
+
+ private enum BrsarEntryKind
+ {
+ Supported,
+ Unsupported,
+ External,
+ Unresolved,
+ }
+
+ private sealed record BrsarParseResult(
+ Dictionary Entries,
+ int ExternalCount,
+ int UnresolvedCount,
+ Dictionary UnsupportedCounts
+ );
+
+ private sealed record BrsarEntry(BrsarEntryKind Kind, byte[] CompareBytes, byte[]? ExportBytes, string? Magic, string Detail)
+ {
+ public static BrsarEntry Unresolved(string detail) => new(BrsarEntryKind.Unresolved, [], null, null, detail);
+ }
+
+ private sealed record GroupInfo(int FileDataOffset, int AudioDataOffset, List ItemOffsets);
+
+ private interface IHeaderOffset
+ {
+ int Offset { get; }
+ }
+
+ private sealed record KnownBrsarHeader(string Magic, int Offset, int Size) : IHeaderOffset;
+
+ private sealed record RwarHeader(int Offset, int Size) : IHeaderOffset;
+}
diff --git a/WheelWizard/Features/Patches/Domain/GameBaselineModels.cs b/WheelWizard/Features/Patches/Domain/GameBaselineModels.cs
new file mode 100644
index 00000000..4cd351a4
--- /dev/null
+++ b/WheelWizard/Features/Patches/Domain/GameBaselineModels.cs
@@ -0,0 +1,81 @@
+using System.Text.Json.Serialization;
+
+namespace WheelWizard.Features.Patches;
+
+public sealed class GameBaselineIndex
+{
+ [JsonPropertyName("entriesByName")]
+ public Dictionary EntriesByName { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+}
+
+public sealed class BaselineCandidate
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("relativePath")]
+ public string RelativePath { get; set; } = string.Empty;
+
+ [JsonPropertyName("kind")]
+ public string Kind { get; set; } = string.Empty;
+
+ [JsonPropertyName("region")]
+ public string? Region { get; set; }
+}
+
+public sealed class GameBaselineData
+{
+ [JsonPropertyName("entries")]
+ public Dictionary Entries { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+}
+
+public sealed class BaselineEntry
+{
+ [JsonPropertyName("kind")]
+ public string Kind { get; set; } = string.Empty;
+
+ [JsonPropertyName("mode")]
+ public string? Mode { get; set; }
+
+ [JsonPropertyName("relativePath")]
+ public string RelativePath { get; set; } = string.Empty;
+
+ [JsonPropertyName("archiveTag")]
+ public string ArchiveTag { get; set; } = string.Empty;
+
+ [JsonPropertyName("wholeFileSize")]
+ public int WholeFileSize { get; set; }
+
+ [JsonPropertyName("wholeFileHash")]
+ public string WholeFileHash { get; set; } = string.Empty;
+
+ [JsonPropertyName("members")]
+ public List