// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using Avalonia; using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Threading; using Avalonia.VisualTree; using SharpEmu.Libs.Pad; using SharpEmu.Logging; using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.Diagnostics; using System.Reflection; using System.Text.Json; namespace SharpEmu.GUI; public partial class MainWindow : Window { private const int MaxConsoleLines = 4000; private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE")); private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488")); private static readonly IBrush InfoLineBrush = new SolidColorBrush(Color.Parse("#6FA8FF")); 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 readonly List _allGames = new(); private readonly ObservableCollection _visibleGames = new(); private readonly AvaloniaList _consoleLines = new(); private readonly List _allConsoleLines = new(); private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new(); private readonly DispatcherTimer _consoleFlushTimer; private GuiSettings _settings = new(); private EmulatorProcess? _emulator; private ConsoleWindow? _consoleWindow; private StreamWriter? _fileLog; private readonly SndPreviewPlayer _sndPreview = new(); private string? _emulatorExePath; private bool _isRunning; private int _autoScrollTicks; private int _activePageIndex; // Discord Rich Presence state. private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); private DiscordRichPresence? _discord; private string? _runningGameName; private string? _runningGameTitleId; private long _runningSinceUnixSeconds; private int _detailLoadGeneration; private int _backdropGeneration; // Controller navigation state. private readonly DispatcherTimer _gamepadTimer; private uint _previousPadButtons; private long _navLeftNextAt; private long _navRightNextAt; private long _navUpNextAt; private long _navDownNextAt; public MainWindow() { InitializeComponent(); GameList.ItemsSource = _visibleGames; ConsoleList.ItemsSource = _consoleLines; _consoleFlushTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(80), }; _consoleFlushTimer.Tick += (_, _) => { FlushPendingConsoleLines(); MaybeAutoScroll(); }; _consoleFlushTimer.Start(); TitleBar.PointerPressed += OnTitleBarPointerPressed; GameList.SelectionChanged += (_, _) => UpdateSelectedGame(); GameList.DoubleTapped += (_, _) => LaunchSelected(); SearchBox.TextChanged += (_, _) => RefreshVisibleGames(); 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(); StopButton.Click += (_, _) => _emulator?.Stop(); ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); }; StopButton.Click += (_, _) => StopEmulator(); ClearLogButton.Click += (_, _) => _consoleLines.Clear(); CopyLogButton.Click += async (_, _) => await CopyConsoleAsync(); DetachConsoleButton.Click += (_, _) => ShowConsoleWindow(); LibraryTabButton.Click += (_, _) => SetActivePage(0); OptionsTabButton.Click += (_, _) => SetActivePage(1); ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null; // The settings page edits _settings live, so a launch started while // it is open already uses the new values. LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel(); TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0); StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true; LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true; OverrideLogFileToggle.IsCheckedChanged += (_, _) => _settings.OverrideLogFile = OverrideLogFileToggle.IsChecked == true; TitleMusicToggle.IsCheckedChanged += (_, _) => { _settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true; OnTitleMusicSettingChanged(); }; DiscordToggle.IsCheckedChanged += (_, _) => { _settings.DiscordRichPresence = DiscordToggle.IsChecked == true; UpdateDiscordPresence(); }; SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync(); LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged(); GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel); CtxLaunch.Click += (_, _) => LaunchSelected(); CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder(); CtxCopyPath.Click += async (_, _) => await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path"); CtxCopyTitleId.Click += async (_, _) => await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId"); CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary(); Opened += async (_, _) => await OnOpenedAsync(); Closing += (_, _) => OnWindowClosing(); DualSenseReader.EnsureStarted(); XInputReader.EnsureStarted(); _gamepadTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50), }; _gamepadTimer.Tick += (_, _) => PollGamepad(); _gamepadTimer.Start(); } /// /// Switches between the Library and Options pages. Also reachable via /// the gamepad's shoulder buttons (LB/RB, L1/R1) from . /// private void SetActivePage(int index) { if (index == _activePageIndex) { return; } if (_activePageIndex == 1) { _settings.Save(); // leaving the Options page } _activePageIndex = index; SetActiveClass(LibraryTabButton, index == 0); SetActiveClass(OptionsTabButton, index == 1); LibraryPage.IsVisible = index == 0; LibraryToolbar.IsVisible = index == 0; OptionsPage.IsVisible = index == 1; } private static void SetActiveClass(Button button, bool active) { if (active) { if (!button.Classes.Contains("active")) { button.Classes.Add("active"); } } else { button.Classes.Remove("active"); } } // ---- Controller navigation ---- private void PollGamepad() { // DualSense wins when both are connected; XInput covers Xbox pads. if (!DualSenseReader.TryGetState(out var pad) && !XInputReader.TryGetState(out pad)) { _previousPadButtons = 0; return; } if (!IsActive) { // Ignore input while the launcher is in the background, e.g. the // game window is focused and using the same controller. _previousPadButtons = pad.Buttons; return; } var shoulderPressed = pad.Buttons & ~_previousPadButtons; if ((shoulderPressed & OrbisPadButton.L1) != 0) { SetActivePage(0); } if ((shoulderPressed & OrbisPadButton.R1) != 0) { SetActivePage(1); } if (_activePageIndex != 0) { _previousPadButtons = pad.Buttons; return; } var now = Environment.TickCount64; var left = (pad.Buttons & 0x0080) != 0 || pad.LeftX < 64; var right = (pad.Buttons & 0x0020) != 0 || pad.LeftX > 192; var up = (pad.Buttons & 0x0010) != 0 || pad.LeftY < 64; var down = (pad.Buttons & 0x0040) != 0 || pad.LeftY > 192; if (ShouldNavigate(left, ref _navLeftNextAt, now)) { MoveSelection(-1); } if (ShouldNavigate(right, ref _navRightNextAt, now)) { MoveSelection(1); } if (ShouldNavigate(up, ref _navUpNextAt, now)) { MoveSelection(-TilesPerRow()); } if (ShouldNavigate(down, ref _navDownNextAt, now)) { MoveSelection(TilesPerRow()); } var pressed = pad.Buttons & ~_previousPadButtons; if ((pressed & 0x4000) != 0) // Cross { LaunchSelected(); } if ((pressed & 0x2000) != 0) // Circle { StopEmulator(); } _previousPadButtons = pad.Buttons; } /// /// Edge-triggered with hold-to-repeat: fires on press, then repeats /// after 400ms at 130ms intervals while held. /// private static bool ShouldNavigate(bool held, ref long nextAt, long now) { if (!held) { nextAt = 0; return false; } if (nextAt == 0) { nextAt = now + 400; return true; } if (now >= nextAt) { nextAt = now + 130; return true; } return false; } private void MoveSelection(int delta) { if (_visibleGames.Count == 0) { return; } var index = GameList.SelectedIndex < 0 ? 0 : Math.Clamp(GameList.SelectedIndex + delta, 0, _visibleGames.Count - 1); GameList.SelectedIndex = index; GameList.ScrollIntoView(index); } private int TilesPerRow() { // Tile footprint: 128 content + 20 item padding + 10 item margin. const double TileOuterWidth = 158; var width = GameList.Bounds.Width; return width > TileOuterWidth ? (int)(width / TileOuterWidth) : 1; } private async Task OnOpenedAsync() { var version = Assembly.GetExecutingAssembly().GetName().Version; var display = version is not null ? $"v{version.ToString(3)}" : "v0.0.1"; display += BuildInfo.CommitSha is null ? " · dev" : BuildInfo.IsOfficialRelease ? $" · {BuildInfo.CommitSha}" : $" · UNOFFICIAL {BuildInfo.CommitSha}"; VersionText.Text = display; Title = $"SharpEmu {display}"; ToolTip.SetTip(VersionText, BuildInfo.Banner); _settings = GuiSettings.Load(); Localization.Instance.Load(_settings.Language); PopulateLanguageBox(); ApplyLocalization(); ApplySettingsToControls(); LocateEmulator(); UpdateDiscordPresence(); await RescanLibraryAsync(); } private void PopulateLanguageBox() { var languages = Localization.Instance.DiscoverLanguages(); LanguageBox.ItemsSource = languages; LanguageBox.SelectedItem = languages.FirstOrDefault(language => string.Equals(language.Code, _settings.Language, StringComparison.OrdinalIgnoreCase)) ?? languages.FirstOrDefault(); } private void OnLanguageChanged() { if (LanguageBox.SelectedItem is not Localization.LanguageInfo language) { return; } _settings.Language = language.Code; Localization.Instance.Load(language.Code); ApplyLocalization(); } /// /// Re-applies every UI string from the current language, so switching /// languages in Options takes effect immediately without reopening the /// window. /// private void ApplyLocalization() { var loc = Localization.Instance; LibraryTabButton.Content = loc.Get("Page.Library"); OptionsTabButton.Content = loc.Get("Page.Options"); SearchBox.Watermark = 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"); CtxOpenFolder.Header = loc.Get("Library.Context.OpenFolder"); CtxCopyPath.Header = loc.Get("Library.Context.CopyPath"); CtxCopyTitleId.Header = loc.Get("Library.Context.CopyTitleId"); CtxRemove.Header = loc.Get("Library.Context.Remove"); EmptyAddFolderButton.Content = loc.Get("Library.Empty.AddFolder"); LoadingStateText.Text = loc.Get("Library.Loading"); GeneralTabItem.Header = loc.Get("Options.General"); EmulationSectionTitle.Text = loc.Get("Options.Section.Emulation"); LoggingSectionTitle.Text = loc.Get("Options.Section.Logging"); LauncherSectionTitle.Text = loc.Get("Options.Section.Launcher"); CpuEngineLabel.Text = loc.Get("Options.CpuEngine.Label"); CpuEngineDesc.Text = loc.Get("Options.CpuEngine.Desc"); CpuEngineNativeItem.Content = loc.Get("Options.CpuEngine.Native"); StrictLabel.Text = loc.Get("Options.Strict.Label"); StrictDesc.Text = loc.Get("Options.Strict.Desc"); LogLevelLabel.Text = loc.Get("Options.LogLevel.Label"); LogLevelDesc.Text = loc.Get("Options.LogLevel.Desc"); LogLevelTraceItem.Content = loc.Get("Options.LogLevel.Trace"); LogLevelDebugItem.Content = loc.Get("Options.LogLevel.Debug"); LogLevelInfoItem.Content = loc.Get("Options.LogLevel.Info"); LogLevelWarningItem.Content = loc.Get("Options.LogLevel.Warning"); LogLevelErrorItem.Content = loc.Get("Options.LogLevel.Error"); LogLevelCriticalItem.Content = loc.Get("Options.LogLevel.Critical"); TraceImportsLabel.Text = loc.Get("Options.TraceImports.Label"); TraceImportsDesc.Text = loc.Get("Options.TraceImports.Desc"); LogToFileLabel.Text = loc.Get("Options.LogToFile.Label"); LogToFileDesc.Text = loc.Get("Options.LogToFile.Desc"); LogFilePathLabel.Text = loc.Get("Options.LogFilePath.Label"); SelectLogFilePathButton.Content = loc.Get("Options.LogFilePath.Select"); UpdateLogFilePathText(); OverrideLogFileLabel.Text = loc.Get("Options.OverrideLogFile.Label"); OverrideLogFileDesc.Text = loc.Get("Options.OverrideLogFile.Desc"); LanguageLabel.Text = loc.Get("Options.Language.Label"); LanguageDesc.Text = loc.Get("Options.Language.Desc"); TitleMusicLabel.Text = loc.Get("Options.TitleMusic.Label"); TitleMusicDesc.Text = loc.Get("Options.TitleMusic.Desc"); DiscordLabel.Text = loc.Get("Options.Discord.Label"); DiscordDesc.Text = loc.Get("Options.Discord.Desc"); foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle }) { toggle.OnContent = loc.Get("Common.On"); toggle.OffContent = loc.Get("Common.Off"); } ConsoleSectionTitle.Text = loc.Get("Console.Title"); ConsoleSearchBox.Watermark = loc.Get("Console.SearchWatermark"); AutoScrollCheck.Content = loc.Get("Console.AutoScroll"); DetachConsoleButton.Content = loc.Get("Console.Split"); CopyLogButton.Content = loc.Get("Console.Copy"); ClearLogButton.Content = loc.Get("Console.Clear"); ConsoleToggle.Content = loc.Get("Launch.Console"); LaunchButton.Content = loc.Get("Launch.Launch"); StopButton.Content = loc.Get("Launch.Stop"); UpdateEmptyStateTexts(); UpdateSelectedGameTexts(); } // ---- Discord Rich Presence ---- /// /// Publishes the launcher state to Discord: browsing while idle, the /// running game (with elapsed time) during emulation. No-ops when /// disabled or when no Discord application ID is configured. /// private void UpdateDiscordPresence() { if (!_settings.DiscordRichPresence || _settings.DiscordClientId.Length == 0) { _discord?.Dispose(); _discord = null; return; } _discord ??= new DiscordRichPresence(_settings.DiscordClientId); if (_isRunning && _runningGameName is { } gameName) { _discord.SetPresence( Localization.Instance.Format("Discord.Playing", gameName), _runningGameTitleId, _runningSinceUnixSeconds); } else { // Discord does not render activities without timestamps, so the // browsing state carries the launcher's start time. var count = _allGames.Count == 1 ? Localization.Instance.Get("Page.GameCount.One") : Localization.Instance.Format("Page.GameCount.Other", _allGames.Count); _discord.SetPresence( Localization.Instance.Get("Discord.Browsing"), count, _launcherStartUnixSeconds); } } private void OnKeyDown(object sender, KeyEventArgs args) { args.Handled = true; switch (args.Key) { case Key.F11: OnWindowFullScreen(this, new RoutedEventArgs()); break; default: args.Handled = false; break; } } private void OnWindowFullScreen(object sender, RoutedEventArgs args) { if (WindowState == WindowState.FullScreen) { WindowState = WindowState.Normal; ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome; TitleBar.IsVisible = true; StatusBar.IsVisible = true; } else { WindowState = WindowState.FullScreen; ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome; TitleBar.IsVisible = false; StatusBar.IsVisible = false; } } private void OnWindowClosing() { _settings.Save(); _consoleFlushTimer.Stop(); _gamepadTimer.Stop(); _sndPreview.Stop(); _discord?.Dispose(); _consoleWindow?.Close(); _emulator?.Dispose(); DropFileLog(); } private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) { if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { BeginMoveDrag(e); } } // ---- Settings ---- private void ApplySettingsToControls() { LogLevelBox.SelectedIndex = _settings.LogLevel.ToLowerInvariant() switch { "trace" => 0, "debug" => 1, "info" => 2, "warning" or "warn" => 3, "error" => 4, "critical" or "fatal" => 5, _ => 2, }; TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096); StrictToggle.IsChecked = _settings.StrictDynlibResolution; LogToFileToggle.IsChecked = _settings.LogToFile; OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile; TitleMusicToggle.IsChecked = _settings.PlayTitleMusic; DiscordToggle.IsChecked = _settings.DiscordRichPresence; UpdateLogFilePathText(); } private string SelectedLogLevel() { return LogLevelBox.SelectedIndex switch { 0 => "Trace", 1 => "Debug", 2 => "Info", 3 => "Warning", 4 => "Error", 5 => "Critical", _ => "Info", }; } private void UpdateLogFilePathText() { LogFilePathText.Text = string.IsNullOrWhiteSpace(_settings.LogFilePath) ? Localization.Instance.Get("Options.LogFilePath.Default") : _settings.LogFilePath; } private async Task SelectLogFilePathAsync() { var loc = Localization.Instance; SaveFilePickerResult result = await StorageProvider.SaveFilePickerWithResultAsync(new FilePickerSaveOptions { Title = loc.Get("Dialog.SaveLogFile"), SuggestedFileName = "SharpEmuLog", DefaultExtension = "log", FileTypeChoices = [ new FilePickerFileType(loc.Get("Dialog.PlainTextFiles")) { Patterns = ["*.txt"] }, new FilePickerFileType(loc.Get("Dialog.LogFiles")) { Patterns = ["*.log"] } ] }); if (result.File is not null) { _settings.LogFilePath = result.File.Path.LocalPath; UpdateLogFilePathText(); } } // ---- Emulator discovery ---- private void LocateEmulator() { var exeName = OperatingSystem.IsWindows() ? "SharpEmu.exe" : "SharpEmu"; var baseDirectory = AppContext.BaseDirectory; var candidates = new List(); if (!string.IsNullOrWhiteSpace(_settings.EmulatorPath)) { candidates.Add(_settings.EmulatorPath); } // The GUI and the CLI are the same executable: with arguments it runs // the emulator, so the preferred child process is this process itself. if (Environment.ProcessPath is { } selfPath && Path.GetFileNameWithoutExtension(selfPath).Equals("SharpEmu", StringComparison.OrdinalIgnoreCase)) { candidates.Add(selfPath); } candidates.Add(Path.Combine(baseDirectory, exeName)); candidates.Add(Path.Combine(baseDirectory, "win-x64", exeName)); candidates.Add(Path.Combine(baseDirectory, "..", exeName)); _emulatorExePath = candidates.FirstOrDefault(File.Exists) is { } found ? Path.GetFullPath(found) : null; EmulatorPathText.Text = _emulatorExePath is not null ? Localization.Instance.Format("Status.EmulatorPath", _emulatorExePath) : Localization.Instance.Get("Status.EmulatorNotFound"); } // ---- Game library ---- private async Task AddFolderAsync() { var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = Localization.Instance.Get("Dialog.ChooseGameFolder"), AllowMultiple = false, }); var path = folders.FirstOrDefault()?.TryGetLocalPath(); if (string.IsNullOrEmpty(path)) { return; } var changed = false; if (!_settings.GameFolders.Contains(path, StringComparer.OrdinalIgnoreCase)) { _settings.GameFolders.Add(path); changed = true; } // Adding (or re-adding) a folder is an explicit signal to restore any // 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, StringComparison.OrdinalIgnoreCase)) > 0; if (changed) { _settings.Save(); } await RescanLibraryAsync(); } private async Task RescanLibraryAsync() { var folders = _settings.GameFolders.ToArray(); var excluded = new HashSet(_settings.ExcludedGames, StringComparer.OrdinalIgnoreCase); StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary"); EmptyState.IsVisible = false; LoadingState.IsVisible = true; var games = await Task.Run(() => ScanFolders(folders, excluded)); _allGames.Clear(); _allGames.AddRange(games); RefreshVisibleGames(); LoadingState.IsVisible = false; LoadGameDetailsInBackground(games); UpdateDiscordPresence(); StatusBarRight.Text = folders.Length == 0 ? Localization.Instance.Get("Status.AddFolderPrompt") : Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length); } /// /// Enriches games off the UI thread — decodes cover art and totals each /// game's install folder size — posting results back as they become /// ready. A newer scan invalidates older loads. /// private void LoadGameDetailsInBackground(IReadOnlyList games) { 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) { if (generation != _detailLoadGeneration) { return; } if (game.CoverPath is null) { continue; } try { using var stream = File.OpenRead(game.CoverPath); var bitmap = Bitmap.DecodeToWidth(stream, 312); Dispatcher.UIThread.Post(() => { if (generation == _detailLoadGeneration) { game.Cover = bitmap; } }); } catch (Exception) { // A missing or undecodable image keeps the placeholder. } } foreach (var game in games) { if (generation != _detailLoadGeneration) { return; } var size = ComputeInstallSize(game.Path); if (size > 0) { Dispatcher.UIThread.Post(() => { if (generation == _detailLoadGeneration) { game.SizeBytes = size; } }); } } }); } /// /// Totals the size of the game's install folder (the directory holding /// the eboot), which is far more accurate than the eboot alone. /// private static long ComputeInstallSize(string ebootPath) { var directory = Path.GetDirectoryName(ebootPath); if (directory is null) { return 0; } long total = 0; try { var enumeration = new EnumerationOptions { IgnoreInaccessible = true, RecurseSubdirectories = true, }; foreach (var file in new DirectoryInfo(directory).EnumerateFiles("*", enumeration)) { total += file.Length; } } catch (Exception) { // Fall back to whatever was accumulated so far. } return total; } private static List ScanFolders(IReadOnlyList folders, IReadOnlySet excludedPaths) { var games = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var enumeration = new EnumerationOptions { IgnoreInaccessible = true, RecurseSubdirectories = true, MaxRecursionDepth = 8, }; foreach (var folder in folders) { if (!Directory.Exists(folder)) { continue; } try { foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration)) { var fullPath = Path.GetFullPath(file); if (!seen.Add(fullPath) || excludedPaths.Contains(fullPath)) { continue; } long size = 0; try { size = new FileInfo(fullPath).Length; } catch (IOException) { } var (title, titleId, version) = TryReadParamJson(fullPath); games.Add(new GameEntry( title ?? GameNameFor(fullPath), titleId, version, fullPath, size, FindCoverFor(fullPath), FindBackgroundFor(fullPath))); } } catch (Exception) { // Skip folders that fail to enumerate. } } games.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); return games; } /// /// Reads the game title, title id and content version from /// sce_sys/param.json next to the executable, when present. /// private static (string? Title, string? TitleId, string? Version) TryReadParamJson(string ebootPath) { try { var directory = Path.GetDirectoryName(ebootPath); if (directory is null) { return (null, null, null); } var paramPath = Path.Combine(directory, "sce_sys", "param.json"); if (!File.Exists(paramPath)) { return (null, null, null); } // ReadAllText handles a UTF-8 BOM, which JsonDocument rejects in // raw bytes. using var document = JsonDocument.Parse(File.ReadAllText(paramPath)); var root = document.RootElement; string? titleId = null; if (root.TryGetProperty("titleId", out var idElement) && idElement.ValueKind == JsonValueKind.String) { titleId = idElement.GetString(); } // contentVersion carries the installed app version // ("01.000.000"); masterVersion is the fallback on older dumps. string? version = null; if (root.TryGetProperty("contentVersion", out var versionElement) && versionElement.ValueKind == JsonValueKind.String) { version = versionElement.GetString(); } else if (root.TryGetProperty("masterVersion", out var masterElement) && masterElement.ValueKind == JsonValueKind.String) { version = masterElement.GetString(); } string? title = null; if (root.TryGetProperty("localizedParameters", out var localized) && localized.ValueKind == JsonValueKind.Object) { if (localized.TryGetProperty("defaultLanguage", out var language) && language.ValueKind == JsonValueKind.String && localized.TryGetProperty(language.GetString()!, out var defaultBlock) && defaultBlock.ValueKind == JsonValueKind.Object && defaultBlock.TryGetProperty("titleName", out var titleName) && titleName.ValueKind == JsonValueKind.String) { title = titleName.GetString(); } else { foreach (var property in localized.EnumerateObject()) { if (property.Value.ValueKind == JsonValueKind.Object && property.Value.TryGetProperty("titleName", out var anyTitleName) && anyTitleName.ValueKind == JsonValueKind.String) { title = anyTitleName.GetString(); break; } } } } return ( string.IsNullOrWhiteSpace(title) ? null : title, string.IsNullOrWhiteSpace(titleId) ? null : titleId, string.IsNullOrWhiteSpace(version) ? null : version.Trim()); } catch (Exception) { return (null, null, null); } } /// /// Finds the cover art shipped with the game: sce_sys/icon0.png next to /// the executable (falling back to pic0.png). /// private static string? FindCoverFor(string ebootPath) { var directory = Path.GetDirectoryName(ebootPath); if (directory is null) { return null; } var sceSys = Path.Combine(directory, "sce_sys"); foreach (var candidate in new[] { "icon0.png", "pic0.png" }) { var coverPath = Path.Combine(sceSys, candidate); if (File.Exists(coverPath)) { return coverPath; } } return null; } /// /// Finds the key art shipped with the game (sce_sys/pic0.png, falling /// back to pic1.png), used as the window backdrop when selected. /// private static string? FindBackgroundFor(string ebootPath) { var directory = Path.GetDirectoryName(ebootPath); if (directory is null) { return null; } var sceSys = Path.Combine(directory, "sce_sys"); foreach (var candidate in new[] { "pic0.png", "pic1.png" }) { var backgroundPath = Path.Combine(sceSys, candidate); if (File.Exists(backgroundPath)) { return backgroundPath; } } return null; } private static string GameNameFor(string ebootPath) { var directory = Path.GetDirectoryName(ebootPath); var name = directory is not null ? Path.GetFileName(directory) : null; return string.IsNullOrEmpty(name) ? Path.GetFileName(ebootPath) : name; } // ---- Game context menu ---- /// /// Selects the tile under the pointer before its context menu opens, and /// suppresses the menu on empty grid space. /// private void OnGameContextRequested(object? sender, ContextRequestedEventArgs e) { var item = (e.Source as Visual)?.FindAncestorOfType(includeSelf: true); if (item?.DataContext is not GameEntry game) { e.Handled = true; return; } GameList.SelectedItem = game; CtxLaunch.IsEnabled = !_isRunning; CtxCopyTitleId.IsEnabled = game.TitleId is not null; } private void OpenSelectedGameFolder() { if (GameList.SelectedItem is not GameEntry game) { return; } try { if (OperatingSystem.IsWindows()) { Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"/select,\"{game.Path}\"", UseShellExecute = false, }); } else if (Path.GetDirectoryName(game.Path) is { } directory) { Process.Start(new ProcessStartInfo { FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open", Arguments = $"\"{directory}\"", UseShellExecute = false, }); } } catch (Exception ex) { StatusBarRight.Text = Localization.Instance.Format("Status.CouldNotOpenFolder", ex.Message); } } /// Copies and reports it via , e.g. "Clipboard.Path". private async Task CopyToClipboardAsync(string? text, string whatKey) { if (string.IsNullOrEmpty(text) || Clipboard is null) { return; } await Clipboard.SetTextAsync(text); StatusBarRight.Text = Localization.Instance.Format("Status.CopiedToClipboard", Localization.Instance.Get(whatKey)); } private void RemoveSelectedFromLibrary() { if (GameList.SelectedItem is not GameEntry game) { return; } if (!_settings.ExcludedGames.Contains(game.Path, StringComparer.OrdinalIgnoreCase)) { _settings.ExcludedGames.Add(game.Path); _settings.Save(); } _allGames.RemoveAll(g => string.Equals(g.Path, game.Path, StringComparison.OrdinalIgnoreCase)); GameList.SelectedItem = null; RefreshVisibleGames(); StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name); } private void RefreshVisibleGames() { var query = SearchBox.Text?.Trim() ?? string.Empty; var selectedPath = (GameList.SelectedItem as GameEntry)?.Path; _visibleGames.Clear(); foreach (var game in _allGames) { if (query.Length == 0 || game.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) || (game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false)) { _visibleGames.Add(game); } } if (selectedPath is not null && _visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, StringComparison.OrdinalIgnoreCase)) is { } reselected) { GameList.SelectedItem = reselected; } EmptyState.IsVisible = _visibleGames.Count == 0; UpdateEmptyStateTexts(); UpdateSelectedGame(); } /// /// Refreshes the empty-state title/hint from the current language and /// search text; a no-op while the empty state is not showing. /// private void UpdateEmptyStateTexts() { if (_visibleGames.Count != 0) { return; } var query = SearchBox.Text?.Trim() ?? string.Empty; var hasFilter = query.Length > 0; EmptyStateTitle.Text = hasFilter ? Localization.Instance.Get("Library.Empty.SearchTitle") : Localization.Instance.Get("Library.Empty.Title"); EmptyStateHint.Text = hasFilter ? Localization.Instance.Format("Library.Empty.SearchHint", query) : Localization.Instance.Get("Library.Empty.Hint"); EmptyAddFolderButton.IsVisible = !hasFilter; } private void UpdateSelectedGame() { if (GameList.SelectedItem is GameEntry game) { UpdateSelectedGameTexts(); SelectedCoverPanel.DataContext = game; SelectedBadgesRow.DataContext = game; SelectedBadgesRow.IsVisible = true; _ = UpdateBackdropAsync(game); PlaySelectedGamePreview(game); } else { UpdateSelectedGameTexts(); SelectedCoverPanel.DataContext = null; SelectedBadgesRow.DataContext = null; SelectedBadgesRow.IsVisible = false; _ = UpdateBackdropAsync(null); _sndPreview.Stop(); } UpdateRunButtons(); } /// /// Text-only refresh of the launch bar's title/path, split out of /// so a language change can re-apply it /// without restarting the backdrop fade or preview music. /// private void UpdateSelectedGameTexts() { if (GameList.SelectedItem is GameEntry game) { SelectedGameTitle.Text = game.Name; SelectedGamePath.Text = game.Path; } else { SelectedGameTitle.Text = Localization.Instance.Get("Launch.NoGameSelected"); SelectedGamePath.Text = Localization.Instance.Get("Launch.NoGameHint"); } } /// /// Loops the selected game's sce_sys/snd0.at9 preview music, console /// home screen style. Silent while a game is running or when disabled /// in the options. /// private void PlaySelectedGamePreview(GameEntry game) { if (_isRunning || !_settings.PlayTitleMusic) { return; } var directory = Path.GetDirectoryName(game.Path); var sndPath = directory is null ? null : Path.Combine(directory, "sce_sys", "snd0.at9"); if (sndPath is not null && File.Exists(sndPath)) { _sndPreview.Play(sndPath); } else { _sndPreview.Stop(); } } private void OnTitleMusicSettingChanged() { if (!_settings.PlayTitleMusic) { _sndPreview.Stop(); } else if (GameList.SelectedItem is GameEntry game) { PlaySelectedGamePreview(game); } } /// Pauses the preview music while the window is minimized. protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == WindowStateProperty) { if (WindowState == WindowState.Minimized) { _sndPreview.Pause(); } else { _sndPreview.Resume(); } } } /// /// Fades the window backdrop to the selected game's key art. The image /// decodes off the UI thread and is cached on the entry; a newer /// selection cancels the fade-in of an older one. /// private async Task UpdateBackdropAsync(GameEntry? game) { var generation = ++_backdropGeneration; BackdropImage.Opacity = 0; if (game?.BackgroundPath is null) { return; } if (game.Background is null) { try { var path = game.BackgroundPath; game.Background = await Task.Run(() => { using var stream = File.OpenRead(path); return Bitmap.DecodeToWidth(stream, 1600); }); } catch (Exception) { return; // undecodable key art: keep the plain background } } if (generation == _backdropGeneration) { BackdropImage.Source = game.Background; BackdropImage.Opacity = 1.0; } } // ---- Launching ---- private async Task OpenFileAsync() { var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = Localization.Instance.Get("Dialog.OpenExecutable"), AllowMultiple = false, FileTypeFilter = new[] { new FilePickerFileType(Localization.Instance.Get("Dialog.PsExecutables")) { Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } }, FilePickerFileTypes.All, }, }); var path = files.FirstOrDefault()?.TryGetLocalPath(); if (!string.IsNullOrEmpty(path)) { Launch(path, Path.GetFileName(path)); } } private void LaunchSelected() { if (GameList.SelectedItem is GameEntry game) { Launch(game.Path, game.Name, game.TitleId); } } private void Launch(string ebootPath, string displayName, string? titleId = null) { if (_isRunning) { return; } if (_emulatorExePath is null) { LocateEmulator(); if (_emulatorExePath is null) { AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush); return; } } _sndPreview.Stop(); var arguments = new List { "--cpu-engine=native", $"--log-level={_settings.LogLevel.ToLowerInvariant()}", }; if (_settings.StrictDynlibResolution) { arguments.Add("--strict"); } if (_settings.ImportTraceLimit > 0) { arguments.Add($"--trace-imports={_settings.ImportTraceLimit}"); } _consoleLines.Clear(); // Let the CLI mirror stdout/stderr itself; it sees loader/native // diagnostics before the GUI pipe reader can filter or batch them. DropFileLog(); if (_settings.LogToFile) { string filePath; if (!string.IsNullOrWhiteSpace(_settings.LogFilePath)) { if (_settings.OverrideLogFile) { filePath = _settings.LogFilePath; } else { string path = _settings.LogFilePath; string id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId; foreach (var invalidChar in Path.GetInvalidFileNameChars()) { id = id.Replace(invalidChar.ToString(), ""); } string identifier = $"{id}-{DateTime.Now:yyyyMMdd-HHmmss}"; string? dir = Path.GetDirectoryName(path); string? fileName = Path.GetFileNameWithoutExtension(path); string? extension = Path.GetExtension(path); string newFileName = $"{fileName}-{identifier}{extension}"; filePath = string.IsNullOrEmpty(dir) ? newFileName : Path.Combine(dir, newFileName); } } else { filePath = BuildLogFilePath(titleId) ?? string.Empty; } if (!string.IsNullOrEmpty(filePath)) { arguments.Add("--log-file"); arguments.Add(filePath); AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush); } } arguments.Add(ebootPath); AppendConsoleLine( Localization.Instance.Format("Launch.Command", string.Join(' ', arguments)), DimLineBrush); var emulator = new EmulatorProcess(); emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError)); emulator.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code)); try { emulator.Start(_emulatorExePath, arguments, Path.GetDirectoryName(ebootPath)); } catch (Exception ex) { emulator.Dispose(); AppendConsoleLine(Localization.Instance.Format("Launch.StartFailed", ex.Message), ErrorLineBrush); DropFileLog(); return; } _emulator = emulator; _isRunning = true; _runningGameName = displayName; _runningGameTitleId = _allGames .FirstOrDefault(game => game.Path.Equals(ebootPath, StringComparison.OrdinalIgnoreCase))? .TitleId; _runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); StatusDot.Fill = SuccessLineBrush; StatusText.Text = Localization.Instance.Format("Launch.Running", displayName); StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName); UpdateRunButtons(); UpdateDiscordPresence(); } /// /// Stops the running game and updates status/presence immediately. The /// process-exit path still runs when the corpse is collected, but a game /// wedged in a GPU driver call can keep its process alive for a long /// time after termination — the launcher should not look (or tell /// Discord it is) "playing" during that window. /// private void StopEmulator() { if (!_isRunning) { return; } _emulator?.Stop(); _runningGameName = null; _runningGameTitleId = null; StatusText.Text = Localization.Instance.Get("Launch.Stopping"); StatusBarRight.Text = Localization.Instance.Get("Status.Stopping"); UpdateDiscordPresence(); } /// /// Builds "user/logs/<titleId>-<timestamp>.log" next to the emulator /// executable, following the same portable-data convention as savedata. /// private string? BuildLogFilePath(string? titleId) { try { var exeDirectory = Path.GetDirectoryName(_emulatorExePath); if (string.IsNullOrEmpty(exeDirectory)) { return null; } var logsDirectory = Path.Combine(exeDirectory, "user", "logs"); Directory.CreateDirectory(logsDirectory); var id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId; foreach (var invalid in Path.GetInvalidFileNameChars()) { id = id.Replace(invalid, '_'); } return Path.Combine(logsDirectory, $"{id}-{DateTime.Now:yyyyMMdd-HHmmss}.log"); } catch (Exception) { return null; // unwritable location: launch continues without a log file } } private void OnEmulatorExited(int exitCode) { FlushPendingConsoleLines(); _isRunning = false; _emulator?.Dispose(); _emulator = null; var meaningKey = exitCode switch { 0 => "Exit.Ok", 1 => "Exit.InvalidArguments", 2 => "Exit.EbootNotFound", 3 => "Exit.RuntimeException", 4 => "Exit.EmulationError", _ => "Exit.Unknown", }; var meaning = Localization.Instance.Get(meaningKey); var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush; AppendConsoleLine(Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning), brush); CloseFileLogSoon(); StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush; StatusText.Text = Localization.Instance.Format("Launch.Exited", exitCode, meaning); StatusBarRight.Text = Localization.Instance.Get("Status.Idle"); _runningGameName = null; _runningGameTitleId = null; UpdateRunButtons(); UpdateDiscordPresence(); } private void UpdateRunButtons() { LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry; StopButton.IsEnabled = _isRunning; OpenFileButton.IsEnabled = !_isRunning; } // ---- Console ---- private void FlushPendingConsoleLines() { if (_pendingLines.IsEmpty) { return; } var incoming = new List(); while (_pendingLines.TryDequeue(out var pending)) { WriteFileLog(pending.Line); incoming.Add(new LogLine(pending.Line, BrushForLine(pending.Line))); } FlushFileLog(); _allConsoleLines.AddRange(incoming); string query = ConsoleSearchBox.Text ?? string.Empty; IEnumerable linesToAdd = incoming; if (!string.IsNullOrWhiteSpace(query)) { linesToAdd = incoming.Where(line => line.Text != null && line.Text.Contains(query, StringComparison.OrdinalIgnoreCase)); } _consoleLines.AddRange(linesToAdd); var overflow = _consoleLines.Count - MaxConsoleLines; while (_allConsoleLines.Count > MaxConsoleLines) { var droppedLine = _allConsoleLines[0]; _allConsoleLines.RemoveAt(0); if (_consoleLines.Count > 0 && _consoleLines[0] == droppedLine) { _consoleLines.RemoveAt(0); } } _autoScrollTicks = 3; } private void AppendConsoleLine(string text, IBrush brush) { WriteFileLog(text); FlushFileLog(); var line = new LogLine(text, brush); _allConsoleLines.Add(line); string query = ConsoleSearchBox.Text ?? string.Empty; if (string.IsNullOrWhiteSpace(query) || (text != null && text.Contains(query, StringComparison.OrdinalIgnoreCase))) { _consoleLines.Add(line); } while (_allConsoleLines.Count > MaxConsoleLines) { var droppedLine = _allConsoleLines[0]; _allConsoleLines.RemoveAt(0); if (_consoleLines.Count > 0 && _consoleLines[0] == droppedLine) { _consoleLines.RemoveAt(0); } } _autoScrollTicks = 3; MaybeAutoScroll(); } private void RefreshVisibleConsoleLines() { string query = ConsoleSearchBox.Text ?? string.Empty; _consoleLines.Clear(); if (string.IsNullOrWhiteSpace(query)) { _consoleLines.AddRange(_allConsoleLines); } else { var filtered = _allConsoleLines.Where(line => line.Text != null && line.Text.Contains(query, StringComparison.OrdinalIgnoreCase)); _consoleLines.AddRange(filtered); } } // ---- Console-to-file mirroring ---- private void WriteFileLog(string text) { if (_fileLog is not { } writer) { return; } try { writer.Write('['); writer.Write(DateTime.Now.ToString("HH:mm:ss.fff")); writer.Write("] "); writer.WriteLine(text); } catch (Exception) { DropFileLog(); // unwritable (disk full, etc.): stop mirroring } } private void FlushFileLog() { try { _fileLog?.Flush(); } catch (Exception) { DropFileLog(); } } private void DropFileLog() { var writer = _fileLog; _fileLog = null; try { writer?.Dispose(); } catch (Exception) { } } /// /// The pipe reader threads can deliver a final burst after the exit /// event, so the file stays open for one more flush cycle. /// private void CloseFileLogSoon() { if (_fileLog is not { } writer) { return; } DispatcherTimer.RunOnce(() => { if (ReferenceEquals(_fileLog, writer)) { FlushPendingConsoleLines(); DropFileLog(); } }, TimeSpan.FromMilliseconds(400)); } private void MaybeAutoScroll() { // ScrollToEnd is applied over a few flush-timer ticks because the // virtualizing panel re-estimates its extent after large batches, and // a single scroll can land short of the true end. A synchronous // ScrollIntoView during rapid adds is avoided entirely — it can crash // the panel with "Invalid Arrange rectangle". if (_autoScrollTicks <= 0 || AutoScrollCheck.IsChecked != true) { return; } _autoScrollTicks--; (ConsoleList.Scroll as ScrollViewer)?.ScrollToEnd(); } private static IBrush BrushForLine(string line) { if (line.Contains("[ERROR]", StringComparison.Ordinal) || line.Contains("[CRITICAL]", StringComparison.Ordinal)) { return ErrorLineBrush; } if (line.Contains("[WARNING]", StringComparison.Ordinal)) { return WarningLineBrush; } if (line.Contains("[INFO]", StringComparison.Ordinal)) { return InfoLineBrush; } if (line.Contains("[DEBUG]", StringComparison.Ordinal) || line.Contains("[TRACE]", StringComparison.Ordinal)) { return DimLineBrush; } return DefaultLineBrush; } private async Task CopyConsoleAsync() { if (_consoleLines.Count == 0 || Clipboard is null) { return; } var text = string.Join(Environment.NewLine, _consoleLines.Select(line => line.Text)); await Clipboard.SetTextAsync(text); } private void ShowConsoleWindow() { if (_consoleWindow is { } window) { window.Activate(); return; } ConsoleSearchBox.Text = string.Empty; ConsoleToggle.IsChecked = false; ConsolePanel.IsVisible = false; _consoleWindow = new ConsoleWindow( _consoleLines, () => { _consoleLines.Clear(); _allConsoleLines.Clear(); }, AutoScrollCheck.IsChecked == true); _consoleWindow.Closed += (_, _) => { _consoleWindow = null; ConsoleToggle.IsChecked = true; ConsolePanel.IsVisible = true; }; _consoleWindow.Show(this); } }