mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-28 05:30:57 +08:00
[GUI] Add native Vulkan host surface support and more (#337)
* [GUI] Add native Vulkan host surface support and more * reuse * [GUI] set width session menu
This commit is contained in:
@@ -12,8 +12,11 @@ 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.HLE.Host.Windows;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.ObjectModel;
|
||||
@@ -28,6 +31,8 @@ public partial class MainWindow : Window
|
||||
{
|
||||
private const int MaxConsoleLines = 4000;
|
||||
private const int MaxConsoleLinesPerFlush = 500;
|
||||
private const double LaunchBlurRadius = 12;
|
||||
private const double BlurTransitionSeconds = 0.24;
|
||||
|
||||
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
|
||||
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
|
||||
@@ -48,14 +53,26 @@ public partial class MainWindow : Window
|
||||
private readonly List<LogLine> _allConsoleLines = new();
|
||||
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
|
||||
private readonly DispatcherTimer _consoleFlushTimer;
|
||||
private readonly DispatcherTimer _libraryBlurTimer;
|
||||
private BlurEffect? _libraryBlur;
|
||||
private double _libraryBlurStartRadius;
|
||||
private double _libraryBlurTargetRadius;
|
||||
private long _libraryBlurStartedAt;
|
||||
private bool _clearLibraryBlurWhenComplete;
|
||||
|
||||
private GuiSettings _settings = new();
|
||||
private EmulatorProcess? _emulator;
|
||||
private GameSurfaceHost? _gameSurfaceHost;
|
||||
private ConsoleWindow? _consoleWindow;
|
||||
private GuiConsoleMirror? _consoleMirror;
|
||||
private StreamWriter? _fileLog;
|
||||
private readonly SndPreviewPlayer _sndPreview = new();
|
||||
private string? _emulatorExePath;
|
||||
private PendingLaunch? _pendingLaunch;
|
||||
private bool _gameFullscreen;
|
||||
private bool _isRunning;
|
||||
private bool _isStopping;
|
||||
private bool _awaitingFirstFrame;
|
||||
private int _autoScrollTicks;
|
||||
private int _activePageIndex;
|
||||
private Updater.UpdateInfo? _availableUpdate;
|
||||
@@ -83,12 +100,21 @@ public partial class MainWindow : Window
|
||||
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
|
||||
private string? _latestCommitSha;
|
||||
|
||||
private sealed record PendingLaunch(
|
||||
string EbootPath,
|
||||
string DisplayName,
|
||||
string? TitleId,
|
||||
SharpEmuRuntimeOptions RuntimeOptions);
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
GameList.ItemsSource = _visibleGames;
|
||||
ConsoleList.ItemsSource = _consoleLines;
|
||||
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
|
||||
_pendingLines.Enqueue((line, isError)));
|
||||
Closed += (_, _) => _emulator?.Stop();
|
||||
|
||||
_consoleFlushTimer = new DispatcherTimer
|
||||
{
|
||||
@@ -101,6 +127,15 @@ public partial class MainWindow : Window
|
||||
};
|
||||
_consoleFlushTimer.Start();
|
||||
|
||||
_libraryBlurTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(16),
|
||||
};
|
||||
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
|
||||
|
||||
Activated += (_, _) => UpdateSessionBarVisibility();
|
||||
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
|
||||
|
||||
TitleBar.PointerPressed += OnTitleBarPointerPressed;
|
||||
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
|
||||
GameList.DoubleTapped += (_, _) => LaunchSelected();
|
||||
@@ -111,10 +146,10 @@ public partial class MainWindow : Window
|
||||
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();
|
||||
SessionStopButton.Click += (_, _) => StopEmulator();
|
||||
SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
||||
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
||||
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
||||
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
||||
@@ -162,6 +197,7 @@ public partial class MainWindow : Window
|
||||
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
|
||||
|
||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
|
||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||
CtxCopyPath.Click += async (_, _) =>
|
||||
@@ -683,14 +719,46 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
|
||||
{
|
||||
// While a session is on screen, Enter and Space are game input
|
||||
// (Cross button). Keyboard focus stays on the launcher window, so a
|
||||
// previously clicked, still-focused button (console toggle, session
|
||||
// bar) would also activate and reshape the game view. Swallow the
|
||||
// keys before button activation; the emulator process reads raw key
|
||||
// state and is unaffected. Fullscreen hides those buttons, which is
|
||||
// why this only manifested in windowed sessions.
|
||||
if (_isRunning && GameView.IsVisible &&
|
||||
args.Key is Key.Enter or Key.Space)
|
||||
{
|
||||
args.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowFullScreen(object sender, RoutedEventArgs args)
|
||||
{
|
||||
if (WindowState == WindowState.FullScreen)
|
||||
{
|
||||
WindowState = WindowState.Normal;
|
||||
// Leaving F11 should restore a monitor-sized window with the
|
||||
// launcher chrome, not fall back to the design-time window size.
|
||||
WindowState = WindowState.Maximized;
|
||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
|
||||
TitleBar.IsVisible = true;
|
||||
StatusBar.IsVisible = true;
|
||||
if (_gameFullscreen)
|
||||
{
|
||||
_gameFullscreen = false;
|
||||
Grid.SetRow(MainContent, 1);
|
||||
Grid.SetRowSpan(MainContent, 1);
|
||||
MainContent.Margin = _isRunning
|
||||
? new Thickness(0)
|
||||
: new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = !_isRunning;
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
LaunchBar.IsVisible = true;
|
||||
QueueGameSurfaceResize();
|
||||
UpdateSessionBarVisibility();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -698,18 +766,45 @@ public partial class MainWindow : Window
|
||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
||||
TitleBar.IsVisible = false;
|
||||
StatusBar.IsVisible = false;
|
||||
if (_isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible)
|
||||
{
|
||||
// The native child receives its new physical Bounds as soon
|
||||
// as this grid spans the monitor. The presenter recreates its
|
||||
// swapchain from that size, rather than stretching 720p.
|
||||
_gameFullscreen = true;
|
||||
// Re-arming restarts the idle countdown, so the cursor also
|
||||
// hides a moment after F11 even without further mouse motion.
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
Grid.SetRow(MainContent, 0);
|
||||
Grid.SetRowSpan(MainContent, 3);
|
||||
MainContent.Margin = new Thickness(0);
|
||||
ContentToolbar.IsVisible = false;
|
||||
ConsolePanel.IsVisible = false;
|
||||
LaunchBar.IsVisible = false;
|
||||
QueueGameSurfaceResize();
|
||||
UpdateSessionBarVisibility();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueGameSurfaceResize()
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() => _gameSurfaceHost?.RefreshSurfaceSize(),
|
||||
DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
private void OnWindowClosing()
|
||||
{
|
||||
_settings.Save();
|
||||
_consoleFlushTimer.Stop();
|
||||
_libraryBlurTimer.Stop();
|
||||
_gamepadTimer.Stop();
|
||||
_sndPreview.Stop();
|
||||
_discord?.Dispose();
|
||||
_consoleWindow?.Close();
|
||||
_emulator?.Dispose();
|
||||
_consoleMirror?.Dispose();
|
||||
DropFileLog();
|
||||
}
|
||||
|
||||
@@ -896,8 +991,8 @@ public partial class MainWindow : Window
|
||||
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.
|
||||
// The GUI and CLI share one executable. The selected path is the
|
||||
// isolated child executable and also defines the portable data root.
|
||||
if (Environment.ProcessPath is { } selfPath &&
|
||||
Path.GetFileNameWithoutExtension(selfPath).Equals("SharpEmu", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -1576,89 +1671,18 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (_emulatorExePath is null)
|
||||
{
|
||||
LocateEmulator();
|
||||
if (_emulatorExePath is null)
|
||||
{
|
||||
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_sndPreview.Stop();
|
||||
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"--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();
|
||||
_allConsoleLines.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);
|
||||
}
|
||||
OpenFileLog(titleId);
|
||||
}
|
||||
|
||||
arguments.Add(ebootPath);
|
||||
|
||||
AppendConsoleLine(
|
||||
Localization.Instance.Format("Launch.Command", string.Join(' ', arguments)),
|
||||
DimLineBrush);
|
||||
|
||||
// Apply the enabled switches to this process; both emulator launch paths
|
||||
// (CreateProcessW and Process.Start) inherit it. Clear switches turned
|
||||
// off since the previous launch.
|
||||
// The isolated game child inherits these diagnostics. Keep them on the
|
||||
// launcher process so every platform receives the same launch options.
|
||||
foreach (var staleName in _appliedEnvironmentVariables)
|
||||
{
|
||||
if (!_settings.EnvironmentToggles.Contains(staleName))
|
||||
@@ -1674,34 +1698,41 @@ public partial class MainWindow : Window
|
||||
_appliedEnvironmentVariables.Add(name);
|
||||
}
|
||||
|
||||
var emulator = new EmulatorProcess();
|
||||
emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError));
|
||||
emulator.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code));
|
||||
|
||||
try
|
||||
if (SharpEmuLog.TryParseLevel(_settings.LogLevel, out var logLevel))
|
||||
{
|
||||
emulator.Start(_emulatorExePath, arguments, Path.GetDirectoryName(ebootPath));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
emulator.Dispose();
|
||||
AppendConsoleLine(Localization.Instance.Format("Launch.StartFailed", ex.Message), ErrorLineBrush);
|
||||
DropFileLog();
|
||||
return;
|
||||
SharpEmuLog.MinimumLevel = logLevel;
|
||||
}
|
||||
|
||||
_emulator = emulator;
|
||||
var runtimeOptions = new SharpEmuRuntimeOptions
|
||||
{
|
||||
CpuEngine = CpuExecutionEngine.NativeOnly,
|
||||
StrictDynlibResolution = _settings.StrictDynlibResolution,
|
||||
ImportTraceLimit = Math.Max(0, _settings.ImportTraceLimit),
|
||||
};
|
||||
|
||||
_isRunning = true;
|
||||
_runningGameName = displayName;
|
||||
_runningGameTitleId = _allGames
|
||||
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?
|
||||
.TitleId;
|
||||
SessionGameTitle.Text = displayName;
|
||||
_runningGameTitleId = titleId ?? _allGames
|
||||
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.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();
|
||||
|
||||
ShowGameView();
|
||||
_pendingLaunch = new PendingLaunch(
|
||||
Path.GetFullPath(ebootPath),
|
||||
displayName,
|
||||
_runningGameTitleId,
|
||||
runtimeOptions);
|
||||
|
||||
if (_gameSurfaceHost?.Surface is { } surface)
|
||||
{
|
||||
StartPendingSession(surface);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1713,17 +1744,35 @@ public partial class MainWindow : Window
|
||||
/// </summary>
|
||||
private void StopEmulator()
|
||||
{
|
||||
if (!_isRunning)
|
||||
if (!_isRunning || _isStopping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_emulator?.Stop();
|
||||
if (_emulator is null)
|
||||
{
|
||||
// The native host can be created a moment after Launch. Do not
|
||||
// let that delayed callback start a session the user already
|
||||
// cancelled.
|
||||
_pendingLaunch = null;
|
||||
OnEmulatorExited(0);
|
||||
return;
|
||||
}
|
||||
|
||||
_isStopping = true;
|
||||
StopButton.IsEnabled = false;
|
||||
SessionStopButton.IsEnabled = false;
|
||||
SessionHintText.Text = Localization.Instance.Get("Launch.Stopping");
|
||||
SessionF11Badge.IsVisible = false;
|
||||
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
|
||||
_emulator.Stop();
|
||||
_runningGameName = null;
|
||||
_runningGameTitleId = null;
|
||||
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
|
||||
UpdateDiscordPresence();
|
||||
UpdateSessionBarVisibility();
|
||||
ReturnToLibraryWhileStopping();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1734,7 +1783,7 @@ public partial class MainWindow : Window
|
||||
{
|
||||
try
|
||||
{
|
||||
var exeDirectory = Path.GetDirectoryName(_emulatorExePath);
|
||||
var exeDirectory = Path.GetDirectoryName(_emulatorExePath) ?? AppContext.BaseDirectory;
|
||||
if (string.IsNullOrEmpty(exeDirectory))
|
||||
{
|
||||
return null;
|
||||
@@ -1761,8 +1810,12 @@ public partial class MainWindow : Window
|
||||
{
|
||||
FlushPendingConsoleLines();
|
||||
_isRunning = false;
|
||||
_isStopping = false;
|
||||
_emulator?.Dispose();
|
||||
_emulator = null;
|
||||
_pendingLaunch = null;
|
||||
DisposeGameSurfaceHost();
|
||||
HideGameView();
|
||||
|
||||
var meaningKey = exitCode switch
|
||||
{
|
||||
@@ -1774,13 +1827,20 @@ public partial class MainWindow : Window
|
||||
-1073741819 => "Exit.EmulationError",
|
||||
_ => "Exit.Unknown",
|
||||
};
|
||||
var stoppedByUser = exitCode == EmulatorProcess.HostStopExitCode;
|
||||
var meaning = Localization.Instance.Get(meaningKey);
|
||||
var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush;
|
||||
AppendConsoleLine(Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning), brush);
|
||||
var brush = exitCode == 0 || stoppedByUser ? SuccessLineBrush : ErrorLineBrush;
|
||||
AppendConsoleLine(
|
||||
stoppedByUser
|
||||
? "Game closed by the user."
|
||||
: 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);
|
||||
StatusDot.Fill = exitCode == 0 || stoppedByUser ? (IBrush)SuccessLineBrush : ErrorLineBrush;
|
||||
StatusText.Text = stoppedByUser
|
||||
? "Game closed by the user."
|
||||
: Localization.Instance.Format("Launch.Exited", exitCode, meaning);
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
|
||||
_runningGameName = null;
|
||||
_runningGameTitleId = null;
|
||||
@@ -1788,13 +1848,368 @@ public partial class MainWindow : Window
|
||||
UpdateDiscordPresence();
|
||||
}
|
||||
|
||||
private void StartPendingSession(VulkanHostSurface surface)
|
||||
{
|
||||
if (_pendingLaunch is not { } launch || _emulator is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_emulatorExePath))
|
||||
{
|
||||
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
|
||||
OnEmulatorExited(3);
|
||||
return;
|
||||
}
|
||||
|
||||
var process = new EmulatorProcess();
|
||||
process.OutputReceived += OnEmulatorOutput;
|
||||
process.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code));
|
||||
|
||||
try
|
||||
{
|
||||
var arguments = BuildEmulatorArguments(launch, surface);
|
||||
_emulator = process;
|
||||
_pendingLaunch = null;
|
||||
process.Start(
|
||||
_emulatorExePath,
|
||||
arguments,
|
||||
Path.GetDirectoryName(_emulatorExePath));
|
||||
AppendConsoleLine(
|
||||
Localization.Instance.Format("Launch.Command", launch.EbootPath),
|
||||
DimLineBrush);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_emulator = null;
|
||||
process.Dispose();
|
||||
AppendConsoleLine(
|
||||
Localization.Instance.Format("Launch.StartFailed", exception.Message),
|
||||
ErrorLineBrush);
|
||||
OnEmulatorExited(3);
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> BuildEmulatorArguments(PendingLaunch launch, VulkanHostSurface surface)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"--cpu-engine=native",
|
||||
$"--log-level={_settings.LogLevel}",
|
||||
};
|
||||
if (launch.RuntimeOptions.StrictDynlibResolution)
|
||||
{
|
||||
arguments.Add("--strict");
|
||||
}
|
||||
if (launch.RuntimeOptions.ImportTraceLimit > 0)
|
||||
{
|
||||
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
|
||||
}
|
||||
|
||||
if (surface.TryGetChildProcessDescriptor(out var descriptor))
|
||||
{
|
||||
arguments.Add($"--host-surface={descriptor}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendConsoleLine(
|
||||
"[GUI][WARN] Embedded child surfaces are unavailable on this platform; opening a game window instead.",
|
||||
WarningLineBrush);
|
||||
}
|
||||
|
||||
arguments.Add(launch.EbootPath);
|
||||
return arguments;
|
||||
}
|
||||
|
||||
private void OnEmulatorOutput(string line, bool isError)
|
||||
{
|
||||
_pendingLines.Enqueue((line, isError));
|
||||
if (!line.Contains("[VIDEOOUT][INFO] Hosted splash ready.", StringComparison.Ordinal) &&
|
||||
!line.Contains("[VIDEOOUT][INFO] Hosted first frame presented.", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (_isRunning && !_isStopping)
|
||||
{
|
||||
_awaitingFirstFrame = false;
|
||||
ClearLibraryBlur();
|
||||
MainContent.Margin = new Thickness(0);
|
||||
GameView.Background = Brushes.Black;
|
||||
GameView.IsHitTestVisible = true;
|
||||
_gameSurfaceHost?.SetPresentationVisible(true);
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
LibraryPage.IsVisible = false;
|
||||
OptionsPage.IsVisible = false;
|
||||
LibraryToolbar.IsVisible = false;
|
||||
ContentToolbar.IsVisible = false;
|
||||
ConsolePanel.IsVisible = false;
|
||||
LaunchBar.IsVisible = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
UpdateSessionBarVisibility();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private GameSurfaceHost EnsureGameSurfaceHost()
|
||||
{
|
||||
if (_gameSurfaceHost is not null)
|
||||
{
|
||||
return _gameSurfaceHost;
|
||||
}
|
||||
|
||||
var host = new GameSurfaceHost();
|
||||
// Configure this before attaching it to Avalonia so its first native
|
||||
// HWND is hidden while the child process starts.
|
||||
host.SetPresentationVisible(false);
|
||||
host.SurfaceAvailable += (_, surface) =>
|
||||
{
|
||||
if (ReferenceEquals(_gameSurfaceHost, host))
|
||||
{
|
||||
StartPendingSession(surface);
|
||||
}
|
||||
};
|
||||
host.SurfaceDestroyed += (_, surface) => OnGameSurfaceDestroyed(host, surface);
|
||||
_gameSurfaceHost = host;
|
||||
GameSurfaceContainer.Children.Add(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
private void DisposeGameSurfaceHost()
|
||||
{
|
||||
var host = _gameSurfaceHost;
|
||||
if (host is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_gameSurfaceHost = null;
|
||||
host.SetPresentationVisible(false);
|
||||
GameSurfaceContainer.Children.Remove(host);
|
||||
}
|
||||
|
||||
private void OnGameSurfaceDestroyed(GameSurfaceHost host, VulkanHostSurface surface)
|
||||
{
|
||||
if (ReferenceEquals(_gameSurfaceHost, host) && _isRunning)
|
||||
{
|
||||
StopEmulator();
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowGameView()
|
||||
{
|
||||
_isStopping = false;
|
||||
_awaitingFirstFrame = true;
|
||||
var host = EnsureGameSurfaceHost();
|
||||
GameView.IsVisible = true;
|
||||
GameView.Background = Brushes.Transparent;
|
||||
GameView.IsHitTestVisible = false;
|
||||
host.SetPresentationVisible(false);
|
||||
AnimateLibraryBlur(LaunchBlurRadius);
|
||||
SessionHintText.Text = "Fullscreen";
|
||||
SessionF11Badge.IsVisible = true;
|
||||
UpdateSessionBarVisibility();
|
||||
ShowSessionLoading("Loading game", "Preparing the emulation session...");
|
||||
}
|
||||
|
||||
private void HideGameView()
|
||||
{
|
||||
if (_gameFullscreen && WindowState == WindowState.FullScreen)
|
||||
{
|
||||
OnWindowFullScreen(this, new RoutedEventArgs());
|
||||
}
|
||||
|
||||
_gameSurfaceHost?.SetCursorAutoHide(false);
|
||||
_gameSurfaceHost?.SetPresentationVisible(false);
|
||||
_awaitingFirstFrame = false;
|
||||
GameView.IsVisible = false;
|
||||
GameView.IsHitTestVisible = true;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
AnimateLibraryBlur(0, clearWhenComplete: true);
|
||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = true;
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
LaunchBar.IsVisible = true;
|
||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
||||
if (GameList.SelectedItem is GameEntry game && game.Background is not null)
|
||||
{
|
||||
BackdropImage.Opacity = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
|
||||
{
|
||||
_libraryBlur ??= new BlurEffect();
|
||||
MainContent.Effect = _libraryBlur;
|
||||
|
||||
_libraryBlurStartRadius = _libraryBlur.Radius;
|
||||
_libraryBlurTargetRadius = Math.Max(0, targetRadius);
|
||||
_libraryBlurStartedAt = Stopwatch.GetTimestamp();
|
||||
_clearLibraryBlurWhenComplete = clearWhenComplete && _libraryBlurTargetRadius == 0;
|
||||
|
||||
if (Math.Abs(_libraryBlurStartRadius - _libraryBlurTargetRadius) < 0.01)
|
||||
{
|
||||
CompleteLibraryBlur();
|
||||
return;
|
||||
}
|
||||
|
||||
_libraryBlurTimer.Start();
|
||||
}
|
||||
|
||||
private void AdvanceLibraryBlur()
|
||||
{
|
||||
if (_libraryBlur is null)
|
||||
{
|
||||
_libraryBlurTimer.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
var elapsed = (Stopwatch.GetTimestamp() - _libraryBlurStartedAt) /
|
||||
(double)Stopwatch.Frequency;
|
||||
var progress = Math.Clamp(elapsed / BlurTransitionSeconds, 0, 1);
|
||||
// Cubic ease-out gives the loading transition a quick response while
|
||||
// keeping the final change of sharpness unobtrusive.
|
||||
var easedProgress = 1 - Math.Pow(1 - progress, 3);
|
||||
_libraryBlur.Radius = _libraryBlurStartRadius +
|
||||
((_libraryBlurTargetRadius - _libraryBlurStartRadius) * easedProgress);
|
||||
|
||||
if (progress >= 1)
|
||||
{
|
||||
CompleteLibraryBlur();
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteLibraryBlur()
|
||||
{
|
||||
_libraryBlurTimer.Stop();
|
||||
if (_libraryBlur is not null)
|
||||
{
|
||||
_libraryBlur.Radius = _libraryBlurTargetRadius;
|
||||
}
|
||||
|
||||
if (_clearLibraryBlurWhenComplete)
|
||||
{
|
||||
MainContent.Effect = null;
|
||||
_libraryBlur = null;
|
||||
_clearLibraryBlurWhenComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearLibraryBlur()
|
||||
{
|
||||
_libraryBlurTimer.Stop();
|
||||
_libraryBlur = null;
|
||||
_clearLibraryBlurWhenComplete = false;
|
||||
MainContent.Effect = null;
|
||||
}
|
||||
|
||||
private void ShowSessionLoading(string title, string detail)
|
||||
{
|
||||
SessionLoadingTitle.Text = title;
|
||||
SessionLoadingDetail.Text = detail;
|
||||
SessionLoadingPopup.IsOpen = true;
|
||||
}
|
||||
|
||||
private void ReturnToLibraryWhileStopping()
|
||||
{
|
||||
if (_gameFullscreen && WindowState == WindowState.FullScreen)
|
||||
{
|
||||
OnWindowFullScreen(this, new RoutedEventArgs());
|
||||
}
|
||||
|
||||
// Keep the native child alive until the session exits, but hide it
|
||||
// immediately. Destroying it while Vulkan still owns the surface can
|
||||
// crash the GUI; leaving it transparent lets the library recover
|
||||
// while the native closing popup reports teardown progress.
|
||||
_gameSurfaceHost?.SetPresentationVisible(false);
|
||||
_awaitingFirstFrame = false;
|
||||
GameView.Background = Brushes.Transparent;
|
||||
GameView.IsHitTestVisible = false;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
AnimateLibraryBlur(LaunchBlurRadius);
|
||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = true;
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
LaunchBar.IsVisible = true;
|
||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
||||
BackdropImage.Opacity = GameList.SelectedItem is GameEntry { Background: not null } ? 1 : 0;
|
||||
UpdateRunButtons();
|
||||
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
|
||||
}
|
||||
|
||||
private void OpenFileLog(string? titleId)
|
||||
{
|
||||
var filePath = ResolveLogFilePath(titleId);
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
_fileLog = new StreamWriter(filePath, append: false) { AutoFlush = true };
|
||||
AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
AppendConsoleLine($"[GUI][WARN] Could not open log file: {exception.Message}", WarningLineBrush);
|
||||
DropFileLog();
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolveLogFilePath(string? titleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.LogFilePath))
|
||||
{
|
||||
return BuildLogFilePath(titleId);
|
||||
}
|
||||
|
||||
if (_settings.OverrideLogFile)
|
||||
{
|
||||
return _settings.LogFilePath;
|
||||
}
|
||||
|
||||
var path = _settings.LogFilePath;
|
||||
var id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId;
|
||||
foreach (var invalid in Path.GetInvalidFileNameChars())
|
||||
{
|
||||
id = id.Replace(invalid.ToString(), string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
var filename = Path.GetFileNameWithoutExtension(path);
|
||||
var extension = Path.GetExtension(path);
|
||||
var timestampedName = $"{filename}-{id}-{DateTime.Now:yyyyMMdd-HHmmss}{extension}";
|
||||
return string.IsNullOrEmpty(directory) ? timestampedName : Path.Combine(directory, timestampedName);
|
||||
}
|
||||
|
||||
private void UpdateRunButtons()
|
||||
{
|
||||
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
|
||||
StopButton.IsEnabled = _isRunning;
|
||||
StopButton.IsEnabled = _isRunning && !_isStopping;
|
||||
SessionStopButton.IsEnabled = _isRunning && !_isStopping;
|
||||
OpenFileButton.IsEnabled = !_isRunning;
|
||||
}
|
||||
|
||||
private void UpdateSessionBarVisibility()
|
||||
{
|
||||
SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
|
||||
!_gameFullscreen && WindowState != WindowState.FullScreen;
|
||||
}
|
||||
|
||||
// ---- Console ----
|
||||
|
||||
private void FlushPendingConsoleLines()
|
||||
|
||||
Reference in New Issue
Block a user