// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using Avalonia; using Avalonia.Automation; using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Input.Platform; 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.Core.Cpu; using SharpEmu.Core.Runtime; using SharpEmu.HLE.Host; using SharpEmu.Libs.Pad; using SharpEmu.Libs.VideoOut; using SharpEmu.Logging; using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.Diagnostics; using System.Reflection; using System.Text.Json; using System.Net.Http.Headers; namespace SharpEmu.GUI; public partial class MainWindow : Window { private const int MaxConsoleLines = 4000; private const int MaxConsoleLinesPerFlush = 500; 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 LocalizedChoice[] _cpuEngineChoices = [ LocalizedChoice.FromKey("Native", "Options.CpuEngine.Native"), ]; private readonly LocalizedChoice[] _logLevelChoices = [ LocalizedChoice.FromKey("Trace", "Options.LogLevel.Trace"), LocalizedChoice.FromKey("Debug", "Options.LogLevel.Debug"), LocalizedChoice.FromKey("Info", "Options.LogLevel.Info"), LocalizedChoice.FromKey("Warning", "Options.LogLevel.Warning"), LocalizedChoice.FromKey("Error", "Options.LogLevel.Error"), LocalizedChoice.FromKey("Critical", "Options.LogLevel.Critical"), ]; private readonly LocalizedChoice[] _renderResolutionChoices = [ LocalizedChoice.FromKey("1.0", "Options.RenderResolution.Native"), LocalizedChoice.Literal("0.75", "75%"), LocalizedChoice.Literal("0.5", "50%"), LocalizedChoice.Literal("0.25", "25%"), ]; private readonly LocalizedChoice[] _windowModeChoices = [ LocalizedChoice.FromKey("Windowed", "Options.WindowMode.Windowed"), LocalizedChoice.FromKey("Borderless", "Options.WindowMode.Borderless"), LocalizedChoice.FromKey("Exclusive", "Options.WindowMode.Exclusive"), ]; private readonly LocalizedChoice[] _scalingModeChoices = [ LocalizedChoice.FromKey("Fit", "Options.Scaling.Fit"), LocalizedChoice.FromKey("Cover", "Options.Scaling.Cover"), LocalizedChoice.FromKey("Stretch", "Options.Scaling.Stretch"), LocalizedChoice.FromKey("Integer", "Options.Scaling.Integer"), ]; private readonly LocalizedChoice[] _hdrModeChoices = [ LocalizedChoice.FromKey("Auto", "Options.Hdr.Auto"), LocalizedChoice.FromKey("On", "Common.On"), LocalizedChoice.FromKey("Off", "Common.Off"), ]; private readonly List _allGames = new(); private readonly ObservableCollection _visibleGames = new(); private readonly LibraryTileCollection _libraryTiles; private readonly GameLibraryWatcher _libraryWatcher = 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 IReadOnlyList _hostDisplays = []; private bool _updatingHostDisplayOptions; private EmulatorProcess? _emulator; private ConsoleWindow? _consoleWindow; private GuiConsoleMirror? _consoleMirror; private StreamWriter? _fileLog; private readonly SndPreviewPlayer _sndPreview = new(); private string? _emulatorExePath; private PendingLaunch? _pendingLaunch; private bool _isRunning; private bool _isStopping; private int _autoScrollTicks; private int _activePageIndex; private Updater.UpdateInfo? _availableUpdate; private string _updateStatusKey = "Updater.Status.Ready"; private object?[] _updateStatusArgs = [BuildInfo.CommitSha ?? "dev"]; // 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 _libraryScanGeneration; private int _detailLoadGeneration; private int _backdropGeneration; private bool _isClosing; private bool _restoringGameSelection; private bool _addFolderInProgress; private GameEntry? _lastSelectedGame; // Bundled key art shown whenever no game-specific backdrop applies; the // plain window color remains the fallback when the asset fails to load. private Bitmap? _defaultBackdrop; // Controller navigation state. private readonly DispatcherTimer _gamepadTimer; private HostGamepadButtons _previousPadButtons; private long _navLeftNextAt; private long _navRightNextAt; //Github http client for latest commit private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient(); private string? _latestCommitSha; private sealed record PendingLaunch( string EbootPath, string DisplayName, string? TitleId, EffectiveLaunchSettings Settings, SharpEmuRuntimeOptions RuntimeOptions); public MainWindow() { InitializeComponent(); InitializeLocalizedChoiceBoxes(); _libraryTiles = new LibraryTileCollection(_visibleGames); try { _defaultBackdrop = new Bitmap( AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/pic0.png"))); BackdropImage.Source = _defaultBackdrop; BackdropImage.Opacity = 1.0; } catch (Exception) { _defaultBackdrop = null; // color background remains the fallback } GameList.ItemsSource = _libraryTiles; _libraryWatcher.RefreshRequested += OnLibraryRefreshRequested; ConsoleList.ItemsSource = _consoleLines; _consoleMirror = GuiConsoleMirror.Install((line, isError) => _pendingLines.Enqueue((line, isError))); Closed += (_, _) => _emulator?.Stop(); _consoleFlushTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(80), }; _consoleFlushTimer.Tick += (_, _) => { FlushPendingConsoleLines(); MaybeAutoScroll(); }; _consoleFlushTimer.Start(); TitleBar.PointerPressed += OnTitleBarPointerPressed; TitleBar.DoubleTapped += OnTitleBarDoubleTapped; MinimizeButton.Click += (_, _) => WindowState = WindowState.Minimized; MaximizeButton.Click += (_, _) => ToggleMaximized(); CloseButton.Click += (_, _) => Close(); Opened += (_, _) => { // Some compositors ignore the initial maximized state while a // frameless native window is still being created. if (WindowState == WindowState.Normal) { WindowState = WindowState.Maximized; } UpdateWindowChromeState(); }; UpdateWindowChromeState(); GameList.SelectionChanged += (_, _) => OnGameListSelectionChanged(); GameList.DoubleTapped += OnGameListDoubleTapped; SearchBox.TextChanged += (_, _) => RefreshVisibleGames(); ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines(); OpenFileButton.Click += async (_, _) => await OpenFileAsync(); LaunchButton.Click += (_, _) => { if (_isRunning) { StopEmulator(); } else { LaunchSelected(); } }; ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); }; CopyLogButton.Click += async (_, _) => await CopyConsoleAsync(); DetachConsoleButton.Click += (_, _) => ShowConsoleWindow(); CloseConsoleButton.Click += (_, _) => ConsoleToggle.IsChecked = false; 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); RenderResolutionBox.SelectionChanged += (_, _) => { if (RenderResolutionBox.SelectedItem is LocalizedChoice { Value: var value } && double.TryParse( value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var scale)) { _settings.RenderResolutionScale = scale; } }; 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(); }; AutoUpdateToggle.IsCheckedChanged += (_, _) => _settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true; WindowModeBox.SelectionChanged += (_, _) => _settings.WindowMode = SelectedComboText(WindowModeBox, "Windowed"); DisplayBox.SelectionChanged += (_, _) => OnHostDisplayChanged(); ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged(); RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged(); ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit"); VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true; HdrModeBox.SelectionChanged += (_, _) => _settings.HdrMode = SelectedComboText(HdrModeBox, "Auto"); UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync(); SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync(); EnvBthidToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_BTHID_UNAVAILABLE", EnvBthidToggle.IsChecked == true); EnvLoopGuardToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD", EnvLoopGuardToggle.IsChecked == true); EnvWritableApp0Toggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_WRITABLE_APP0", EnvWritableApp0Toggle.IsChecked == true); EnvVkValidationToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_VK_VALIDATION", EnvVkValidationToggle.IsChecked == true); EnvDumpSpirvToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_DUMP_SPIRV", EnvDumpSpirvToggle.IsChecked == true); EnvLogDirectMemoryToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_LOG_DIRECT_MEMORY", EnvLogDirectMemoryToggle.IsChecked == true); EnvLogIoToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_LOG_IO", EnvLogIoToggle.IsChecked == true); EnvLogNpToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_LOG_NP", EnvLogNpToggle.IsChecked == true); EnvGuestImageCpuSyncToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle( "SHARPEMU_GUEST_IMAGE_CPU_SYNC", EnvGuestImageCpuSyncToggle.IsChecked == true); DefaultProfileBox.TextChanged += (_, _) => _settings.DefaultProfile = GuiSettings.NormalizeDefaultProfile(DefaultProfileBox.Text); LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged(); GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel); AddHandler(KeyDownEvent, OnPreviewKeyDown, 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"); CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings(); CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary(); Opened += async (_, _) => await OnOpenedAsync(); Closing += (_, _) => OnWindowClosing(); SdlLauncherGamepad.EnsureStarted(); _gamepadTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50), }; _gamepadTimer.Tick += (_, _) => PollGamepad(); _gamepadTimer.Start(); GithubButton.Click += (_, _) => { Process.Start(new ProcessStartInfo { FileName = "https://github.com/sharpemu/sharpemu", UseShellExecute = true }); }; LatestCommitHashText.Click += (_, _) => { if (string.IsNullOrWhiteSpace(_latestCommitSha)) { return; } Process.Start(new ProcessStartInfo { FileName = $"https://github.com/sharpemu/sharpemu/commit/{_latestCommitSha}", UseShellExecute = true }); }; } /// /// 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"); } } // ---- Github http client config ---- // This is for getting lash commit id private static HttpClient CreateGithubHttpClient() { var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; client.DefaultRequestHeaders.UserAgent.ParseAdd("SharpEmu/1.0"); client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/vnd.github.sha")); client.DefaultRequestHeaders.Add( "X-GitHub-Api-Version", "2026-03-10"); return client; } private async Task LoadLatestCommitAsync() { const string apiUrl = "https://api.github.com/repos/sharpemu/sharpemu/commits/main"; _latestCommitSha = null; LatestCommitHashText.Content = "Loading…"; LatestCommitHashText.IsEnabled = false; try { using var response = await GithubHttpClient.GetAsync(apiUrl); var responseBody = (await response.Content.ReadAsStringAsync()).Trim(); if (!response.IsSuccessStatusCode) { LatestCommitHashText.Content = $"HTTP {(int)response.StatusCode}"; ToolTip.SetTip( LatestCommitHashText, string.IsNullOrWhiteSpace(responseBody) ? response.ReasonPhrase : responseBody); return; } if (responseBody.Length < 7) { LatestCommitHashText.Content = "Invalid response"; ToolTip.SetTip(LatestCommitHashText, responseBody); return; } // Keep the complete SHA for the URL. _latestCommitSha = responseBody; // Display only the short SHA. LatestCommitHashText.Content = responseBody[..Math.Min(7, responseBody.Length)]; LatestCommitHashText.IsEnabled = true; ToolTip.SetTip( LatestCommitHashText, $"Open commit {_latestCommitSha}"); } catch (TaskCanceledException ex) { LatestCommitHashText.Content = "Timeout"; ToolTip.SetTip(LatestCommitHashText, ex.Message); } catch (HttpRequestException ex) { LatestCommitHashText.Content = "Connection error"; ToolTip.SetTip(LatestCommitHashText, ex.Message); } catch (Exception ex) { LatestCommitHashText.Content = "Error"; ToolTip.SetTip(LatestCommitHashText, ex.Message); } } // ---- Controller navigation ---- private void PollGamepad() { if (!SdlLauncherGamepad.TryGetState(out var pad)) { _previousPadButtons = HostGamepadButtons.None; 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; } if (_isRunning || _isStopping) { // The controller belongs to the separate game window while a // session is active; Circle/B must never stop the session. _previousPadButtons = pad.Buttons; return; } var shoulderPressed = pad.Buttons & ~_previousPadButtons; if ((shoulderPressed & HostGamepadButtons.L1) != 0) { SetActivePage(0); } if ((shoulderPressed & HostGamepadButtons.R1) != 0) { SetActivePage(1); } if (_activePageIndex != 0) { _previousPadButtons = pad.Buttons; return; } var now = Environment.TickCount64; var left = (pad.Buttons & HostGamepadButtons.Left) != 0 || pad.LeftX < 64; var right = (pad.Buttons & HostGamepadButtons.Right) != 0 || pad.LeftX > 192; if (ShouldNavigate(left, ref _navLeftNextAt, now)) { MoveSelection(-1); } if (ShouldNavigate(right, ref _navRightNextAt, now)) { MoveSelection(1); } var pressed = pad.Buttons & ~_previousPadButtons; if ((pressed & HostGamepadButtons.Cross) != 0) { LaunchSelected(); } _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) { var index = GameList.SelectedIndex < 0 ? 0 : Math.Clamp(GameList.SelectedIndex + delta, 0, _libraryTiles.Count - 1); GameList.SelectedIndex = index; GameList.ScrollIntoView(index); } 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(); _ = LoadLatestCommitAsync(); if (_settings.CheckForUpdatesOnStartup) { _ = CheckForUpdatesAsync(); } 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 LanguageInfo language) { return; } _settings.Language = language.Code; Localization.Instance.Load(language.Code); ApplyLocalization(); } /// /// Recomputes localized strings that also depend on runtime state. /// Static labels update automatically through XAML bindings. /// private void ApplyLocalization() { RefreshLocalizedChoices(); UpdateLogFilePathText(); RefreshHostRefreshRates(_settings.RefreshRate); RefreshUpdateText(); UpdateEmptyStateTexts(); UpdateRunButtons(); } // ---- 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) { if (args.Key == Key.F11 && !_isRunning) { WindowState = WindowState == WindowState.FullScreen ? WindowState.Maximized : WindowState.FullScreen; args.Handled = true; } } private void OnPreviewKeyDown(object? sender, KeyEventArgs args) { // The session runs in its own SDL window and takes keyboard focus with // it, so launcher buttons no longer see game input and nothing has to // be swallowed here. Kept as the wired handler because the launcher // still needs a preview hook for its own shortcuts. } private void OnWindowClosing() { _isClosing = true; Interlocked.Increment(ref _libraryScanGeneration); Interlocked.Increment(ref _detailLoadGeneration); _libraryWatcher.Dispose(); _settings.Save(); _consoleFlushTimer.Stop(); _gamepadTimer.Stop(); SdlLauncherGamepad.Shutdown(); _sndPreview.Stop(); _discord?.Dispose(); _consoleWindow?.Close(); _emulator?.Dispose(); _consoleMirror?.Dispose(); DropFileLog(); } private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) { if (e.Source is Visual source && source.FindAncestorOfType