From fee0f77971ab414b9d4cad3c305823b26f4a1da2 Mon Sep 17 00:00:00 2001
From: UlisseWolf <88635118+UlisseWolf@users.noreply.github.com>
Date: Mon, 31 Aug 2026 21:57:59 +0200
Subject: [PATCH] Update 2.0
---
.github/workflows/ci.yml | 33 ++
.github/workflows/release.yml | 178 ++++++++
App.axaml | 111 +++++
App.axaml.cs | 23 +
App.xaml | 6 -
App.xaml.cs | 7 -
Dialogs/MessageDialog.axaml | 20 +
Dialogs/MessageDialog.axaml.cs | 60 +++
LogEntryView.cs | 4 +-
MainWindow.axaml | 136 ++++++
MainWindow.axaml.cs | 609 +++++++++++++++++++++++++++
MainWindow.xaml | 312 --------------
MainWindow.xaml.cs | 442 -------------------
Models/LogColor.cs | 2 +-
Models/Sc4pacExplicitPackages.cs | 15 +
Models/TgiKey.cs | 6 +-
Program.cs | 20 +
README.md | 135 +++++-
SC4ModMigrationAssistant.csproj | 31 +-
Services/CatalogDatabaseService.cs | 11 +-
Services/DbpfScanService.cs | 35 +-
Services/Sc4pacLookupService.cs | 25 +-
Services/Sc4pacPluginsFileService.cs | 157 +++++++
23 files changed, 1562 insertions(+), 816 deletions(-)
create mode 100644 .github/workflows/ci.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 App.axaml
create mode 100644 App.axaml.cs
delete mode 100644 App.xaml
delete mode 100644 App.xaml.cs
create mode 100644 Dialogs/MessageDialog.axaml
create mode 100644 Dialogs/MessageDialog.axaml.cs
create mode 100644 MainWindow.axaml
create mode 100644 MainWindow.axaml.cs
delete mode 100644 MainWindow.xaml
delete mode 100644 MainWindow.xaml.cs
create mode 100644 Models/Sc4pacExplicitPackages.cs
create mode 100644 Program.cs
create mode 100644 Services/Sc4pacPluginsFileService.cs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f28b299
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,33 @@
+name: CI
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, macos-latest, ubuntu-latest]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Restore
+ run: dotnet restore SC4ModMigrationAssistant.csproj
+
+ - name: Build
+ run: dotnet build SC4ModMigrationAssistant.csproj -c Release --no-restore
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..470c623
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,178 @@
+name: Release
+
+on:
+ push:
+ branches: [master]
+ tags: ["v*.*.*"]
+
+permissions:
+ contents: write
+
+jobs:
+ prepare:
+ name: Prepare version
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.set.outputs.version }}
+ file_version: ${{ steps.set.outputs.file_version }}
+ release_tag: ${{ steps.set.outputs.release_tag }}
+ prerelease: ${{ steps.set.outputs.prerelease }}
+
+ steps:
+ - name: Determine version and release tag
+ id: set
+ shell: bash
+ run: |
+ if [[ "${{ github.ref_type }}" == "tag" ]]; then
+ TAG="${{ github.ref_name }}"
+ if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Release tags must match vMAJOR.MINOR.PATCH, for example v0.4.0. Got: $TAG"
+ exit 1
+ fi
+ echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
+ echo "file_version=${TAG#v}" >> "$GITHUB_OUTPUT"
+ echo "release_tag=$TAG" >> "$GITHUB_OUTPUT"
+ echo "prerelease=false" >> "$GITHUB_OUTPUT"
+ else
+ SHORT_SHA="${GITHUB_SHA:0:7}"
+ echo "version=0.0.0-dev.${{ github.run_number }}+$SHORT_SHA" >> "$GITHUB_OUTPUT"
+ echo "file_version=0.0.0-dev.${{ github.run_number }}-$SHORT_SHA" >> "$GITHUB_OUTPUT"
+ echo "release_tag=latest" >> "$GITHUB_OUTPUT"
+ echo "prerelease=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ package:
+ name: Package ${{ matrix.platform.name }}
+ runs-on: ${{ matrix.platform.os }}
+ needs: prepare
+ continue-on-error: ${{ matrix.platform.experimental }}
+ strategy:
+ fail-fast: false
+ matrix:
+ platform:
+ - name: Windows
+ os: windows-latest
+ rid: win-x64
+ suffix: windows-x64
+ experimental: false
+ - name: Linux
+ os: ubuntu-latest
+ rid: linux-x64
+ suffix: linux-x64
+ experimental: true
+ - name: macOS
+ os: macos-latest
+ rid: osx-arm64
+ suffix: macos-arm64
+ experimental: true
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Restore
+ run: dotnet restore SC4ModMigrationAssistant.csproj
+
+ - name: Set archive name
+ shell: bash
+ run: echo "ARCHIVE=SC4ModMigrationAssistant-${{ needs.prepare.outputs.file_version }}-${{ matrix.platform.suffix }}.zip" >> "$GITHUB_ENV"
+
+ - name: Publish
+ run: >
+ dotnet publish SC4ModMigrationAssistant.csproj -c Release
+ -r ${{ matrix.platform.rid }}
+ --self-contained true
+ -p:Version=${{ needs.prepare.outputs.version }}
+ -p:PublishSingleFile=true
+ -p:IncludeNativeLibrariesForSelfExtract=true
+ -p:IncludeAllContentForSelfExtract=true
+ -p:DebugType=None
+ -o publish/${{ matrix.platform.rid }}
+
+ - name: Ad-hoc sign macOS executable
+ if: runner.os == 'macOS'
+ run: codesign --force --sign - publish/${{ matrix.platform.rid }}/SC4ModMigrationAssistant
+
+ - name: Mark Unix executable bit
+ if: runner.os != 'Windows'
+ run: chmod +x publish/${{ matrix.platform.rid }}/SC4ModMigrationAssistant
+
+ - name: Create Windows archive
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: Compress-Archive -Path publish/${{ matrix.platform.rid }}/* -DestinationPath $env:ARCHIVE
+
+ - name: Create Unix archive
+ if: runner.os != 'Windows'
+ shell: bash
+ run: (cd publish/${{ matrix.platform.rid }} && zip -r -y "$OLDPWD/$ARCHIVE" .)
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: ${{ env.ARCHIVE }}
+ path: ${{ env.ARCHIVE }}
+ if-no-files-found: error
+
+ publish:
+ name: Publish GitHub Release
+ runs-on: ubuntu-latest
+ needs: [prepare, package]
+
+ steps:
+ - name: Download artifacts
+ uses: actions/download-artifact@v7
+ with:
+ pattern: SC4ModMigrationAssistant-*
+ path: release-artifacts
+ merge-multiple: true
+
+ - name: Write release notes
+ shell: bash
+ run: |
+ cat > release-notes.md << 'EOF'
+ ## Windows
+
+ Unzip and run `SC4ModMigrationAssistant.exe`. Single self-contained executable, no
+ other files needed.
+
+ ## Linux
+
+ Unzip and run `./SC4ModMigrationAssistant` (executable bit already set in the
+ archive). Single self-contained executable, no other files needed.
+
+ ## macOS
+
+ Apple Silicon (arm64) only, ad-hoc signed and unnotarized. After unzipping, clear the
+ quarantine flag before launching:
+
+ ```sh
+ xattr -rd com.apple.quarantine SC4ModMigrationAssistant
+ ```
+ EOF
+
+ - name: Replace rolling release
+ if: needs.prepare.outputs.release_tag == 'latest'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release delete latest --repo "${{ github.repository }}" --yes --cleanup-tag || true
+
+ - name: Create release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ PRERELEASE_FLAG=()
+ if [[ "${{ needs.prepare.outputs.prerelease }}" == "true" ]]; then
+ PRERELEASE_FLAG=(--prerelease)
+ fi
+ gh release create "${{ needs.prepare.outputs.release_tag }}" release-artifacts/* \
+ --repo "${{ github.repository }}" \
+ --title "${{ needs.prepare.outputs.release_tag == 'latest' && 'Latest (master)' || needs.prepare.outputs.release_tag }}" \
+ --notes-file release-notes.md \
+ --target "${{ github.sha }}" \
+ "${PRERELEASE_FLAG[@]}"
diff --git a/App.axaml b/App.axaml
new file mode 100644
index 0000000..0d7d893
--- /dev/null
+++ b/App.axaml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+ #FF7B2FF7
+
+ #FF1B1330
+ #FF241B3A
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App.axaml.cs b/App.axaml.cs
new file mode 100644
index 0000000..bf96e58
--- /dev/null
+++ b/App.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace SC4ModMigrationAssistant;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/App.xaml b/App.xaml
deleted file mode 100644
index aba1568..0000000
--- a/App.xaml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
diff --git a/App.xaml.cs b/App.xaml.cs
deleted file mode 100644
index af0da97..0000000
--- a/App.xaml.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-using System.Windows;
-
-namespace SC4ModMigrationAssistant;
-
-public partial class App : Application
-{
-}
diff --git a/Dialogs/MessageDialog.axaml b/Dialogs/MessageDialog.axaml
new file mode 100644
index 0000000..cfec756
--- /dev/null
+++ b/Dialogs/MessageDialog.axaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/Dialogs/MessageDialog.axaml.cs b/Dialogs/MessageDialog.axaml.cs
new file mode 100644
index 0000000..653a372
--- /dev/null
+++ b/Dialogs/MessageDialog.axaml.cs
@@ -0,0 +1,60 @@
+using System.Threading.Tasks;
+using Avalonia.Controls;
+
+namespace SC4ModMigrationAssistant.Dialogs;
+
+///
+/// Minimal, self-contained modal dialog used in place of WPF's MessageBox, which
+/// Avalonia does not provide out of the box. Supports a single "OK" button (informational /
+/// error messages) or "Yes" / "No" buttons (confirmations).
+///
+public partial class MessageDialog : Window
+{
+ public MessageDialog()
+ {
+ InitializeComponent();
+ }
+
+ /// Shows an informational/error/warning message with a single OK button.
+ public static Task ShowInfo(Window owner, string title, string message) =>
+ ShowCore(owner, title, message, new[] { "OK" });
+
+ /// Shows a Yes/No confirmation; returns true if the user chose "Yes".
+ public static async Task ShowConfirm(Window owner, string title, string message)
+ {
+ string result = await ShowCore(owner, title, message, new[] { "Yes", "No" });
+ return result == "Yes";
+ }
+
+ private static async Task ShowCore(Window owner, string title, string message, string[] buttonLabels)
+ {
+ var dialog = new MessageDialog { Title = title };
+ dialog.MessageText.Text = message;
+
+ var tcs = new TaskCompletionSource();
+ bool resultSet = false;
+
+ foreach (string label in buttonLabels)
+ {
+ var button = new Button { Content = label, MinWidth = 80 };
+ button.Click += (_, _) =>
+ {
+ resultSet = true;
+ tcs.TrySetResult(label);
+ dialog.Close();
+ };
+ dialog.ButtonPanel.Children.Add(button);
+ }
+
+ dialog.Closed += (_, _) =>
+ {
+ if (!resultSet)
+ {
+ tcs.TrySetResult(string.Empty);
+ }
+ };
+
+ await dialog.ShowDialog(owner);
+ return await tcs.Task;
+ }
+}
diff --git a/LogEntryView.cs b/LogEntryView.cs
index 794ef38..ede0ab6 100644
--- a/LogEntryView.cs
+++ b/LogEntryView.cs
@@ -1,4 +1,4 @@
-using System.Windows.Media;
+using Avalonia.Media;
namespace SC4ModMigrationAssistant;
@@ -9,5 +9,5 @@ namespace SC4ModMigrationAssistant;
public sealed class LogEntryView
{
public required string Text { get; init; }
- public required Brush Brush { get; init; }
+ public required IBrush Brush { get; init; }
}
diff --git a/MainWindow.axaml b/MainWindow.axaml
new file mode 100644
index 0000000..b83471f
--- /dev/null
+++ b/MainWindow.axaml
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs
new file mode 100644
index 0000000..9a658ba
--- /dev/null
+++ b/MainWindow.axaml.cs
@@ -0,0 +1,609 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using SC4ModMigrationAssistant.Dialogs;
+using SC4ModMigrationAssistant.Models;
+using SC4ModMigrationAssistant.Services;
+
+namespace SC4ModMigrationAssistant;
+
+public partial class MainWindow : Window
+{
+ private readonly DbpfScanService _scanService = new();
+ private readonly DuplicateMoverService _moverService = new();
+ private readonly Sc4pacLookupService _catalogLookupService;
+ private readonly Sc4pacPluginsFileService _pluginsFileService = new();
+
+ private readonly ObservableCollection _logEntries = new();
+ private readonly ObservableCollection _catalogMatches = new();
+ private readonly ConcurrentQueue _pendingLog = new();
+ private readonly DispatcherTimer _logFlushTimer;
+
+ // Synthwave neon palette used for log lines, matching the brushes defined in App.axaml.
+ private static readonly IBrush BrushPlugins = new SolidColorBrush(Color.FromRgb(0xF1, 0xEA, 0xFB)); // soft white/lavender
+ private static readonly IBrush BrushOverride = new SolidColorBrush(Color.FromRgb(0x22, 0xD3, 0xEE)); // neon cyan
+ private static readonly IBrush BrushDuplicate = new SolidColorBrush(Color.FromRgb(0xFF, 0x3C, 0xAC)); // neon pink
+ private static readonly IBrush BrushWarning = new SolidColorBrush(Color.FromRgb(0xFF, 0xB8, 0x6C)); // neon amber
+ private static readonly IBrush BrushStatus = new SolidColorBrush(Color.FromRgb(0x9B, 0x8F, 0xC0)); // muted lavender-gray
+
+ // System.Text.Json's indented writer defaults to 2 spaces per level, matching sc4pac's own
+ // "explicit": [ ... ] file formatting.
+ private static readonly JsonSerializerOptions Sc4pacJsonOptions = new() { WriteIndented = true };
+
+ private string? _pluginsRoot;
+ private ScanResult? _lastScanResult;
+ private CancellationTokenSource? _cts;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ _catalogLookupService = new Sc4pacLookupService(_scanService);
+ LogListBox.ItemsSource = _logEntries;
+ CatalogResultsListBox.ItemsSource = _catalogMatches;
+
+ // Best-effort native dark title bar on Windows; harmless no-op elsewhere.
+ Opened += (_, _) => TryEnableDarkTitleBar();
+
+ string? detectedPluginsFile = _pluginsFileService.TryFindDefaultFile();
+ if (detectedPluginsFile != null)
+ {
+ TxtSc4pacPluginsPath.Text = detectedPluginsFile;
+ }
+
+ // Log lines are enqueued from the background scan thread (cheap, thread-safe, no UI
+ // marshaling) and flushed to the UI in small batches on a timer. This is what keeps
+ // the window responsive even with tens of thousands of files: without batching, a
+ // UI update per file is what causes the "freezing" during scans.
+ _logFlushTimer = new DispatcherTimer(DispatcherPriority.Background)
+ {
+ Interval = TimeSpan.FromMilliseconds(150)
+ };
+ _logFlushTimer.Tick += (_, _) => FlushLogQueue();
+ _logFlushTimer.Start();
+ }
+
+ private async void BtnBrowse_Click(object? sender, RoutedEventArgs e)
+ {
+ IReadOnlyList folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Select the Plugins folder",
+ AllowMultiple = false
+ });
+
+ if (folders.Count == 0)
+ {
+ return;
+ }
+
+ string? path = folders[0].TryGetLocalPath();
+ if (string.IsNullOrEmpty(path))
+ {
+ return;
+ }
+
+ _pluginsRoot = path;
+ TxtPluginsPath.Text = _pluginsRoot;
+ BtnScan.IsEnabled = true;
+ BtnMoveDuplicates.IsEnabled = false;
+ BtnCheckCatalog.IsEnabled = true;
+ _lastScanResult = null;
+ _logEntries.Clear();
+ _catalogMatches.Clear();
+ CatalogResultsPanel.IsVisible = false;
+ ProgressBarScan.Value = 0;
+ TxtProgressCount.Text = string.Empty;
+ ResetCompareProgress();
+ }
+
+ private async void BtnBrowseSc4pacPlugins_Click(object? sender, RoutedEventArgs e)
+ {
+ string defaultDir = Sc4pacPluginsFileService.DefaultProfilesRoot;
+ IStorageFolder? startLocation = Directory.Exists(defaultDir)
+ ? await StorageProvider.TryGetFolderFromPathAsync(defaultDir)
+ : null;
+
+ IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Select sc4pac-plugins.json",
+ AllowMultiple = false,
+ SuggestedStartLocation = startLocation,
+ FileTypeFilter = new[]
+ {
+ new FilePickerFileType("sc4pac plugins file") { Patterns = new[] { "sc4pac-plugins.json" } },
+ new FilePickerFileType("JSON files") { Patterns = new[] { "*.json" } },
+ FilePickerFileTypes.All
+ }
+ });
+
+ if (files.Count == 0)
+ {
+ return;
+ }
+
+ string? path = files[0].TryGetLocalPath();
+ if (!string.IsNullOrEmpty(path))
+ {
+ TxtSc4pacPluginsPath.Text = path;
+ }
+ }
+
+ private async void BtnScan_Click(object? sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrWhiteSpace(_pluginsRoot))
+ {
+ await MessageDialog.ShowInfo(this, "Warning", "Please select the Plugins folder first.");
+ return;
+ }
+
+ _logEntries.Clear();
+ ProgressBarScan.IsIndeterminate = true;
+ ProgressBarScan.Value = 0;
+ TxtProgressCount.Text = string.Empty;
+ ResetCompareProgress();
+ _cts = new CancellationTokenSource();
+ SetBusy(true, "Scanning...");
+
+ Action log = msg => _pendingLog.Enqueue(msg);
+ var scanProgress = new Progress(OnScanProgress);
+ var compareProgress = new Progress(OnCompareProgress);
+
+ try
+ {
+ ScanResult result = await Task.Run(() =>
+ _scanService.ScanPlugins(_pluginsRoot!, log, scanProgress, compareProgress, _cts.Token), _cts.Token);
+
+ FlushLogQueue();
+ _lastScanResult = result;
+ BtnMoveDuplicates.IsEnabled = result.Duplicates.Count > 0;
+
+ TxtStatus.Text = $"Files read: {result.TotalFilesScanned} - Duplicates found: {result.Duplicates.Count}";
+ }
+ catch (OperationCanceledException)
+ {
+ FlushLogQueue();
+ AppendLogImmediate(new LogMessage("Scan cancelled by the user.", LogColor.Orange));
+ TxtStatus.Text = "Scan cancelled.";
+ }
+ catch (Exception ex)
+ {
+ FlushLogQueue();
+ AppendLogImmediate(new LogMessage($"Error during scan: {ex.Message}", LogColor.Orange));
+ await MessageDialog.ShowInfo(this, "Error", ex.Message);
+ }
+ finally
+ {
+ ProgressBarScan.IsIndeterminate = false;
+ CompareProgressPanel.IsVisible = false;
+ SetBusy(false, TxtStatus.Text);
+ _cts?.Dispose();
+ _cts = null;
+ }
+ }
+
+ private async void BtnMoveDuplicates_Click(object? sender, RoutedEventArgs e)
+ {
+ if (_lastScanResult == null || _lastScanResult.Duplicates.Count == 0)
+ {
+ await MessageDialog.ShowInfo(this, "Warning", "No duplicates to move. Run a scan first.");
+ return;
+ }
+
+ IReadOnlyList folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Select (or create) the folder to move duplicates to",
+ AllowMultiple = false
+ });
+
+ if (folders.Count == 0)
+ {
+ return;
+ }
+
+ string? destination = folders[0].TryGetLocalPath();
+ if (string.IsNullOrEmpty(destination))
+ {
+ return;
+ }
+
+ bool confirm = await MessageDialog.ShowConfirm(this, "Confirm move",
+ $"{_lastScanResult.Duplicates.Count} duplicate file(s) will be moved to:\n{destination}\n\nContinue?");
+
+ if (!confirm)
+ {
+ return;
+ }
+
+ _cts = new CancellationTokenSource();
+ SetBusy(true, "Moving duplicates...");
+ Action log = msg => _pendingLog.Enqueue(msg);
+ List duplicates = _lastScanResult.Duplicates;
+
+ try
+ {
+ await Task.Run(() => _moverService.MoveDuplicates(duplicates, destination, log));
+ FlushLogQueue();
+ TxtStatus.Text = $"Move complete: {duplicates.Count} file(s) moved to {destination}";
+ BtnMoveDuplicates.IsEnabled = false;
+ }
+ catch (Exception ex)
+ {
+ FlushLogQueue();
+ AppendLogImmediate(new LogMessage($"Error while moving files: {ex.Message}", LogColor.Orange));
+ await MessageDialog.ShowInfo(this, "Error", ex.Message);
+ }
+ finally
+ {
+ SetBusy(false, TxtStatus.Text);
+ _cts?.Dispose();
+ _cts = null;
+ }
+ }
+
+ private void BtnCancel_Click(object? sender, RoutedEventArgs e)
+ {
+ _cts?.Cancel();
+ BtnCancel.IsEnabled = false;
+ }
+
+ private async void BtnCheckCatalog_Click(object? sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrWhiteSpace(_pluginsRoot))
+ {
+ await MessageDialog.ShowInfo(this, "Warning", "Please select the Plugins folder first.");
+ return;
+ }
+
+ _logEntries.Clear();
+ _catalogMatches.Clear();
+ CatalogResultsPanel.IsVisible = false;
+ ProgressBarScan.IsIndeterminate = true;
+ ProgressBarScan.Value = 0;
+ TxtProgressCount.Text = string.Empty;
+ ResetCompareProgress();
+ _cts = new CancellationTokenSource();
+ SetBusy(true, "Checking sc4pac catalog...");
+
+ Action log = msg => _pendingLog.Enqueue(msg);
+ var scanProgress = new Progress(OnScanProgress);
+
+ HashSet? installedPackageIds = null;
+ string sc4pacPluginsPath = TxtSc4pacPluginsPath.Text?.Trim() ?? string.Empty;
+ if (!string.IsNullOrEmpty(sc4pacPluginsPath))
+ {
+ if (File.Exists(sc4pacPluginsPath))
+ {
+ try
+ {
+ installedPackageIds = _pluginsFileService.LoadInstalledPackageIds(sc4pacPluginsPath, log);
+ log.Invoke(new LogMessage(
+ $"[sc4pac] Loaded {installedPackageIds.Count} already-installed package ID(s) (explicit + dependencies) via {sc4pacPluginsPath}",
+ LogColor.Gray));
+ }
+ catch (Exception ex)
+ {
+ log.Invoke(new LogMessage(
+ $"[sc4pac] Could not read sc4pac-plugins.json ({ex.Message}) - results may include already-installed packages.",
+ LogColor.Orange));
+ }
+ }
+ else
+ {
+ log.Invoke(new LogMessage(
+ $"[sc4pac] sc4pac-plugins.json not found at {sc4pacPluginsPath} - results may include already-installed packages.",
+ LogColor.Orange));
+ }
+ }
+ else
+ {
+ log.Invoke(new LogMessage(
+ "[sc4pac] No sc4pac-plugins.json set - results may include packages you already installed via sc4pac.",
+ LogColor.Orange));
+ }
+
+ try
+ {
+ List matches = await _catalogLookupService.CheckCatalogAsync(
+ _pluginsRoot!, log, scanProgress, _cts.Token, installedPackageIds);
+
+ FlushLogQueue();
+
+ foreach (Sc4pacMatch match in matches)
+ {
+ _catalogMatches.Add(match);
+ }
+ CatalogResultsPanel.IsVisible = matches.Count > 0;
+
+ TxtStatus.Text = matches.Count > 0
+ ? $"{matches.Count} sc4pac package(s) found for your override files."
+ : "No matching sc4pac packages found.";
+ }
+ catch (OperationCanceledException)
+ {
+ FlushLogQueue();
+ AppendLogImmediate(new LogMessage("Catalog check cancelled by the user.", LogColor.Orange));
+ TxtStatus.Text = "Catalog check cancelled.";
+ }
+ catch (Exception ex)
+ {
+ FlushLogQueue();
+ AppendLogImmediate(new LogMessage($"Error during catalog check: {ex.Message}", LogColor.Orange));
+ await MessageDialog.ShowInfo(this, "Error", ex.Message);
+ }
+ finally
+ {
+ ProgressBarScan.IsIndeterminate = false;
+ SetBusy(false, TxtStatus.Text);
+ _cts?.Dispose();
+ _cts = null;
+ }
+ }
+
+ private async void BtnCopyPackageId_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is Button { Tag: Sc4pacMatch match })
+ {
+ await TryCopyToClipboard(match.PackageId);
+ TxtStatus.Text = $"Copied package ID: {match.PackageId}";
+ }
+ }
+
+ private async void BtnCopyAddCommand_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is Button { Tag: Sc4pacMatch match })
+ {
+ await TryCopyToClipboard(match.Sc4pacAddCommand);
+ TxtStatus.Text = $"Copied: {match.Sc4pacAddCommand}";
+ }
+ }
+
+ private async void BtnOpenPage_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not Button { Tag: Sc4pacMatch { PageUrl: { Length: > 0 } url } })
+ {
+ return;
+ }
+
+ try
+ {
+ // UseShellExecute=true opens the URL with the OS's default handler (xdg-open on
+ // Linux, "open" on macOS, the default browser association on Windows) - this
+ // project never launches a specific browser directly.
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ await MessageDialog.ShowInfo(this, "Error", $"Could not open the page:\n{ex.Message}");
+ }
+ }
+
+ ///
+ /// Exports every package currently listed in the catalog results as a sc4pac-compatible
+ /// JSON file: { "explicit": ["group:package-id", ...] }, sorted and de-duplicated.
+ /// This matches the format sc4pac itself uses for its explicit-packages list, so the file
+ /// can be handed straight to sc4pac to install everything at once, easing migration to it.
+ ///
+ private async void BtnExportCatalogJson_Click(object? sender, RoutedEventArgs e)
+ {
+ if (_catalogMatches.Count == 0)
+ {
+ await MessageDialog.ShowInfo(this, "Warning", "No sc4pac packages to export yet. Run \"Check sc4pac Catalog\" first.");
+ return;
+ }
+
+ IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export sc4pac package list",
+ SuggestedFileName = "sc4pac-import.json",
+ DefaultExtension = "json",
+ FileTypeChoices = new[]
+ {
+ new FilePickerFileType("sc4pac JSON file") { Patterns = new[] { "*.json" } },
+ FilePickerFileTypes.All
+ }
+ });
+
+ if (file == null)
+ {
+ return;
+ }
+
+ string? filePath = file.TryGetLocalPath();
+ if (string.IsNullOrEmpty(filePath))
+ {
+ return;
+ }
+
+ List packageIds = _catalogMatches
+ .Select(m => m.PackageId)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var payload = new Sc4pacExplicitPackages { Explicit = packageIds };
+
+ try
+ {
+ string json = JsonSerializer.Serialize(payload, Sc4pacJsonOptions);
+ await File.WriteAllTextAsync(filePath, json);
+
+ AppendLogImmediate(new LogMessage(
+ $"[sc4pac] Exported {packageIds.Count} package(s) to {filePath}", LogColor.Gray));
+ TxtStatus.Text = $"Exported {packageIds.Count} package(s) to {filePath}";
+ }
+ catch (Exception ex)
+ {
+ await MessageDialog.ShowInfo(this, "Error", $"Could not save the file:\n{ex.Message}");
+ }
+ }
+
+ ///
+ /// Copies text to the clipboard, retrying once after a short delay if the first attempt
+ /// fails - clipboard access can transiently fail if another process briefly holds it.
+ ///
+ private async Task TryCopyToClipboard(string text)
+ {
+ IClipboard? clipboard = Clipboard;
+ if (clipboard == null)
+ {
+ return;
+ }
+
+ try
+ {
+ await clipboard.SetTextAsync(text);
+ }
+ catch (Exception)
+ {
+ try
+ {
+ await Task.Delay(100);
+ await clipboard.SetTextAsync(text);
+ }
+ catch
+ {
+ // Give up silently - copying to the clipboard is a convenience, not essential.
+ }
+ }
+ }
+
+ private void OnScanProgress(ScanProgress p)
+ {
+ ProgressBarScan.IsIndeterminate = false;
+ ProgressBarScan.Maximum = Math.Max(p.Total, 1);
+ ProgressBarScan.Value = p.Processed;
+ TxtProgressCount.Text = p.Total > 0 ? $"{p.Processed} / {p.Total} files" : string.Empty;
+ }
+
+ ///
+ /// Shows/updates the dedicated comparison progress bar. It only becomes visible once the
+ /// comparison phase actually starts, and is hidden again as soon as a new scan begins.
+ ///
+ private void OnCompareProgress(ScanProgress p)
+ {
+ if (!CompareProgressPanel.IsVisible)
+ {
+ CompareProgressPanel.IsVisible = true;
+ }
+
+ ProgressBarCompare.Maximum = Math.Max(p.Total, 1);
+ ProgressBarCompare.Value = p.Processed;
+ TxtCompareCount.Text = p.Total > 0 ? $"Analyzing duplicates: {p.Processed} / {p.Total}" : "Analyzing duplicates";
+ }
+
+ private void ResetCompareProgress()
+ {
+ CompareProgressPanel.IsVisible = false;
+ ProgressBarCompare.Value = 0;
+ TxtCompareCount.Text = string.Empty;
+ }
+
+ ///
+ /// Drains all currently queued log messages and adds them to the bound collection in one
+ /// batch. Runs on the UI thread (called from the DispatcherTimer tick or right after an
+ /// awaited background task completes).
+ ///
+ private void FlushLogQueue()
+ {
+ if (_pendingLog.IsEmpty)
+ {
+ return;
+ }
+
+ while (_pendingLog.TryDequeue(out LogMessage? msg))
+ {
+ _logEntries.Add(ToView(msg));
+ }
+
+ if (_logEntries.Count > 0)
+ {
+ LogListBox.ScrollIntoView(_logEntries[^1]);
+ }
+ }
+
+ private void AppendLogImmediate(LogMessage message)
+ {
+ _logEntries.Add(ToView(message));
+ if (_logEntries.Count > 0)
+ {
+ LogListBox.ScrollIntoView(_logEntries[^1]);
+ }
+ }
+
+ private static LogEntryView ToView(LogMessage message)
+ {
+ IBrush brush = message.Color switch
+ {
+ LogColor.Black => BrushPlugins,
+ LogColor.Blue => BrushOverride,
+ LogColor.Red => BrushDuplicate,
+ LogColor.Orange => BrushWarning,
+ LogColor.Gray => BrushStatus,
+ _ => BrushPlugins
+ };
+
+ return new LogEntryView { Text = message.Text, Brush = brush };
+ }
+
+ private void SetBusy(bool busy, string? status)
+ {
+ BtnBrowse.IsEnabled = !busy;
+ BtnScan.IsEnabled = !busy && !string.IsNullOrWhiteSpace(_pluginsRoot);
+ BtnMoveDuplicates.IsEnabled = !busy && _lastScanResult is { } r && r.Duplicates.Count > 0;
+ BtnCheckCatalog.IsEnabled = !busy && !string.IsNullOrWhiteSpace(_pluginsRoot);
+ BtnCancel.IsEnabled = busy;
+ TxtStatus.Text = status;
+ Cursor = busy ? new Cursor(StandardCursorType.Wait) : Cursor.Default;
+ }
+
+ ///
+ /// Best-effort attempt to make the native window title bar follow the dark theme too
+ /// (Windows 10 2004+ / Windows 11). No-op on Linux and macOS, where Avalonia's own window
+ /// chrome / theme handling already applies consistently.
+ ///
+ private void TryEnableDarkTitleBar()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ try
+ {
+ IntPtr handle = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
+ if (handle == IntPtr.Zero)
+ {
+ return;
+ }
+
+ int useImmersiveDarkMode = 1;
+ // 20 = DWMWA_USE_IMMERSIVE_DARK_MODE (Windows 10 20H1+ / 11). Falls back to the
+ // older attribute id (19) used on early Windows 10 20H1 builds if that fails.
+ if (DwmSetWindowAttribute(handle, 20, ref useImmersiveDarkMode, sizeof(int)) != 0)
+ {
+ DwmSetWindowAttribute(handle, 19, ref useImmersiveDarkMode, sizeof(int));
+ }
+ }
+ catch
+ {
+ // Not critical - the rest of the UI is already themed regardless of the title bar.
+ }
+ }
+
+ [DllImport("dwmapi.dll", PreserveSig = true)]
+ private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size);
+}
diff --git a/MainWindow.xaml b/MainWindow.xaml
deleted file mode 100644
index 4610891..0000000
--- a/MainWindow.xaml
+++ /dev/null
@@ -1,312 +0,0 @@
-
-
-
-
-
-
-
- #FF1B1330
- #FF241B3A
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
deleted file mode 100644
index ea9cebb..0000000
--- a/MainWindow.xaml.cs
+++ /dev/null
@@ -1,442 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Diagnostics;
-using System.Runtime.InteropServices;
-using System.Threading;
-using System.Threading.Tasks;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Input;
-using System.Windows.Interop;
-using System.Windows.Media;
-using System.Windows.Threading;
-using Microsoft.Win32;
-using SC4ModMigrationAssistant.Models;
-using SC4ModMigrationAssistant.Services;
-
-namespace SC4ModMigrationAssistant;
-
-public partial class MainWindow : Window
-{
- private readonly DbpfScanService _scanService = new();
- private readonly DuplicateMoverService _moverService = new();
- private readonly Sc4pacLookupService _catalogLookupService;
-
- private readonly ObservableCollection _logEntries = new();
- private readonly ObservableCollection _catalogMatches = new();
- private readonly ConcurrentQueue _pendingLog = new();
- private readonly DispatcherTimer _logFlushTimer;
-
- // Synthwave neon palette used for log lines, matching the brushes defined in MainWindow.xaml.
- private static readonly Brush BrushPlugins = new SolidColorBrush(Color.FromRgb(0xF1, 0xEA, 0xFB)); // soft white/lavender
- private static readonly Brush BrushOverride = new SolidColorBrush(Color.FromRgb(0x22, 0xD3, 0xEE)); // neon cyan
- private static readonly Brush BrushDuplicate = new SolidColorBrush(Color.FromRgb(0xFF, 0x3C, 0xAC)); // neon pink
- private static readonly Brush BrushWarning = new SolidColorBrush(Color.FromRgb(0xFF, 0xB8, 0x6C)); // neon amber
- private static readonly Brush BrushStatus = new SolidColorBrush(Color.FromRgb(0x9B, 0x8F, 0xC0)); // muted lavender-gray
-
- private string? _pluginsRoot;
- private ScanResult? _lastScanResult;
- private CancellationTokenSource? _cts;
-
- public MainWindow()
- {
- InitializeComponent();
- _catalogLookupService = new Sc4pacLookupService(_scanService);
- LogListBox.ItemsSource = _logEntries;
- CatalogResultsListBox.ItemsSource = _catalogMatches;
- TryEnableDarkTitleBar();
-
- // Log lines are enqueued from the background scan thread (cheap, thread-safe, no UI
- // marshaling) and flushed to the UI in small batches on a timer. This is what keeps
- // the window responsive even with tens of thousands of files: without batching, a
- // WPF UI update per file is what causes the "freezing" during scans.
- _logFlushTimer = new DispatcherTimer(DispatcherPriority.Background)
- {
- Interval = TimeSpan.FromMilliseconds(150)
- };
- _logFlushTimer.Tick += (_, _) => FlushLogQueue();
- _logFlushTimer.Start();
- }
-
- private void BtnBrowse_Click(object sender, RoutedEventArgs e)
- {
- var dialog = new OpenFolderDialog
- {
- Title = "Select the Plugins folder"
- };
-
- if (dialog.ShowDialog() == true)
- {
- _pluginsRoot = dialog.FolderName;
- TxtPluginsPath.Text = _pluginsRoot;
- BtnScan.IsEnabled = true;
- BtnMoveDuplicates.IsEnabled = false;
- BtnCheckCatalog.IsEnabled = true;
- _lastScanResult = null;
- _logEntries.Clear();
- _catalogMatches.Clear();
- CatalogResultsPanel.Visibility = Visibility.Collapsed;
- ProgressBarScan.Value = 0;
- TxtProgressCount.Text = string.Empty;
- ResetCompareProgress();
- }
- }
-
- private async void BtnScan_Click(object sender, RoutedEventArgs e)
- {
- if (string.IsNullOrWhiteSpace(_pluginsRoot))
- {
- MessageBox.Show(this, "Please select the Plugins folder first.", "Warning",
- MessageBoxButton.OK, MessageBoxImage.Warning);
- return;
- }
-
- _logEntries.Clear();
- ProgressBarScan.IsIndeterminate = true;
- ProgressBarScan.Value = 0;
- TxtProgressCount.Text = string.Empty;
- ResetCompareProgress();
- _cts = new CancellationTokenSource();
- SetBusy(true, "Scanning...");
-
- Action log = msg => _pendingLog.Enqueue(msg);
- var scanProgress = new Progress(OnScanProgress);
- var compareProgress = new Progress(OnCompareProgress);
-
- try
- {
- ScanResult result = await Task.Run(() =>
- _scanService.ScanPlugins(_pluginsRoot!, log, scanProgress, compareProgress, _cts.Token), _cts.Token);
-
- FlushLogQueue();
- _lastScanResult = result;
- BtnMoveDuplicates.IsEnabled = result.Duplicates.Count > 0;
-
- TxtStatus.Text = $"Files read: {result.TotalFilesScanned} - Duplicates found: {result.Duplicates.Count}";
- }
- catch (OperationCanceledException)
- {
- FlushLogQueue();
- AppendLogImmediate(new LogMessage("Scan cancelled by the user.", LogColor.Orange));
- TxtStatus.Text = "Scan cancelled.";
- }
- catch (Exception ex)
- {
- FlushLogQueue();
- AppendLogImmediate(new LogMessage($"Error during scan: {ex.Message}", LogColor.Orange));
- MessageBox.Show(this, ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error);
- }
- finally
- {
- ProgressBarScan.IsIndeterminate = false;
- CompareProgressPanel.Visibility = Visibility.Collapsed;
- SetBusy(false, TxtStatus.Text);
- _cts?.Dispose();
- _cts = null;
- }
- }
-
- private async void BtnMoveDuplicates_Click(object sender, RoutedEventArgs e)
- {
- if (_lastScanResult == null || _lastScanResult.Duplicates.Count == 0)
- {
- MessageBox.Show(this, "No duplicates to move. Run a scan first.", "Warning",
- MessageBoxButton.OK, MessageBoxImage.Warning);
- return;
- }
-
- var dialog = new OpenFolderDialog
- {
- Title = "Select (or create) the folder to move duplicates to"
- };
-
- if (dialog.ShowDialog() != true)
- {
- return;
- }
-
- string destination = dialog.FolderName;
-
- var confirm = MessageBox.Show(this,
- $"{_lastScanResult.Duplicates.Count} duplicate file(s) will be moved to:\n{destination}\n\nContinue?",
- "Confirm move", MessageBoxButton.YesNo, MessageBoxImage.Question);
-
- if (confirm != MessageBoxResult.Yes)
- {
- return;
- }
-
- _cts = new CancellationTokenSource();
- SetBusy(true, "Moving duplicates...");
- Action log = msg => _pendingLog.Enqueue(msg);
- List duplicates = _lastScanResult.Duplicates;
-
- try
- {
- await Task.Run(() => _moverService.MoveDuplicates(duplicates, destination, log));
- FlushLogQueue();
- TxtStatus.Text = $"Move complete: {duplicates.Count} file(s) moved to {destination}";
- BtnMoveDuplicates.IsEnabled = false;
- }
- catch (Exception ex)
- {
- FlushLogQueue();
- AppendLogImmediate(new LogMessage($"Error while moving files: {ex.Message}", LogColor.Orange));
- MessageBox.Show(this, ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error);
- }
- finally
- {
- SetBusy(false, TxtStatus.Text);
- _cts?.Dispose();
- _cts = null;
- }
- }
-
- private void BtnCancel_Click(object sender, RoutedEventArgs e)
- {
- _cts?.Cancel();
- BtnCancel.IsEnabled = false;
- }
-
- private async void BtnCheckCatalog_Click(object sender, RoutedEventArgs e)
- {
- if (string.IsNullOrWhiteSpace(_pluginsRoot))
- {
- MessageBox.Show(this, "Please select the Plugins folder first.", "Warning",
- MessageBoxButton.OK, MessageBoxImage.Warning);
- return;
- }
-
- _logEntries.Clear();
- _catalogMatches.Clear();
- CatalogResultsPanel.Visibility = Visibility.Collapsed;
- ProgressBarScan.IsIndeterminate = true;
- ProgressBarScan.Value = 0;
- TxtProgressCount.Text = string.Empty;
- ResetCompareProgress();
- _cts = new CancellationTokenSource();
- SetBusy(true, "Checking sc4pac catalog...");
-
- Action log = msg => _pendingLog.Enqueue(msg);
- var scanProgress = new Progress(OnScanProgress);
-
- try
- {
- List matches = await _catalogLookupService.CheckCatalogAsync(_pluginsRoot!, log, scanProgress, _cts.Token);
-
- FlushLogQueue();
-
- foreach (Sc4pacMatch match in matches)
- {
- _catalogMatches.Add(match);
- }
- CatalogResultsPanel.Visibility = matches.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
-
- TxtStatus.Text = matches.Count > 0
- ? $"{matches.Count} sc4pac package(s) found for your override files."
- : "No matching sc4pac packages found.";
- }
- catch (OperationCanceledException)
- {
- FlushLogQueue();
- AppendLogImmediate(new LogMessage("Catalog check cancelled by the user.", LogColor.Orange));
- TxtStatus.Text = "Catalog check cancelled.";
- }
- catch (Exception ex)
- {
- FlushLogQueue();
- AppendLogImmediate(new LogMessage($"Error during catalog check: {ex.Message}", LogColor.Orange));
- MessageBox.Show(this, ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error);
- }
- finally
- {
- ProgressBarScan.IsIndeterminate = false;
- SetBusy(false, TxtStatus.Text);
- _cts?.Dispose();
- _cts = null;
- }
- }
-
- private void BtnCopyPackageId_Click(object sender, RoutedEventArgs e)
- {
- if (sender is Button { Tag: Sc4pacMatch match })
- {
- TryCopyToClipboard(match.PackageId);
- TxtStatus.Text = $"Copied package ID: {match.PackageId}";
- }
- }
-
- private void BtnCopyAddCommand_Click(object sender, RoutedEventArgs e)
- {
- if (sender is Button { Tag: Sc4pacMatch match })
- {
- TryCopyToClipboard(match.Sc4pacAddCommand);
- TxtStatus.Text = $"Copied: {match.Sc4pacAddCommand}";
- }
- }
-
- private void BtnOpenPage_Click(object sender, RoutedEventArgs e)
- {
- if (sender is not Button { Tag: Sc4pacMatch { PageUrl: { Length: > 0 } url } })
- {
- return;
- }
-
- try
- {
- // UseShellExecute=true opens the URL with the OS's default browser, same as
- // double-clicking a link - this project never launches a specific browser directly.
- Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
- }
- catch (Exception ex)
- {
- MessageBox.Show(this, $"Could not open the page:\n{ex.Message}", "Error",
- MessageBoxButton.OK, MessageBoxImage.Error);
- }
- }
-
- ///
- /// Clipboard.SetText can intermittently throw COMException if another process briefly holds
- /// the clipboard (common on Windows) - retrying once after a short delay, as recommended
- /// practice, instead of letting a transient failure surface as an error to the user.
- ///
- private static void TryCopyToClipboard(string text)
- {
- try
- {
- Clipboard.SetText(text);
- }
- catch (Exception)
- {
- try
- {
- System.Threading.Thread.Sleep(100);
- Clipboard.SetText(text);
- }
- catch
- {
- // Give up silently - copying to the clipboard is a convenience, not essential.
- }
- }
- }
-
- private void OnScanProgress(ScanProgress p)
- {
- ProgressBarScan.IsIndeterminate = false;
- ProgressBarScan.Maximum = Math.Max(p.Total, 1);
- ProgressBarScan.Value = p.Processed;
- TxtProgressCount.Text = p.Total > 0 ? $"{p.Processed} / {p.Total} files" : string.Empty;
- }
-
- ///
- /// Shows/updates the dedicated comparison progress bar. It only becomes visible once the
- /// comparison phase actually starts, and is hidden again as soon as a new scan begins.
- ///
- private void OnCompareProgress(ScanProgress p)
- {
- if (CompareProgressPanel.Visibility != Visibility.Visible)
- {
- CompareProgressPanel.Visibility = Visibility.Visible;
- }
-
- ProgressBarCompare.Maximum = Math.Max(p.Total, 1);
- ProgressBarCompare.Value = p.Processed;
- TxtCompareCount.Text = p.Total > 0 ? $"Analyzing duplicates: {p.Processed} / {p.Total}" : "Analyzing duplicates";
- }
-
- private void ResetCompareProgress()
- {
- CompareProgressPanel.Visibility = Visibility.Collapsed;
- ProgressBarCompare.Value = 0;
- TxtCompareCount.Text = string.Empty;
- }
-
- ///
- /// Drains all currently queued log messages and adds them to the bound collection in one
- /// batch. Runs on the UI thread (called from the DispatcherTimer tick or right after an
- /// awaited background task completes).
- ///
- private void FlushLogQueue()
- {
- if (_pendingLog.IsEmpty)
- {
- return;
- }
-
- while (_pendingLog.TryDequeue(out LogMessage? msg))
- {
- _logEntries.Add(ToView(msg));
- }
-
- if (_logEntries.Count > 0)
- {
- LogListBox.ScrollIntoView(_logEntries[^1]);
- }
- }
-
- private void AppendLogImmediate(LogMessage message)
- {
- _logEntries.Add(ToView(message));
- if (_logEntries.Count > 0)
- {
- LogListBox.ScrollIntoView(_logEntries[^1]);
- }
- }
-
- private static LogEntryView ToView(LogMessage message)
- {
- Brush brush = message.Color switch
- {
- LogColor.Black => BrushPlugins,
- LogColor.Blue => BrushOverride,
- LogColor.Red => BrushDuplicate,
- LogColor.Orange => BrushWarning,
- LogColor.Gray => BrushStatus,
- _ => BrushPlugins
- };
-
- return new LogEntryView { Text = message.Text, Brush = brush };
- }
-
- private void SetBusy(bool busy, string status)
- {
- BtnBrowse.IsEnabled = !busy;
- BtnScan.IsEnabled = !busy && !string.IsNullOrWhiteSpace(_pluginsRoot);
- BtnMoveDuplicates.IsEnabled = !busy && _lastScanResult is { } r && r.Duplicates.Count > 0;
- BtnCheckCatalog.IsEnabled = !busy && !string.IsNullOrWhiteSpace(_pluginsRoot);
- BtnCancel.IsEnabled = busy;
- TxtStatus.Text = status;
- Mouse.OverrideCursor = busy ? Cursors.Arrow : null;
- }
-
- ///
- /// Best-effort attempt to make the native window title bar follow the dark theme too
- /// (Windows 10 2004+ / Windows 11). Silently does nothing on older systems.
- ///
- private void TryEnableDarkTitleBar()
- {
- SourceInitialized += (_, _) =>
- {
- try
- {
- var hwnd = new WindowInteropHelper(this).Handle;
- int useImmersiveDarkMode = 1;
- // 20 = DWMWA_USE_IMMERSIVE_DARK_MODE (Windows 10 20H1+ / 11). Falls back to the
- // older attribute id (19) used on early Windows 10 20H1 builds if that fails.
- if (DwmSetWindowAttribute(hwnd, 20, ref useImmersiveDarkMode, sizeof(int)) != 0)
- {
- DwmSetWindowAttribute(hwnd, 19, ref useImmersiveDarkMode, sizeof(int));
- }
- }
- catch
- {
- // Not critical - the rest of the UI is already themed regardless of the title bar.
- }
- };
- }
-
- [DllImport("dwmapi.dll", PreserveSig = true)]
- private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size);
-}
diff --git a/Models/LogColor.cs b/Models/LogColor.cs
index d7b5e15..9476d00 100644
--- a/Models/LogColor.cs
+++ b/Models/LogColor.cs
@@ -2,7 +2,7 @@ namespace SC4ModMigrationAssistant.Models;
///
/// Semantic color of a log message. The UI (MainWindow) maps this to the actual
-/// Brush to use, so the scanning services stay decoupled from WPF.
+/// Brush to use, so the scanning services stay decoupled from any specific UI framework.
///
public enum LogColor
{
diff --git a/Models/Sc4pacExplicitPackages.cs b/Models/Sc4pacExplicitPackages.cs
new file mode 100644
index 0000000..84b9187
--- /dev/null
+++ b/Models/Sc4pacExplicitPackages.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace SC4ModMigrationAssistant.Models;
+
+///
+/// Mirrors the JSON format sc4pac itself uses for an explicit-packages list:
+/// { "explicit": ["group:package-id", ...] }. Used to export the packages found by
+/// "Check sc4pac Catalog" into a file sc4pac can consume directly, to ease migration.
+///
+public sealed class Sc4pacExplicitPackages
+{
+ [JsonPropertyName("explicit")]
+ public required List Explicit { get; init; }
+}
diff --git a/Models/TgiKey.cs b/Models/TgiKey.cs
index 793138b..7ab7b5d 100644
--- a/Models/TgiKey.cs
+++ b/Models/TgiKey.cs
@@ -3,9 +3,9 @@
namespace SC4ModMigrationAssistant.Models;
///
-/// Compact value-type key (Type, Group, Instance) used everywhere in the scanner instead of
-/// csDBPF's own TGI struct. Implementing ourselves guarantees
-/// the runtime never boxes instances during hashing/equality checks in a HashSet/Dictionary -
+/// Compact value-type key (Type, Group, Instance) used everywhere in the scanner to identify a
+/// DBPF resource. Implementing ourselves guarantees the
+/// runtime never boxes instances during hashing/equality checks in a HashSet/Dictionary -
/// something that would otherwise be easy to trigger accidentally and that, across the tens of
/// millions of comparisons a large Plugins folder can involve, would show up as exactly the
/// kind of runaway memory/CPU usage this type exists to avoid.
diff --git a/Program.cs b/Program.cs
new file mode 100644
index 0000000..a9197b1
--- /dev/null
+++ b/Program.cs
@@ -0,0 +1,20 @@
+using System;
+using Avalonia;
+
+namespace SC4ModMigrationAssistant;
+
+internal static class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ // Avalonia configuration, used by the app itself and by the Avalonia designer.
+ public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/README.md b/README.md
index 45a90a6..929d523 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,48 @@
# SC4 Mod Migration Assistant
-A Windows desktop tool for SimCity 4 modders. It scans your `Plugins` folder and flags files
-in your `075-my-plugins` and `895-my-overrides` folders that duplicate content already present
-elsewhere in `Plugins`, so you can safely move them out of the way.
+[](https://github.com/UlisseWolf/SC4ModMigrationAssistant/actions/workflows/ci.yml)
+[](https://github.com/UlisseWolf/SC4ModMigrationAssistant/actions/workflows/release.yml)
+[](https://github.com/UlisseWolf/SC4ModMigrationAssistant/releases/latest)
+[](LICENSE.md)
-Built with WPF (.NET 10).
+A cross-platform desktop tool (Windows, Linux, macOS) for SimCity 4 modders. It scans your
+`Plugins` folder and flags files in your `075-my-plugins` and `895-my-overrides` folders that
+duplicate content already present elsewhere in `Plugins`, so you can safely move them out of
+the way.
+
+Built with [Avalonia UI](https://avaloniaui.net/) (.NET 10).
*The tool was developed with Claude's support and under human supervision*
+## Download
+
+Prebuilt, self-contained binaries for Windows, Linux, and macOS (Apple Silicon) are published
+automatically on the [Releases page](https://github.com/UlisseWolf/SC4ModMigrationAssistant/releases) —
+no .NET runtime install required. See [Setup](#setup) if you'd rather build from source.
+
+## Cross-platform support
+
+This app runs natively on Windows, Linux, and macOS via Avalonia UI. Two things made this a
+clean port:
+
+- The DBPF/TGI parsing (`Services/DbpfParsingService.cs`, `Services/DbpfFileSystemService.cs`)
+ is plain, portable C# reading raw file bytes directly - no external native or Windows-only
+ library dependency at all.
+- `Models/` and `Services/` (all scanning, comparison, moving, and sc4pac catalog logic) never
+ referenced the UI framework, so none of it needed to change for this port - only the UI layer
+ (`MainWindow`, dialogs, file/folder pickers) did.
+
+A few platform-specific notes:
+
+- **Folder/file pickers** use Avalonia's `IStorageProvider`, which uses each OS's native
+ picker - including the XDG desktop portal on Linux.
+- **Dark title bar**: the Windows-only `DwmSetWindowAttribute` call that colors the native
+ title bar is guarded by `OperatingSystem.IsWindows()` and does nothing on Linux/macOS, where
+ the window chrome already follows the app's dark theme through Avalonia itself.
+- **Opening a mod's page** (`Open Page` button) uses `Process.Start(..., UseShellExecute = true)`,
+ which resolves to the OS's default URL handler on every platform (`xdg-open` on Linux, `open`
+ on macOS, the default browser association on Windows).
+
## Features
- Recursively scans a Plugins folder for `.dat`, `.sc4lot`, `.sc4model`, and `.sc4desc` files
@@ -25,6 +60,12 @@ Built with WPF (.NET 10).
against the community-run [SC4 Prop Texture Catalog](https://github.com/noah-severyn/SC4PropTextureCatalog),
and lists any [sc4pac](https://sc4pac.github.io/) package that already provides that content —
so you can install it properly instead of keeping a loose manual copy
+- Cross-checks results against sc4pac's own `sc4pac-plugins.json` / `sc4pac-plugins-lock.json`
+ (auto-detected from the default profile location), so packages you've already installed —
+ explicitly or as a dependency — and the false positives that come from two different mods
+ sharing the same TGI, are filtered out automatically
+- **Export sc4pac JSON**: saves every package found by the catalog check to a
+ `{ "explicit": [...] }` file, the same format sc4pac itself uses, ready to hand off to sc4pac
## Screenshots
@@ -34,14 +75,17 @@ Built with WPF (.NET 10).
## Requirements
-- Windows 10/11
+- Windows 10/11, Linux, or macOS
- [.NET 10 SDK](https://dotnet.microsoft.com/download) to build, or the .NET 10 Desktop Runtime
- to run a published build
+ to run a published build — no platform-specific workload needed
- Internet access the first time you use **Check sc4pac Catalog** (to download `Catalog.db`,
~22 MB); the scan/move features work fully offline
## Setup
+Prebuilt binaries are available from the [Releases page](https://github.com/UlisseWolf/SC4ModMigrationAssistant/releases) —
+most users won't need to build from source at all. To build it yourself instead:
+
1. Clone this repository.
2. Build and run:
@@ -50,16 +94,29 @@ Built with WPF (.NET 10).
dotnet run
```
+To publish a self-contained build for a specific OS:
+
+```bash
+dotnet publish -c Release -r win-x64 --self-contained
+dotnet publish -c Release -r linux-x64 --self-contained
+dotnet publish -c Release -r osx-x64 --self-contained # osx-arm64 for Apple Silicon
+```
+
## Usage
1. **Browse...** – select your SimCity 4 `Plugins` folder (the one containing
`075-my-plugins` and `895-my-overrides`).
-2. **Scan TGIs** – scans the whole folder tree and identifies duplicates. Files are
+2. **sc4pac-plugins.json** – auto-detected on startup from sc4pac's default profile location
+ (`%AppData%\io.github.memo33\sc4pac\config\profiles\...`); use **Browse...** next to it if
+ sc4pac isn't installed there, uses a different profile, or wasn't detected. This is optional,
+ but strongly recommended before running **Check sc4pac Catalog** (step 5) — see
+ [Filtering out already-installed packages](#filtering-out-already-installed-packages-and-cross-mod-tgi-false-positives).
+3. **Scan TGIs** – scans the whole folder tree and identifies duplicates. Files are
color-coded by origin, and detected duplicates are highlighted.
-3. **Move Duplicates** – choose a destination folder; all detected duplicates are moved there
+4. **Move Duplicates** – choose a destination folder; all detected duplicates are moved there
(with their relative subfolder structure preserved), freeing them from `Plugins` without
permanently deleting anything.
-4. **Check sc4pac Catalog** – scans only `075-my-plugins` / `895-my-overrides` and checks their
+5. **Check sc4pac Catalog** – scans only `075-my-plugins` / `895-my-overrides` and checks their
TGIs against the SC4 Prop Texture Catalog (downloaded automatically on first use, then cached
locally). Any matching sc4pac package is listed with:
- **Copy ID** – copies the sc4pac package identifier (`group:package-name`)
@@ -70,6 +127,11 @@ Built with WPF (.NET 10).
installing is a two-step, always-accurate process: copy the ID (or the command) here, then
paste it into sc4pac's own *Find Packages* search (or run the copied command in a terminal).
+ **Export sc4pac JSON** (button above the results list) saves every package found so far to a
+ `{ "explicit": ["group:package-id", ...] }` file — the same format sc4pac itself uses for its
+ explicit-packages list — so you can hand it straight to sc4pac and install everything found
+ in one go, instead of adding packages one by one.
+
## How duplicate detection works
A file inside `075-my-plugins` is flagged as a duplicate if it shares at least one TGI with a
@@ -106,6 +168,43 @@ sc4pac, so results are given as a package ID / CLI command you paste into sc4pac
[Usage](#usage)) plus, when available, a link to the mod's real page — not a fabricated
"click to install" link that might not actually work.
+### Filtering out already-installed packages and cross-mod TGI false positives
+
+Two things can make the catalog check suggest a package you don't actually need:
+
+1. **You already installed it via sc4pac** — either explicitly, or as an automatic dependency
+ of something else (e.g. `nam:bridges`, pulled in as a dependency of NAM). The local file in
+ `075`/`895` still exists (maybe left over from before you switched to sc4pac), so its TGI
+ still matches the catalog, even though sc4pac is already managing that content.
+2. **A TGI is shared by more than one catalog package.** Some resources (shared dependencies,
+ assets reused across packs) are indexed under more than one package in the catalog, so a
+ single local file can resolve to several candidates - only one of which may actually be
+ relevant.
+
+To avoid both, the app reads sc4pac's own profile files (see
+[sc4pac's documentation](https://memo33.github.io/sc4pac/#/), "Details" section) - both are
+looked for in the same folder, whichever one is selected/detected:
+
+- `sc4pac-plugins.json`'s `"explicit"` array — packages you added yourself.
+- `sc4pac-plugins-lock.json`'s `"installed"` array — **every** package actually installed,
+ dependencies included. This is the one that matters most: `sc4pac-plugins.json` alone would
+ have missed `nam:bridges` in the example above, since it only tracks explicit additions.
+
+Both lists are combined, and for every TGI, **all** of its candidate packages are skipped if
+**any** of them is in that combined list. This handles case 1 directly (now covering
+dependency-only packages too), and also resolves most instances of case 2: if the TGI's
+"correct" package is already installed, the other candidate(s) that merely happen to share the
+same TGI are false positives anyway and get filtered out along with it.
+
+`sc4pac-plugins.json` is auto-detected on startup from sc4pac's default location
+(`%AppData%\io.github.memo33\sc4pac\config\profiles\...\sc4pac-plugins.json`, picking the most
+recently modified one if you have several profiles); `sc4pac-plugins-lock.json` is then looked
+for automatically right next to it. Use **Browse...** in the UI to point at either file manually
+if auto-detection doesn't find them (e.g. a non-default profile location) - whichever one you
+pick, the app still looks for its sibling in the same folder. If the lock file can't be found,
+the catalog check still runs, but the log will warn that dependency-only packages may still be
+suggested.
+
## Configuration
A few constants in `Services/DbpfScanService.cs` can be adjusted:
@@ -123,23 +222,33 @@ The catalog download URL and local cache path are in
## Project structure
```
+.github/workflows/
+ ci.yml Build check on every pull request (Windows/Linux/macOS)
+ release.yml Packages and publishes a GitHub Release on push to master or a v*.*.* tag
SC4ModMigrationAssistant.csproj
-LICENSE
-App.xaml / App.xaml.cs
-MainWindow.xaml / MainWindow.xaml.cs
+LICENSE.md
+Program.cs Avalonia entry point (BuildAvaloniaApp / Main)
+App.axaml / App.axaml.cs FluentTheme (Dark) + Synthwave accent/brushes + control styles
+MainWindow.axaml / MainWindow.axaml.cs
LogEntryView.cs
+Dialogs/
+ MessageDialog.axaml / .axaml.cs Custom OK / Yes-No modal (Avalonia has no built-in MessageBox)
Models/
SourceCategory.cs
TgiKey.cs
ScannedFile.cs
CatalogScanFile.cs
Sc4pacMatch.cs
+ Sc4pacExplicitPackages.cs
LogColor.cs
Services/
+ DbpfFileSystemService.cs
+ DbpfParsingService.cs
DbpfScanService.cs
DuplicateMoverService.cs
CatalogDatabaseService.cs
Sc4pacLookupService.cs
+ Sc4pacPluginsFileService.cs
```
## Contributing
@@ -148,7 +257,7 @@ Issues and pull requests are welcome.
## License
-This project is licensed under the [MIT License](LICENSE)
+This project is licensed under the [MIT License](LICENSE.md)
## Disclaimer
diff --git a/SC4ModMigrationAssistant.csproj b/SC4ModMigrationAssistant.csproj
index bac8fd3..b8a589e 100644
--- a/SC4ModMigrationAssistant.csproj
+++ b/SC4ModMigrationAssistant.csproj
@@ -2,26 +2,45 @@
WinExe
- net10.0-windows
- true
+ net10.0
enable
enable
SC4ModMigrationAssistant
SC4ModMigrationAssistant
- Assets\SC4ModMigrationAssistant.ico
latest
SC4 Mod Migration Assistant
+ 2.0.0
+ false
false
true
+
+
+
+
+
+
+
+
+
+
+
@@ -36,4 +55,8 @@
+
+
+
+
diff --git a/Services/CatalogDatabaseService.cs b/Services/CatalogDatabaseService.cs
index 4c1168d..69fadb4 100644
--- a/Services/CatalogDatabaseService.cs
+++ b/Services/CatalogDatabaseService.cs
@@ -22,7 +22,7 @@ public sealed record CatalogPackageInfo(string PackageId, string? Author, string
/// from GitHub, caches it locally, and resolves TGIs to the sc4pac package(s) that provide them.
///
///
-/// Schema (verified directly against a real copy of Catalog.db - not guessed):
+/// Schema:
///
/// - TGIs(FileId, TGI, Category, Name) - TGI is a TEXT column formatted as
/// "0xTTTTTTTT, 0xGGGGGGGG, 0xIIIIIIII" (lowercase hex, comma-space separated). For
@@ -35,9 +35,8 @@ public sealed record CatalogPackageInfo(string PackageId, string? Author, string
/// sc4pac package identifier itself, in "group:package-name" form. Websites is a
/// ;-separated list of the mod's real download/info page URL(s).
///
-/// This was confirmed by opening an actual copy of Catalog.db provided directly - not
-/// inferred from documentation, which the upstream project does not publish for this database.
-/// If a future version of the catalog changes its schema, the queries in
+/// The upstream project does not publish schema documentation for this database. If a
+/// future version of the catalog changes its schema, the queries in
/// are the only place that needs updating.
///
public sealed class CatalogDatabaseService
@@ -57,9 +56,7 @@ public sealed class CatalogDatabaseService
// In-memory indexes, built once per app session (LoadIndexesAsync is a no-op if already
// loaded) and reused across multiple "Check sc4pac Catalog" runs. Together these total a
- // few tens of MB at most for the current catalog size (~260k TGI rows, ~5k packages),
- // consistent with this app's low-memory design goals - nowhere near the size of the actual
- // Plugins-folder TGI sets this app scans separately.
+ // few tens of MB at most for the current catalog size (~260k TGI rows, ~5k packages).
private Dictionary>? _exactTgiToFileIds;
private Dictionary<(uint Group, uint Instance), List>? _wildcardTypeToFileIds;
private Dictionary>? _packageIdsByFileId;
diff --git a/Services/DbpfScanService.cs b/Services/DbpfScanService.cs
index 889063d..6dd25b7 100644
--- a/Services/DbpfScanService.cs
+++ b/Services/DbpfScanService.cs
@@ -14,7 +14,7 @@ public sealed record LogMessage(string Text, LogColor Color);
///
/// Progress of a scanning phase, expressed as items processed out of an approximate total.
-/// Reused for both the file-scanning phase and the (now very quick) TGI-comparison phase.
+/// Reused for both the file-scanning phase and the TGI-comparison phase.
///
public sealed record ScanProgress(int Processed, int Total);
@@ -40,8 +40,8 @@ public sealed class ScanResult
///
/// Scans the Plugins folder (and the 075-my-plugins / 895-my-overrides subfolders), reads
-/// the TGIs of every DBPF file via csDBPF, and flags genuine duplicates found inside the two
-/// override folders.
+/// the TGIs of every DBPF file, and flags genuine duplicates found inside the two override
+/// folders.
///
///
/// Duplicate checking is scoped only to the main Plugins folder. There is no
@@ -52,26 +52,25 @@ public sealed class ScanResult
/// against the main Plugins folder is what actually indicates a redundant copy, so that is the
/// only comparison performed.
///
-/// Excluded (non-content) TGIs - the main source of false positives: some DBPF
-/// entries are structural/bookkeeping data whose TGI is, by design, identical (or drawn from a
-/// tiny, near-universal set of default values) across a huge number of otherwise unrelated
-/// files. Treating these as evidence of duplicated content produces large-scale false
-/// positives. Two are known and excluded via :
+/// Excluded (non-content) TGIs: some DBPF entries are structural/bookkeeping data
+/// whose TGI is, by design, identical (or drawn from a tiny, near-universal set of default
+/// values) across a huge number of otherwise unrelated files. Treating these as evidence of
+/// duplicated content produces false positives. Two are known and excluded via
+/// :
///
-/// - The Directory subfile (csDBPF's DBPFTGI.DIRECTORY): Type=0xE86B1EEF,
-/// Group=0xE86B1EEF, Instance=0x286B1F03 - present, with this exact TGI, in every compressed
-/// DBPF file.
+/// - The DBPF Directory subfile: Type=0xE86B1EEF, Group=0xE86B1EEF, Instance=0x286B1F03 -
+/// present, with this exact TGI, in every compressed DBPF file.
/// - The common default LD (Lot Data) entry: Type=0x6BE74C60, Group=0x6BE74C60,
/// Instance=0x00000001 - a near-universal default LD instance shared across a huge number of
/// otherwise unrelated lot files.
///
-/// If further testing turns up other similarly universal, non-content TGIs causing false
-/// positives, add them to that same set.
+/// If testing turns up other similarly universal, non-content TGIs causing false positives,
+/// add them to that same set.
///
-/// Memory design (kept from the previous fix): the main Plugins folder is
-/// collapsed into a single flat HashSet<TgiKey> of unique TGIs with no per-file
-/// object retained; only the two override folders get lightweight per-file records; TGIs use
-/// the boxing-free type.
+/// Memory design: the main Plugins folder is collapsed into a single flat
+/// HashSet<TgiKey> of unique TGIs with no per-file object retained; only the two
+/// override folders get lightweight per-file records; TGIs use the boxing-free
+/// type.
///
public sealed class DbpfScanService
{
@@ -108,7 +107,7 @@ public sealed class DbpfScanService
/// Full path of the Plugins folder to scan.
/// Callback invoked (from a background thread) for every log line produced.
/// Callback invoked at most every files while scanning.
- /// Callback invoked for the (now very quick) final comparison step.
+ /// Callback invoked for the final comparison step.
/// Cancellation token, checked between files.
public ScanResult ScanPlugins(
string pluginsRoot,
diff --git a/Services/Sc4pacLookupService.cs b/Services/Sc4pacLookupService.cs
index b0fe0a3..50d7313 100644
--- a/Services/Sc4pacLookupService.cs
+++ b/Services/Sc4pacLookupService.cs
@@ -29,8 +29,11 @@ public async Task> CheckCatalogAsync(
string pluginsRoot,
Action log,
IProgress scanProgress,
- CancellationToken token)
+ CancellationToken token,
+ IReadOnlyCollection? installedPackageIds = null)
{
+ var installed = new HashSet(installedPackageIds ?? Array.Empty(), StringComparer.OrdinalIgnoreCase);
+
List files = await Task.Run(
() => _scanService.ScanOverrideFoldersForCatalog(pluginsRoot, log, scanProgress, token),
token).ConfigureAwait(false);
@@ -67,9 +70,22 @@ public async Task> CheckCatalogAsync(
token).ConfigureAwait(false);
var matchesById = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ int alreadyInstalledTgiCount = 0;
foreach ((TgiKey tgi, List infos) in matches)
{
+ // Some TGIs are provided by more than one catalog package (shared/duplicated
+ // resources across mods). If any of the candidates for this TGI is already
+ // installed via sc4pac, the content is already covered - skip every candidate for
+ // this TGI rather than only the installed one, since the alternative(s) are most
+ // likely the very false positives this filter exists to remove (the "same TGI,
+ // different mod" case), not a genuinely missing extra package.
+ if (installed.Count > 0 && infos.Any(i => installed.Contains(i.PackageId)))
+ {
+ alreadyInstalledTgiCount++;
+ continue;
+ }
+
foreach (CatalogPackageInfo info in infos)
{
if (!matchesById.TryGetValue(info.PackageId, out Sc4pacMatch? match))
@@ -104,6 +120,13 @@ public async Task> CheckCatalogAsync(
LogColor.Red));
}
+ if (installed.Count > 0)
+ {
+ log.Invoke(new LogMessage(
+ $"[sc4pac] Skipped {alreadyInstalledTgiCount} TGI(s) already covered by a package in sc4pac-plugins.json.",
+ LogColor.Gray));
+ }
+
log.Invoke(new LogMessage($"[sc4pac] Catalog check complete: {ordered.Count} package(s) found for {files.Count} local file(s).", LogColor.Gray));
return ordered;
diff --git a/Services/Sc4pacPluginsFileService.cs b/Services/Sc4pacPluginsFileService.cs
new file mode 100644
index 0000000..376e6b3
--- /dev/null
+++ b/Services/Sc4pacPluginsFileService.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using SC4ModMigrationAssistant.Models;
+
+namespace SC4ModMigrationAssistant.Services;
+
+///
+/// Locates and reads sc4pac's own profile files, so the "Check sc4pac Catalog" feature can
+/// tell which packages the user already has installed and exclude them from its suggestions.
+///
+///
+/// Per sc4pac's own documentation (https://memo33.github.io/sc4pac/#/, "Details" section),
+/// the CLI keeps state in two files, both living in the same per-profile folder under
+/// %AppData%\io.github.memo33\sc4pac\config\profiles\<profile>\:
+///
+/// - sc4pac-plugins.json - its "explicit" array lists only the packages the
+/// user explicitly added themselves, excluding dependencies.
+/// - sc4pac-plugins-lock.json - its "installed" array lists every package
+/// actually installed, including every dependency pulled in automatically (e.g.
+/// nam:bridges, installed as a dependency of NAM rather than added directly).
+///
+/// Since a package only needs to be excluded here if it's genuinely already present in
+/// the Plugins folder, sc4pac-plugins-lock.json's "installed" list is the authoritative,
+/// complete source, and is read whenever available. The narrower "explicit" list from
+/// sc4pac-plugins.json is still included too (a strict subset in practice), so filtering still
+/// works even if only that file can be found.
+///
+public sealed class Sc4pacPluginsFileService
+{
+ private const string PluginsFileName = "sc4pac-plugins.json";
+ private const string LockFileName = "sc4pac-plugins-lock.json";
+
+ /// Root folder sc4pac stores its per-profile config under, on Windows.
+ public static string DefaultProfilesRoot => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "io.github.memo33", "sc4pac", "config", "profiles");
+
+ ///
+ /// Searches for every sc4pac-plugins.json file
+ /// (one per sc4pac profile) and returns the most recently modified one, or null if none
+ /// were found (e.g. sc4pac isn't installed, or uses a non-default config location).
+ ///
+ public string? TryFindDefaultFile()
+ {
+ if (!Directory.Exists(DefaultProfilesRoot))
+ {
+ return null;
+ }
+
+ try
+ {
+ return Directory.EnumerateFiles(DefaultProfilesRoot, PluginsFileName, SearchOption.AllDirectories)
+ .OrderByDescending(File.GetLastWriteTimeUtc)
+ .FirstOrDefault();
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Loads every package ID that counts as "already installed" for filtering purposes:
+ /// sc4pac-plugins.json's "explicit" list plus, more importantly,
+ /// sc4pac-plugins-lock.json's "installed" list (which also covers dependencies).
+ ///
+ ///
+ /// Path to either sc4pac-plugins.json or sc4pac-plugins-lock.json (both are looked for in
+ /// the same folder regardless of which one was actually selected/detected).
+ ///
+ /// Optional callback used to report whether the lock file was found.
+ public HashSet LoadInstalledPackageIds(string selectedFilePath, Action? log = null)
+ {
+ var result = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ string? directory = Path.GetDirectoryName(selectedFilePath);
+ string fileName = Path.GetFileName(selectedFilePath);
+
+ string pluginsJsonPath = string.Equals(fileName, PluginsFileName, StringComparison.OrdinalIgnoreCase)
+ ? selectedFilePath
+ : Path.Combine(directory ?? string.Empty, PluginsFileName);
+
+ string lockJsonPath = string.Equals(fileName, LockFileName, StringComparison.OrdinalIgnoreCase)
+ ? selectedFilePath
+ : Path.Combine(directory ?? string.Empty, LockFileName);
+
+ if (File.Exists(pluginsJsonPath))
+ {
+ AddPackageIdsFromArray(pluginsJsonPath, "explicit", result, isLockFileFormat: false);
+ }
+
+ if (File.Exists(lockJsonPath))
+ {
+ AddPackageIdsFromArray(lockJsonPath, "installed", result, isLockFileFormat: true);
+ }
+ else
+ {
+ log?.Invoke(new LogMessage(
+ $"[sc4pac] {LockFileName} not found next to {selectedFilePath} - only explicitly-added " +
+ "packages will be excluded, so packages installed only as a dependency (e.g. nam:bridges, " +
+ "pulled in by NAM) may still be suggested. Make sure both sc4pac files are in the same folder.",
+ LogColor.Orange));
+ }
+
+ return result;
+ }
+
+ ///
+ /// Reads package IDs out of one of sc4pac's JSON arrays into .
+ /// sc4pac-plugins.json's "explicit" array is a plain array of "group:name" strings;
+ /// sc4pac-plugins-lock.json's "installed" array is an array of objects with separate
+ /// "group" and "name" fields that need combining into the same "group:name" form.
+ ///
+ private static void AddPackageIdsFromArray(string filePath, string propertyName, HashSet destination, bool isLockFileFormat)
+ {
+ using FileStream stream = File.OpenRead(filePath);
+ using JsonDocument document = JsonDocument.Parse(stream);
+
+ if (!document.RootElement.TryGetProperty(propertyName, out JsonElement array) || array.ValueKind != JsonValueKind.Array)
+ {
+ return;
+ }
+
+ foreach (JsonElement item in array.EnumerateArray())
+ {
+ if (!isLockFileFormat)
+ {
+ if (item.ValueKind == JsonValueKind.String)
+ {
+ string? id = item.GetString();
+ if (!string.IsNullOrWhiteSpace(id))
+ {
+ destination.Add(id);
+ }
+ }
+ continue;
+ }
+
+ if (item.ValueKind == JsonValueKind.Object
+ && item.TryGetProperty("group", out JsonElement groupElement)
+ && item.TryGetProperty("name", out JsonElement nameElement)
+ && groupElement.ValueKind == JsonValueKind.String
+ && nameElement.ValueKind == JsonValueKind.String)
+ {
+ string? group = groupElement.GetString();
+ string? name = nameElement.GetString();
+ if (!string.IsNullOrWhiteSpace(group) && !string.IsNullOrWhiteSpace(name))
+ {
+ destination.Add($"{group}:{name}");
+ }
+ }
+ }
+ }
+}