diff --git a/src/SharpEmu.GUI/GameEntry.cs b/src/SharpEmu.GUI/GameEntry.cs
index b63e11cb..e0d2cc37 100644
--- a/src/SharpEmu.GUI/GameEntry.cs
+++ b/src/SharpEmu.GUI/GameEntry.cs
@@ -8,6 +8,15 @@ using Avalonia.Media.Imaging;
namespace SharpEmu.GUI;
+[Flags]
+internal enum GameEntryChanges
+{
+ None = 0,
+ Metadata = 1,
+ Cover = 2,
+ Background = 4,
+}
+
public sealed class GameEntry : INotifyPropertyChanged
{
// Placeholder gradients for games without cover art, picked
@@ -27,29 +36,39 @@ public sealed class GameEntry : INotifyPropertyChanged
private Bitmap? _cover;
private IBrush? _placeholderBrush;
private long _sizeBytes;
+ private string _name;
+ private string? _titleId;
+ private string? _version;
+ private string? _coverPath;
+ private string? _backgroundPath;
+ private string _initials;
+ private FileStamp _coverStamp;
+ private FileStamp _backgroundStamp;
public GameEntry(
string name, string? titleId, string? version, string path, long sizeBytes,
string? coverPath, string? backgroundPath)
{
- Name = name;
- TitleId = titleId;
- Version = version;
+ _name = name;
+ _titleId = titleId;
+ _version = version;
Path = path;
_sizeBytes = sizeBytes;
- CoverPath = coverPath;
- BackgroundPath = backgroundPath;
- Initials = ComputeInitials(name);
+ _coverPath = coverPath;
+ _backgroundPath = backgroundPath;
+ _initials = ComputeInitials(name);
+ _coverStamp = FileStamp.Read(coverPath);
+ _backgroundStamp = FileStamp.Read(backgroundPath);
}
public event PropertyChangedEventHandler? PropertyChanged;
- public string Name { get; }
+ public string Name => _name;
- public string? TitleId { get; }
+ public string? TitleId => _titleId;
/// Content version from sce_sys/param.json, e.g. "01.000.000".
- public string? Version { get; }
+ public string? Version => _version;
public string Path { get; }
@@ -75,10 +94,10 @@ public sealed class GameEntry : INotifyPropertyChanged
}
/// Path to the cover art image shipped with the game, if found.
- public string? CoverPath { get; }
+ public string? CoverPath => _coverPath;
/// Path to the key art (pic0/pic1) shipped with the game, if found.
- public string? BackgroundPath { get; }
+ public string? BackgroundPath => _backgroundPath;
///
/// Decoded key art used as the window backdrop while this game is
@@ -86,7 +105,7 @@ public sealed class GameEntry : INotifyPropertyChanged
///
public Bitmap? Background { get; set; }
- public string Initials { get; }
+ public string Initials => _initials;
// Built lazily: brushes are AvaloniaObjects that must be created on the
// UI thread, while GameEntry itself is constructed on the scan thread.
@@ -121,6 +140,74 @@ public sealed class GameEntry : INotifyPropertyChanged
/// Formatted install size badge shown in the launch bar.
public string SizeText => FormatSize(SizeBytes);
+ ///
+ /// Applies a fresh filesystem scan to this presentation object so its
+ /// ListBox container and selection remain stable
+ ///
+ internal GameEntryChanges UpdateFrom(GameEntry scanned)
+ {
+ if (!Path.Equals(scanned.Path, GameLibraryPath.Comparison))
+ {
+ throw new ArgumentException("Only matching game paths can be reconciled", nameof(scanned));
+ }
+
+ var changes = GameEntryChanges.None;
+ if (!string.Equals(_name, scanned.Name, StringComparison.Ordinal))
+ {
+ _name = scanned.Name;
+ _initials = ComputeInitials(scanned.Name);
+ _placeholderBrush = null;
+ RaisePropertyChanged(nameof(Name));
+ RaisePropertyChanged(nameof(Initials));
+ RaisePropertyChanged(nameof(PlaceholderBrush));
+ changes |= GameEntryChanges.Metadata;
+ }
+
+ if (!string.Equals(_titleId, scanned.TitleId, StringComparison.Ordinal))
+ {
+ _titleId = scanned.TitleId;
+ RaisePropertyChanged(nameof(TitleId));
+ RaisePropertyChanged(nameof(HasTitleId));
+ changes |= GameEntryChanges.Metadata;
+ }
+
+ if (!string.Equals(_version, scanned.Version, StringComparison.Ordinal))
+ {
+ _version = scanned.Version;
+ RaisePropertyChanged(nameof(Version));
+ RaisePropertyChanged(nameof(VersionText));
+ RaisePropertyChanged(nameof(HasVersion));
+ changes |= GameEntryChanges.Metadata;
+ }
+
+ if (!string.Equals(_coverPath, scanned.CoverPath, StringComparison.Ordinal)
+ || _coverStamp != scanned._coverStamp)
+ {
+ _coverPath = scanned.CoverPath;
+ _coverStamp = scanned._coverStamp;
+ var previousCover = Cover;
+ Cover = null;
+ previousCover?.Dispose();
+ changes |= GameEntryChanges.Cover;
+ }
+
+ if (!string.Equals(_backgroundPath, scanned.BackgroundPath, StringComparison.Ordinal)
+ || _backgroundStamp != scanned._backgroundStamp)
+ {
+ _backgroundPath = scanned.BackgroundPath;
+ _backgroundStamp = scanned._backgroundStamp;
+ var previousBackground = Background;
+ Background = null;
+ previousBackground?.Dispose();
+ changes |= GameEntryChanges.Background;
+ }
+
+ return changes;
+ }
+
+ private void RaisePropertyChanged(string propertyName) =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
private static string ComputeInitials(string name)
{
var initials = name
@@ -164,4 +251,29 @@ public sealed class GameEntry : INotifyPropertyChanged
_ => $"{bytes} B",
};
}
+
+ private readonly record struct FileStamp(long Length, long LastWriteTimeUtcTicks)
+ {
+ public static FileStamp Read(string? path)
+ {
+ if (path is null)
+ {
+ return default;
+ }
+
+ try
+ {
+ var file = new FileInfo(path);
+ return file.Exists
+ ? new FileStamp(file.Length, file.LastWriteTimeUtc.Ticks)
+ : default;
+ }
+ catch (Exception exception) when (
+ exception is IOException
+ or UnauthorizedAccessException)
+ {
+ return default;
+ }
+ }
+ }
}
diff --git a/src/SharpEmu.GUI/GameLibraryPath.cs b/src/SharpEmu.GUI/GameLibraryPath.cs
new file mode 100644
index 00000000..52ca46ef
--- /dev/null
+++ b/src/SharpEmu.GUI/GameLibraryPath.cs
@@ -0,0 +1,17 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.GUI;
+
+internal static class GameLibraryPath
+{
+ public static StringComparer Comparer { get; } =
+ OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal;
+
+ public static StringComparison Comparison { get; } =
+ OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+}
diff --git a/src/SharpEmu.GUI/GameLibraryReconciler.cs b/src/SharpEmu.GUI/GameLibraryReconciler.cs
new file mode 100644
index 00000000..caae283e
--- /dev/null
+++ b/src/SharpEmu.GUI/GameLibraryReconciler.cs
@@ -0,0 +1,98 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.GUI;
+
+internal sealed record GameLibraryReconciliation(
+ IReadOnlyList Games,
+ IReadOnlyList CoversToLoad,
+ IReadOnlySet BackgroundsChanged);
+
+///
+/// Applies filesystem scan results while preserving presentation object
+/// identity for games that remain in the library
+///
+internal static class GameLibraryReconciler
+{
+ public static GameLibraryReconciliation Reconcile(
+ IReadOnlyList current,
+ IReadOnlyList scanned)
+ {
+ var existingByPath = current.ToDictionary(game => game.Path, GameLibraryPath.Comparer);
+ var merged = new List(scanned.Count);
+ var coversToLoad = new List();
+ var backgroundsChanged = new HashSet();
+
+ foreach (var scannedGame in scanned)
+ {
+ if (!existingByPath.TryGetValue(scannedGame.Path, out var existing))
+ {
+ merged.Add(scannedGame);
+ if (scannedGame.CoverPath is not null)
+ {
+ coversToLoad.Add(scannedGame);
+ }
+
+ continue;
+ }
+
+ var changes = existing.UpdateFrom(scannedGame);
+ merged.Add(existing);
+ if ((changes & GameEntryChanges.Cover) != 0 && existing.CoverPath is not null)
+ {
+ coversToLoad.Add(existing);
+ }
+
+ if ((changes & GameEntryChanges.Background) != 0)
+ {
+ backgroundsChanged.Add(existing);
+ }
+ }
+
+ return new GameLibraryReconciliation(merged, coversToLoad, backgroundsChanged);
+ }
+
+ ///
+ /// Reorders, inserts and removes only the items needed to reach the desired
+ /// visible sequence
+ ///
+ public static void ReconcileVisibleGames(
+ IList visible,
+ IReadOnlyList desired)
+ {
+ for (var index = 0; index < desired.Count; index++)
+ {
+ var game = desired[index];
+ if (index < visible.Count && ReferenceEquals(visible[index], game))
+ {
+ continue;
+ }
+
+ var existingIndex = -1;
+ for (var candidate = index + 1; candidate < visible.Count; candidate++)
+ {
+ if (ReferenceEquals(visible[candidate], game))
+ {
+ existingIndex = candidate;
+ break;
+ }
+ }
+
+ if (existingIndex >= 0)
+ {
+ var existing = visible[existingIndex];
+ visible.RemoveAt(existingIndex);
+ visible.Insert(index, existing);
+ }
+ else
+ {
+ visible.Insert(index, game);
+ }
+ }
+
+ while (visible.Count > desired.Count)
+ {
+ visible.RemoveAt(visible.Count - 1);
+ }
+ }
+}
diff --git a/src/SharpEmu.GUI/GameLibraryWatcher.cs b/src/SharpEmu.GUI/GameLibraryWatcher.cs
new file mode 100644
index 00000000..204deb4c
--- /dev/null
+++ b/src/SharpEmu.GUI/GameLibraryWatcher.cs
@@ -0,0 +1,147 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.GUI;
+
+///
+/// Watches configured game folders and collapses filesystem bursts into one
+/// library refresh request
+///
+internal sealed class GameLibraryWatcher : IDisposable
+{
+ private static readonly TimeSpan DefaultDebounceInterval = TimeSpan.FromMilliseconds(600);
+
+ private readonly object _sync = new();
+ private readonly TimeSpan _debounceInterval;
+ private readonly List _watchers = [];
+ private Timer? _debounceTimer;
+ private bool _disposed;
+
+ internal GameLibraryWatcher(TimeSpan? debounceInterval = null)
+ {
+ _debounceInterval = debounceInterval ?? DefaultDebounceInterval;
+ }
+
+ public event EventHandler? RefreshRequested;
+
+ ///
+ /// Replaces the watched roots with the current configured game folders
+ ///
+ public void Watch(IReadOnlyList folders)
+ {
+ lock (_sync)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ foreach (var watcher in _watchers)
+ {
+ watcher.Dispose();
+ }
+
+ _watchers.Clear();
+ _debounceTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
+ foreach (var folder in folders.Distinct(GameLibraryPath.Comparer))
+ {
+ if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
+ {
+ continue;
+ }
+
+ try
+ {
+ var watcher = new FileSystemWatcher(folder)
+ {
+ IncludeSubdirectories = true,
+ NotifyFilter = NotifyFilters.FileName
+ | NotifyFilters.DirectoryName
+ | NotifyFilters.LastWrite
+ | NotifyFilters.Size,
+ };
+ watcher.Created += OnFileSystemChanged;
+ watcher.Changed += OnFileSystemChanged;
+ watcher.Deleted += OnFileSystemChanged;
+ watcher.Renamed += OnFileSystemChanged;
+ watcher.Error += OnWatcherError;
+ watcher.EnableRaisingEvents = true;
+ _watchers.Add(watcher);
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException
+ or IOException
+ or UnauthorizedAccessException)
+ {
+ Console.Error.WriteLine(
+ $"[GUI][WARN] Could not watch game folder '{folder}': {exception.Message}");
+ }
+ }
+ }
+ }
+
+ private void OnFileSystemChanged(object sender, FileSystemEventArgs args)
+ => ScheduleRefresh();
+
+ private void OnWatcherError(object sender, ErrorEventArgs args)
+ {
+ Console.Error.WriteLine(
+ $"[GUI][WARN] Game library watcher reported an error: {args.GetException().Message}");
+ ScheduleRefresh();
+ }
+
+ private void ScheduleRefresh()
+ {
+ lock (_sync)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (_debounceTimer is null)
+ {
+ _debounceTimer = new Timer(
+ static state => ((GameLibraryWatcher)state!).RaiseRefreshRequested(),
+ this,
+ _debounceInterval,
+ Timeout.InfiniteTimeSpan);
+ }
+ else
+ {
+ _debounceTimer.Change(_debounceInterval, Timeout.InfiniteTimeSpan);
+ }
+ }
+ }
+
+ private void RaiseRefreshRequested()
+ {
+ lock (_sync)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+ }
+
+ RefreshRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void Dispose()
+ {
+ lock (_sync)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _debounceTimer?.Dispose();
+ _debounceTimer = null;
+ foreach (var watcher in _watchers)
+ {
+ watcher.Dispose();
+ }
+
+ _watchers.Clear();
+ }
+ }
+}
diff --git a/src/SharpEmu.GUI/Languages/ar.json b/src/SharpEmu.GUI/Languages/ar.json
index 55101ce3..486bd353 100644
--- a/src/SharpEmu.GUI/Languages/ar.json
+++ b/src/SharpEmu.GUI/Languages/ar.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "ابحث في المكتبة...",
"Library.AddFolder": "+ إضافة مجلد",
- "Library.Rescan": "⟳ إعادة الفحص",
"Library.OpenFile": "فتح ملف...",
"Library.Context.Launch": "تشغيل",
diff --git a/src/SharpEmu.GUI/Languages/br.json b/src/SharpEmu.GUI/Languages/br.json
index 950ef746..1918f386 100644
--- a/src/SharpEmu.GUI/Languages/br.json
+++ b/src/SharpEmu.GUI/Languages/br.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Pesquisar na biblioteca…",
"Library.AddFolder": "+ Adicionar pasta",
- "Library.Rescan": "⟳ Atualizar biblioteca",
"Library.OpenFile": "Abrir arquivo…",
"Library.Context.Launch": "Jogar",
diff --git a/src/SharpEmu.GUI/Languages/de.json b/src/SharpEmu.GUI/Languages/de.json
index 55f42792..0d366f2a 100644
--- a/src/SharpEmu.GUI/Languages/de.json
+++ b/src/SharpEmu.GUI/Languages/de.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Bibliothek durchsuchen…",
"Library.AddFolder": "+ Spielordner hinzufügen",
- "Library.Rescan": "⟳ Neu scannen",
"Library.OpenFile": "Datei öffnen…",
"Library.Context.Launch": "Starten",
diff --git a/src/SharpEmu.GUI/Languages/dk.json b/src/SharpEmu.GUI/Languages/dk.json
index 5336f235..c047ade1 100644
--- a/src/SharpEmu.GUI/Languages/dk.json
+++ b/src/SharpEmu.GUI/Languages/dk.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Søg i biblioteket…",
"Library.AddFolder": "+ Tilføj mappe",
- "Library.Rescan": "⟳ Genindlæs",
"Library.OpenFile": "Åbn fil…",
"Library.Context.Launch": "Start",
diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json
index ce7d8a46..e4f4bd49 100644
--- a/src/SharpEmu.GUI/Languages/en.json
+++ b/src/SharpEmu.GUI/Languages/en.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Search library…",
"Library.AddFolder": "+ Add folder",
- "Library.Rescan": "⟳ Rescan",
"Library.OpenFile": "Open file…",
"Library.Context.Launch": "Launch",
diff --git a/src/SharpEmu.GUI/Languages/es.json b/src/SharpEmu.GUI/Languages/es.json
index 79f1d784..7267941e 100644
--- a/src/SharpEmu.GUI/Languages/es.json
+++ b/src/SharpEmu.GUI/Languages/es.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Buscar en la biblioteca…",
"Library.AddFolder": "+ Añadir carpeta",
- "Library.Rescan": "⟳ Volver a escanear",
"Library.OpenFile": "Abrir archivo…",
"Library.Context.Launch": "Iniciar",
diff --git a/src/SharpEmu.GUI/Languages/fr.json b/src/SharpEmu.GUI/Languages/fr.json
index da245776..47f7b08c 100644
--- a/src/SharpEmu.GUI/Languages/fr.json
+++ b/src/SharpEmu.GUI/Languages/fr.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
"Library.AddFolder": "+ Ajouter un dossier",
- "Library.Rescan": "⟳ Analyser à nouveau",
"Library.OpenFile": "Ouvrir un fichier…",
"Library.Context.Launch": "Lancer",
diff --git a/src/SharpEmu.GUI/Languages/hu.json b/src/SharpEmu.GUI/Languages/hu.json
index 7bcb8ac5..51803593 100644
--- a/src/SharpEmu.GUI/Languages/hu.json
+++ b/src/SharpEmu.GUI/Languages/hu.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Keresés a könyvtárban",
"Library.AddFolder": "+ Mappa hozzáadása",
- "Library.Rescan": "⟳ Újrakeresés",
"Library.OpenFile": "Fájl megnyitása…",
"Library.Context.Launch": "Inditás",
diff --git a/src/SharpEmu.GUI/Languages/it.json b/src/SharpEmu.GUI/Languages/it.json
index 7646ceb5..3f553f9f 100644
--- a/src/SharpEmu.GUI/Languages/it.json
+++ b/src/SharpEmu.GUI/Languages/it.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Cerca nella libreria…",
"Library.AddFolder": "+ Aggiungi cartella",
- "Library.Rescan": "⟳ Riscansiona",
"Library.OpenFile": "Apri file…",
"Library.Context.Launch": "Avvia",
diff --git a/src/SharpEmu.GUI/Languages/ja.json b/src/SharpEmu.GUI/Languages/ja.json
index 86decc7c..79b186fd 100644
--- a/src/SharpEmu.GUI/Languages/ja.json
+++ b/src/SharpEmu.GUI/Languages/ja.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "ライブラリを検索…",
"Library.AddFolder": "+ フォルダーを追加",
- "Library.Rescan": "⟳ 再スキャン",
"Library.OpenFile": "ファイルを開く…",
"Library.Context.Launch": "起動",
diff --git a/src/SharpEmu.GUI/Languages/ko.json b/src/SharpEmu.GUI/Languages/ko.json
index 03af4c71..1b7d218a 100644
--- a/src/SharpEmu.GUI/Languages/ko.json
+++ b/src/SharpEmu.GUI/Languages/ko.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "라이브러리 검색…",
"Library.AddFolder": "+ 폴더 추가",
- "Library.Rescan": "⟳ 다시 스캔",
"Library.OpenFile": "파일 열기…",
"Library.Context.Launch": "실행",
diff --git a/src/SharpEmu.GUI/Languages/nl.json b/src/SharpEmu.GUI/Languages/nl.json
index 54df184f..8c759f91 100644
--- a/src/SharpEmu.GUI/Languages/nl.json
+++ b/src/SharpEmu.GUI/Languages/nl.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Zoeken in bibliotheek…",
"Library.AddFolder": "+ Map toevoegen",
- "Library.Rescan": "⟳ Opnieuw scannen",
"Library.OpenFile": "Bestand openen…",
"Library.Context.Launch": "Starten",
diff --git a/src/SharpEmu.GUI/Languages/pt.json b/src/SharpEmu.GUI/Languages/pt.json
index ba757fa5..62242f50 100644
--- a/src/SharpEmu.GUI/Languages/pt.json
+++ b/src/SharpEmu.GUI/Languages/pt.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Pesquisar biblioteca…",
"Library.AddFolder": "+ Adicionar pasta",
- "Library.Rescan": "⟳ Reanalisar",
"Library.OpenFile": "Abrir ficheiro…",
"Library.Context.Launch": "Iniciar",
diff --git a/src/SharpEmu.GUI/Languages/ru.json b/src/SharpEmu.GUI/Languages/ru.json
index 924a7f1b..59117b1f 100644
--- a/src/SharpEmu.GUI/Languages/ru.json
+++ b/src/SharpEmu.GUI/Languages/ru.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Поиск…",
"Library.AddFolder": "+ Добавить папку",
- "Library.Rescan": "⟳ Сканировать",
"Library.OpenFile": "Открыть файл…",
"Library.Context.Launch": "Запустить",
diff --git a/src/SharpEmu.GUI/Languages/tr.json b/src/SharpEmu.GUI/Languages/tr.json
index 5132220c..dfa139d7 100644
--- a/src/SharpEmu.GUI/Languages/tr.json
+++ b/src/SharpEmu.GUI/Languages/tr.json
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Kütüphanede ara…",
"Library.AddFolder": "+ Klasör ekle",
- "Library.Rescan": "⟳ Yeniden tara",
"Library.OpenFile": "Dosya aç…",
"Library.Context.Launch": "Başlat",
diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml
index 31169e47..79ba92f7 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml
+++ b/src/SharpEmu.GUI/MainWindow.axaml
@@ -80,7 +80,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
VerticalAlignment="Center">
-
diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs
index f3bbc567..2be7e768 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml.cs
+++ b/src/SharpEmu.GUI/MainWindow.axaml.cs
@@ -41,15 +41,9 @@ public partial class MainWindow : Window
private static readonly IBrush WarningLineBrush = new SolidColorBrush(Color.Parse("#E8B341"));
private static readonly IBrush ErrorLineBrush = new SolidColorBrush(Color.Parse("#F2777C"));
private static readonly IBrush SuccessLineBrush = new SolidColorBrush(Color.Parse("#63D489"));
- private static readonly StringComparer FilePathComparer = OperatingSystem.IsWindows()
- ? StringComparer.OrdinalIgnoreCase
- : StringComparer.Ordinal;
- private static readonly StringComparison FilePathComparison = OperatingSystem.IsWindows()
- ? StringComparison.OrdinalIgnoreCase
- : StringComparison.Ordinal;
-
private readonly List _allGames = new();
private readonly ObservableCollection _visibleGames = new();
+ private readonly GameLibraryWatcher _libraryWatcher = new();
private readonly AvaloniaList _consoleLines = new();
private readonly List _allConsoleLines = new();
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
@@ -85,8 +79,10 @@ public partial class MainWindow : Window
private string? _runningGameName;
private string? _runningGameTitleId;
private long _runningSinceUnixSeconds;
+ private int _libraryScanGeneration;
private int _detailLoadGeneration;
private int _backdropGeneration;
+ private bool _isClosing;
// Bundled key art shown whenever no game-specific backdrop applies; the
// plain window color remains the fallback when the asset fails to load.
@@ -133,6 +129,7 @@ public partial class MainWindow : Window
}
GameList.ItemsSource = _visibleGames;
+ _libraryWatcher.RefreshRequested += OnLibraryRefreshRequested;
ConsoleList.ItemsSource = _consoleLines;
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
_pendingLines.Enqueue((line, isError)));
@@ -173,7 +170,6 @@ public partial class MainWindow : Window
ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines();
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
- RescanButton.Click += async (_, _) => await RescanLibraryAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected();
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
@@ -619,7 +615,6 @@ public partial class MainWindow : Window
SearchBox.PlaceholderText = loc.Get("Library.SearchWatermark");
AddFolderButton.Content = loc.Get("Library.AddFolder");
- RescanButton.Content = loc.Get("Library.Rescan");
OpenFileButton.Content = loc.Get("Library.OpenFile");
CtxLaunch.Header = loc.Get("Library.Context.Launch");
@@ -798,6 +793,10 @@ public partial class MainWindow : Window
private void OnWindowClosing()
{
+ _isClosing = true;
+ Interlocked.Increment(ref _libraryScanGeneration);
+ Interlocked.Increment(ref _detailLoadGeneration);
+ _libraryWatcher.Dispose();
_settings.Save();
_consoleFlushTimer.Stop();
_libraryBlurTimer.Stop();
@@ -1176,7 +1175,7 @@ public partial class MainWindow : Window
}
var changed = false;
- if (!_settings.GameFolders.Contains(path, FilePathComparer))
+ if (!_settings.GameFolders.Contains(path, GameLibraryPath.Comparer))
{
_settings.GameFolders.Add(path);
changed = true;
@@ -1186,7 +1185,7 @@ public partial class MainWindow : Window
// games beneath it that were removed from the library earlier.
var prefix = Path.TrimEndingDirectorySeparator(path) + Path.DirectorySeparatorChar;
changed |= _settings.ExcludedGames.RemoveAll(excluded =>
- excluded.StartsWith(prefix, FilePathComparison)) > 0;
+ excluded.StartsWith(prefix, GameLibraryPath.Comparison)) > 0;
if (changed)
{
@@ -1196,25 +1195,72 @@ public partial class MainWindow : Window
await RescanLibraryAsync();
}
- private async Task RescanLibraryAsync()
+ private void OnLibraryRefreshRequested(object? sender, EventArgs args)
{
+ Dispatcher.UIThread.Post(
+ () =>
+ {
+ if (!_isClosing)
+ {
+ _ = RescanLibraryFromWatcherAsync();
+ }
+ },
+ DispatcherPriority.Background);
+ }
+
+ private async Task RescanLibraryFromWatcherAsync()
+ {
+ try
+ {
+ await RescanLibraryAsync(showProgress: false);
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"[GUI][WARN] Automatic library refresh failed: {exception.Message}");
+ }
+ }
+
+ private async Task RescanLibraryAsync(bool showProgress = true)
+ {
+ Dispatcher.UIThread.VerifyAccess();
+
+ var scanGeneration = Interlocked.Increment(ref _libraryScanGeneration);
var folders = _settings.GameFolders.ToArray();
- var excluded = new HashSet(_settings.ExcludedGames, FilePathComparer);
- StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
- EmptyState.IsVisible = false;
- LoadingState.IsVisible = true;
+ var excluded = new HashSet(_settings.ExcludedGames, GameLibraryPath.Comparer);
+ _libraryWatcher.Watch(folders);
+ var showLoadingState = showProgress && _allGames.Count == 0;
+ if (showProgress)
+ {
+ StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
+ }
+
+ if (showLoadingState)
+ {
+ EmptyState.IsVisible = false;
+ LoadingState.IsVisible = true;
+ }
var games = await Task.Run(() => ScanFolders(folders, excluded));
+ if (_isClosing || scanGeneration != Volatile.Read(ref _libraryScanGeneration))
+ {
+ return;
+ }
+ Dispatcher.UIThread.VerifyAccess();
+ var reconciliation = GameLibraryReconciler.Reconcile(_allGames, games);
_allGames.Clear();
- _allGames.AddRange(games);
- RefreshVisibleGames();
+ _allGames.AddRange(reconciliation.Games);
+ RefreshVisibleGames(reconciliation.BackgroundsChanged);
LoadingState.IsVisible = false;
- LoadGameDetailsInBackground(games);
+ LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
UpdateDiscordPresence();
- StatusBarRight.Text = folders.Length == 0
- ? Localization.Instance.Get("Status.AddFolderPrompt")
- : Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
+ if (showProgress)
+ {
+ StatusBarRight.Text = folders.Length == 0
+ ? Localization.Instance.Get("Status.AddFolderPrompt")
+ : Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
+ }
}
///
@@ -1222,44 +1268,57 @@ public partial class MainWindow : Window
/// game's install folder size — posting results back as they become
/// ready. A newer scan invalidates older loads.
///
- private void LoadGameDetailsInBackground(IReadOnlyList games)
+ private void LoadGameDetailsInBackground(
+ IReadOnlyList coversToLoad,
+ IReadOnlyList gamesToMeasure)
{
var generation = ++_detailLoadGeneration;
_ = Task.Run(() =>
{
// Covers first: they are cheap and the most visible, so the grid
// fills with art before the (potentially slow) size pass runs.
- foreach (var game in games)
+ foreach (var game in coversToLoad)
{
if (generation != _detailLoadGeneration)
{
return;
}
- if (game.CoverPath is null)
+ var requestedCoverPath = game.CoverPath;
+ if (requestedCoverPath is null)
{
continue;
}
try
{
- using var stream = File.OpenRead(game.CoverPath);
+ using var stream = File.OpenRead(requestedCoverPath);
var bitmap = Bitmap.DecodeToWidth(stream, 312);
Dispatcher.UIThread.Post(() =>
{
- if (generation == _detailLoadGeneration)
+ if (generation == _detailLoadGeneration
+ && _allGames.Contains(game)
+ && string.Equals(
+ game.CoverPath,
+ requestedCoverPath,
+ StringComparison.Ordinal))
{
game.Cover = bitmap;
}
+ else
+ {
+ bitmap.Dispose();
+ }
});
}
- catch (Exception)
+ catch (Exception exception)
{
- // A missing or undecodable image keeps the placeholder.
+ Console.Error.WriteLine(
+ $"[GUI][WARN] Could not load cover '{requestedCoverPath}': {exception.Message}");
}
}
- foreach (var game in games)
+ foreach (var game in gamesToMeasure)
{
if (generation != _detailLoadGeneration)
{
@@ -1271,7 +1330,7 @@ public partial class MainWindow : Window
{
Dispatcher.UIThread.Post(() =>
{
- if (generation == _detailLoadGeneration)
+ if (generation == _detailLoadGeneration && _allGames.Contains(game))
{
game.SizeBytes = size;
}
@@ -1306,9 +1365,10 @@ public partial class MainWindow : Window
total += file.Length;
}
}
- catch (Exception)
+ catch (Exception exception)
{
- // Fall back to whatever was accumulated so far.
+ Console.Error.WriteLine(
+ $"[GUI][WARN] Could not measure game folder '{directory}': {exception.Message}");
}
return total;
@@ -1317,7 +1377,7 @@ public partial class MainWindow : Window
private static List ScanFolders(IReadOnlyList folders, IReadOnlySet excludedPaths)
{
var games = new List();
- var seen = new HashSet(FilePathComparer);
+ var seen = new HashSet(GameLibraryPath.Comparer);
var enumeration = new EnumerationOptions
{
IgnoreInaccessible = true,
@@ -1347,8 +1407,10 @@ public partial class MainWindow : Window
{
size = new FileInfo(fullPath).Length;
}
- catch (IOException)
+ catch (IOException exception)
{
+ Console.Error.WriteLine(
+ $"[GUI][WARN] Could not inspect executable '{fullPath}': {exception.Message}");
}
var (title, titleId, version) = TryReadParamJson(fullPath);
@@ -1357,9 +1419,10 @@ public partial class MainWindow : Window
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
}
}
- catch (Exception)
+ catch (Exception exception)
{
- // Skip folders that fail to enumerate.
+ Console.Error.WriteLine(
+ $"[GUI][WARN] Could not scan game folder '{folder}': {exception.Message}");
}
}
@@ -1600,24 +1663,26 @@ public partial class MainWindow : Window
return;
}
- if (!_settings.ExcludedGames.Contains(game.Path, FilePathComparer))
+ if (!_settings.ExcludedGames.Contains(game.Path, GameLibraryPath.Comparer))
{
_settings.ExcludedGames.Add(game.Path);
_settings.Save();
}
- _allGames.RemoveAll(g => string.Equals(g.Path, game.Path, FilePathComparison));
+ _allGames.RemoveAll(g =>
+ string.Equals(g.Path, game.Path, GameLibraryPath.Comparison));
GameList.SelectedItem = null;
RefreshVisibleGames();
StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name);
}
- private void RefreshVisibleGames()
+ private void RefreshVisibleGames(IReadOnlySet? backgroundsChanged = null)
{
var query = SearchBox.Text?.Trim() ?? string.Empty;
- var selectedPath = (GameList.SelectedItem as GameEntry)?.Path;
+ var selectedBefore = GameList.SelectedItem as GameEntry;
+ var selectedPath = selectedBefore?.Path;
+ var desired = new List(_allGames.Count);
- _visibleGames.Clear();
foreach (var game in _allGames)
{
if (query.Length == 0 ||
@@ -1625,21 +1690,37 @@ public partial class MainWindow : Window
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
{
- _visibleGames.Add(game);
+ desired.Add(game);
}
}
- if (selectedPath is not null &&
- _visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, FilePathComparison))
- is { } reselected)
+ GameLibraryReconciler.ReconcileVisibleGames(_visibleGames, desired);
+
+ var selectedAfter = selectedPath is null
+ ? null
+ : _visibleGames.FirstOrDefault(game =>
+ game.Path.Equals(selectedPath, GameLibraryPath.Comparison));
+ if (!ReferenceEquals(GameList.SelectedItem, selectedAfter))
{
- GameList.SelectedItem = reselected;
+ GameList.SelectedItem = selectedAfter;
}
EmptyState.IsVisible = _visibleGames.Count == 0;
UpdateEmptyStateTexts();
- UpdateSelectedGame();
+ if (!ReferenceEquals(selectedBefore, selectedAfter))
+ {
+ UpdateSelectedGame();
+ }
+ else
+ {
+ UpdateSelectedGameTexts();
+ UpdateRunButtons();
+ if (selectedAfter is not null && backgroundsChanged?.Contains(selectedAfter) == true)
+ {
+ _ = UpdateBackdropAsync(selectedAfter);
+ }
+ }
}
///
@@ -1861,7 +1942,8 @@ public partial class MainWindow : Window
}
var resolvedTitleId = string.IsNullOrWhiteSpace(titleId)
- ? _allGames.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId
+ ? _allGames.FirstOrDefault(game =>
+ game.Path.Equals(ebootPath, GameLibraryPath.Comparison))?.TitleId
: titleId;
var effective = EffectiveLaunchSettings.Resolve(_settings, PerGameSettings.Load(resolvedTitleId));
diff --git a/tests/SharpEmu.Libs.Tests/GUI/GameLibraryReconcilerTests.cs b/tests/SharpEmu.Libs.Tests/GUI/GameLibraryReconcilerTests.cs
new file mode 100644
index 00000000..64ca37b4
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/GUI/GameLibraryReconcilerTests.cs
@@ -0,0 +1,100 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Collections.ObjectModel;
+using SharpEmu.GUI;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.GUI;
+
+public sealed class GameLibraryReconcilerTests
+{
+ [Fact]
+ public void Reconcile_PreservesExistingEntriesAndAppliesMetadataChanges()
+ {
+ var retained = CreateGame("retained", name: "Old name", version: "01.000");
+ var removed = CreateGame("removed");
+ var scannedRetained = CreateGame(
+ "retained",
+ name: "New name",
+ version: "02.000");
+ var added = CreateGame("added");
+
+ var result = GameLibraryReconciler.Reconcile(
+ [retained, removed],
+ [scannedRetained, added]);
+
+ Assert.Equal(2, result.Games.Count);
+ Assert.Same(retained, result.Games[0]);
+ Assert.Same(added, result.Games[1]);
+ Assert.Equal("New name", retained.Name);
+ Assert.Equal("02.000", retained.Version);
+ Assert.DoesNotContain(removed, result.Games);
+ }
+
+ [Fact]
+ public void Reconcile_ChangedCoverAndBackgroundReloadsExistingEntry()
+ {
+ var retained = CreateGame(
+ "retained",
+ coverPath: AssetPath("old-cover.png"),
+ backgroundPath: AssetPath("old-background.png"));
+ var scanned = CreateGame(
+ "retained",
+ coverPath: AssetPath("new-cover.png"),
+ backgroundPath: AssetPath("new-background.png"));
+
+ var result = GameLibraryReconciler.Reconcile([retained], [scanned]);
+
+ Assert.Same(retained, Assert.Single(result.Games));
+ Assert.Same(retained, Assert.Single(result.CoversToLoad));
+ Assert.Contains(retained, result.BackgroundsChanged);
+ Assert.Equal(scanned.CoverPath, retained.CoverPath);
+ Assert.Equal(scanned.BackgroundPath, retained.BackgroundPath);
+ }
+
+ [Fact]
+ public void ReconcileVisibleGames_UsesMinimalIdentityPreservingChanges()
+ {
+ var first = CreateGame("first");
+ var second = CreateGame("second");
+ var removed = CreateGame("removed");
+ var added = CreateGame("added");
+ var visible = new ObservableCollection
+ {
+ first,
+ second,
+ removed,
+ };
+
+ GameLibraryReconciler.ReconcileVisibleGames(
+ visible,
+ [second, first, added]);
+
+ Assert.Equal([second, first, added], visible);
+ Assert.Same(second, visible[0]);
+ Assert.Same(first, visible[1]);
+ Assert.Same(added, visible[2]);
+ }
+
+ private static GameEntry CreateGame(
+ string id,
+ string? name = null,
+ string? version = null,
+ string? coverPath = null,
+ string? backgroundPath = null)
+ => new(
+ name ?? id,
+ $"PPSA-{id}",
+ version,
+ GamePath(id),
+ 1,
+ coverPath,
+ backgroundPath);
+
+ private static string GamePath(string id)
+ => Path.GetFullPath(Path.Combine("library-tests", id, "eboot.bin"));
+
+ private static string AssetPath(string name)
+ => Path.GetFullPath(Path.Combine("library-tests", "assets", name));
+}
diff --git a/tests/SharpEmu.Libs.Tests/GUI/GameLibraryWatcherTests.cs b/tests/SharpEmu.Libs.Tests/GUI/GameLibraryWatcherTests.cs
new file mode 100644
index 00000000..b2f7426a
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/GUI/GameLibraryWatcherTests.cs
@@ -0,0 +1,38 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.GUI;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.GUI;
+
+public sealed class GameLibraryWatcherTests
+{
+ [Fact]
+ public async Task Watch_FileCreation_RequestsRefresh()
+ {
+ var directory = Path.Combine(
+ Path.GetTempPath(),
+ $"sharpemu-library-watcher-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(directory);
+
+ try
+ {
+ using var watcher = new GameLibraryWatcher(TimeSpan.FromMilliseconds(25));
+ var refreshRequested = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ watcher.RefreshRequested += (_, _) => refreshRequested.TrySetResult();
+ watcher.Watch([directory]);
+
+ await File.WriteAllTextAsync(
+ Path.Combine(directory, "eboot.bin"),
+ "test");
+
+ await refreshRequested.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+}