mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-22 19:06:15 +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:
@@ -5,6 +5,7 @@ path = [
|
|||||||
"REUSE.toml",
|
"REUSE.toml",
|
||||||
"nuget.config",
|
"nuget.config",
|
||||||
"global.json",
|
"global.json",
|
||||||
|
"**/packages.lock.json",
|
||||||
"scripts/ps5_names.txt",
|
"scripts/ps5_names.txt",
|
||||||
"src/SharpEmu.GUI/Languages/**",
|
"src/SharpEmu.GUI/Languages/**",
|
||||||
"_logs/**",
|
"_logs/**",
|
||||||
|
|||||||
+121
-54
@@ -69,10 +69,9 @@ internal static partial class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||||
if (args.Length == 0 && !isMitigatedChild)
|
|
||||||
|
if (args.Length == 0)
|
||||||
{
|
{
|
||||||
// No arguments: open the desktop frontend. Any argument selects
|
|
||||||
// the classic CLI behavior below.
|
|
||||||
return GuiLauncher.Run();
|
return GuiLauncher.Run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +227,17 @@ internal static partial class Program
|
|||||||
return childExitCode;
|
return childExitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryParseArguments(args, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
|
if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
HostSessionControl.SetEmbeddedHostSurface(
|
||||||
|
hostSurface?.WindowHandle ?? 0,
|
||||||
|
hostSurface?.DisplayHandle ?? 0);
|
||||||
|
|
||||||
|
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
|
||||||
{
|
{
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
return 1;
|
return 1;
|
||||||
@@ -255,66 +264,122 @@ internal static partial class Program
|
|||||||
|
|
||||||
Console.Error.WriteLine("[DEBUG] Creating runtime...");
|
Console.Error.WriteLine("[DEBUG] Creating runtime...");
|
||||||
|
|
||||||
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
|
|
||||||
|
|
||||||
OrbisGen2Result result;
|
|
||||||
ConsoleCancelEventHandler? cancelHandler = null;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
cancelHandler = (_, eventArgs) =>
|
if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
|
||||||
{
|
{
|
||||||
eventArgs.Cancel = true;
|
Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
|
||||||
VideoOutExports.NotifyHostInterrupt();
|
return 3;
|
||||||
};
|
}
|
||||||
Console.CancelKeyPress += cancelHandler;
|
|
||||||
|
|
||||||
Console.Error.WriteLine($"[DEBUG] Running: {ebootPath}");
|
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
|
||||||
result = runtime.Run(ebootPath);
|
|
||||||
Console.Error.WriteLine($"[DEBUG] Result: {result}");
|
OrbisGen2Result result;
|
||||||
}
|
ConsoleCancelEventHandler? cancelHandler = null;
|
||||||
catch (Exception ex)
|
try
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[DEBUG] Exception: {ex}");
|
cancelHandler = (_, eventArgs) =>
|
||||||
Log.Error("SharpEmu failed to run.", ex);
|
{
|
||||||
return 3;
|
eventArgs.Cancel = true;
|
||||||
|
VideoOutExports.NotifyHostInterrupt();
|
||||||
|
};
|
||||||
|
Console.CancelKeyPress += cancelHandler;
|
||||||
|
|
||||||
|
Console.Error.WriteLine($"[DEBUG] Running: {ebootPath}");
|
||||||
|
result = runtime.Run(ebootPath);
|
||||||
|
Console.Error.WriteLine($"[DEBUG] Result: {result}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[DEBUG] Exception: {ex}");
|
||||||
|
Log.Error("SharpEmu failed to run.", ex);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (cancelHandler is not null)
|
||||||
|
{
|
||||||
|
Console.CancelKeyPress -= cancelHandler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Info($"SharpEmu execution completed. Result={result} (0x{(int)result:X8})");
|
||||||
|
if (!string.IsNullOrWhiteSpace(runtime.LastSessionSummary))
|
||||||
|
{
|
||||||
|
Log.Info(runtime.LastSessionSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(runtime.LastBasicBlockTrace))
|
||||||
|
{
|
||||||
|
Log.Info("BB trace:");
|
||||||
|
Log.Info(runtime.LastBasicBlockTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(runtime.LastMilestoneLog))
|
||||||
|
{
|
||||||
|
Log.Info(runtime.LastMilestoneLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result != OrbisGen2Result.ORBIS_GEN2_OK && !string.IsNullOrWhiteSpace(runtime.LastExecutionDiagnostics))
|
||||||
|
{
|
||||||
|
Log.Warn(runtime.LastExecutionDiagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeOptions.ImportTraceLimit > 0 && !string.IsNullOrWhiteSpace(runtime.LastExecutionTrace))
|
||||||
|
{
|
||||||
|
Log.Info("Import trace:");
|
||||||
|
Log.Info(runtime.LastExecutionTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (cancelHandler is not null)
|
HostSessionControl.SetEmbeddedHostSurface(0);
|
||||||
|
if (hostSurface is not null)
|
||||||
{
|
{
|
||||||
Console.CancelKeyPress -= cancelHandler;
|
VulkanVideoHost.RequestClose();
|
||||||
|
VulkanVideoHost.DetachSurface(hostSurface);
|
||||||
|
hostSurface.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryExtractHostSurfaceArgument(
|
||||||
|
IReadOnlyList<string> args,
|
||||||
|
out string[] emulatorArgs,
|
||||||
|
out VulkanHostSurface? hostSurface,
|
||||||
|
out string? error)
|
||||||
|
{
|
||||||
|
const string hostSurfacePrefix = "--host-surface=";
|
||||||
|
var remaining = new List<string>(args.Count);
|
||||||
|
hostSurface = null;
|
||||||
|
error = null;
|
||||||
|
foreach (var argument in args)
|
||||||
|
{
|
||||||
|
if (!argument.StartsWith(hostSurfacePrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
remaining.Add(argument);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostSurface is not null)
|
||||||
|
{
|
||||||
|
emulatorArgs = [];
|
||||||
|
error = "more than one GUI host surface was specified";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var descriptor = argument[hostSurfacePrefix.Length..];
|
||||||
|
if (!VulkanHostSurface.TryCreateChildProcessSurface(descriptor, out hostSurface, out error))
|
||||||
|
{
|
||||||
|
emulatorArgs = [];
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.Info($"SharpEmu execution completed. Result={result} (0x{(int)result:X8})");
|
emulatorArgs = remaining.ToArray();
|
||||||
if (!string.IsNullOrWhiteSpace(runtime.LastSessionSummary))
|
return true;
|
||||||
{
|
|
||||||
Log.Info(runtime.LastSessionSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(runtime.LastBasicBlockTrace))
|
|
||||||
{
|
|
||||||
Log.Info("BB trace:");
|
|
||||||
Log.Info(runtime.LastBasicBlockTrace);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(runtime.LastMilestoneLog))
|
|
||||||
{
|
|
||||||
Log.Info(runtime.LastMilestoneLog);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result != OrbisGen2Result.ORBIS_GEN2_OK && !string.IsNullOrWhiteSpace(runtime.LastExecutionDiagnostics))
|
|
||||||
{
|
|
||||||
Log.Warn(runtime.LastExecutionDiagnostics);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runtimeOptions.ImportTraceLimit > 0 && !string.IsNullOrWhiteSpace(runtime.LastExecutionTrace))
|
|
||||||
{
|
|
||||||
Log.Info("Import trace:");
|
|
||||||
Log.Info(runtime.LastExecutionTrace);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsureCliConsole()
|
private static void EnsureCliConsole()
|
||||||
@@ -411,7 +476,9 @@ internal static partial class Program
|
|||||||
return handle != 0 && handle != -1;
|
return handle != 0 && handle != -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string[] NormalizeInternalArguments(string[] args, out bool isMitigatedChild)
|
private static string[] NormalizeInternalArguments(
|
||||||
|
string[] args,
|
||||||
|
out bool isMitigatedChild)
|
||||||
{
|
{
|
||||||
isMitigatedChild = false;
|
isMitigatedChild = false;
|
||||||
var trustedMitigatedChild = string.Equals(
|
var trustedMitigatedChild = string.Equals(
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
|
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
|
||||||
<ApplicationIcon>..\..\assets\images\SharpEmu.ico</ApplicationIcon>
|
<ApplicationIcon>..\..\assets\images\SharpEmu.ico</ApplicationIcon>
|
||||||
<Win32Icon>..\..\assets\images\SharpEmu.ico</Win32Icon>
|
<Win32Icon>..\..\assets\images\SharpEmu.ico</Win32Icon>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="SharpEmu" />
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Required by Avalonia NativeControlHost on Windows 10 and 11. -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0": {
|
||||||
|
"Microsoft.NET.ILLink.Tasks": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[10.0.3, )",
|
||||||
|
"resolved": "10.0.3",
|
||||||
|
"contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA=="
|
||||||
|
},
|
||||||
|
"Avalonia.Angle.Windows.Natives": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.1.25547.20250602",
|
||||||
|
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||||
|
},
|
||||||
|
"Avalonia.BuildServices": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.2",
|
||||||
|
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
|
||||||
|
},
|
||||||
|
"Avalonia.FreeDesktop": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "aUwv8BNruRUOaUfMu4U3uibIUS60/rSHgGOhd8zBkLkpxY3JFJvgRbeq5ZzHIyKXCuKi18PO00YHAgCarp3wdw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18",
|
||||||
|
"Tmds.DBus.Protocol": "0.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Native": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Remote.Protocol": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "vw+6ZfgTuu72dA9aVWn6u56t2nrBd5MoMU0wo/qI9XJAl/c0oYYphIvwLvJP1JorubQY4UE3d0ac8ULBhrGBiA=="
|
||||||
|
},
|
||||||
|
"Avalonia.Skia": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "/B4aXmNRNjG8I5U/a1xJI+bIi0XO6DDzS3mBrIKlVnJRY2CyZiUeESRQXLnIU77Z9TvqkUROs+D47s085YjFtA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18",
|
||||||
|
"HarfBuzzSharp": "8.3.1.1",
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1",
|
||||||
|
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1",
|
||||||
|
"SkiaSharp": "2.88.9",
|
||||||
|
"SkiaSharp.NativeAssets.Linux": "2.88.9",
|
||||||
|
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "eioUHkM2PeLPETd1aEks3rvb9plbba6buIrNdrqCpwE/qgHKUjvRNBd5mUQfAbGgTLiAes524gB8uUMDhrsJVQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18",
|
||||||
|
"Avalonia.Angle.Windows.Natives": "2.1.25547.20250602"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.X11": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "m4Ki/G5Dovnq+6QzfS0iGbK8V77Q6oTjToMLOB0CxPCCrl3Oxywh6kIjuGJDPaN6kopMmjxlNShyQf+vPYL+JA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18",
|
||||||
|
"Avalonia.FreeDesktop": "11.3.18",
|
||||||
|
"Avalonia.Skia": "11.3.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1",
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.WebAssembly": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||||
|
},
|
||||||
|
"MicroCom.Runtime": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "0.11.0",
|
||||||
|
"contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA=="
|
||||||
|
},
|
||||||
|
"Microsoft.DotNet.PlatformAbstractions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.1.6",
|
||||||
|
"contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.DependencyModel": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.9",
|
||||||
|
"contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
|
||||||
|
},
|
||||||
|
"Silk.NET.Core": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.DotNet.PlatformAbstractions": "3.1.6",
|
||||||
|
"Microsoft.Extensions.DependencyModel": "9.0.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Silk.NET.GLFW": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Silk.NET.Core": "2.23.0",
|
||||||
|
"Ultz.Native.GLFW": "3.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Silk.NET.Input.Common": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
|
||||||
|
"dependencies": {
|
||||||
|
"Silk.NET.Windowing.Common": "2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Silk.NET.Input.Glfw": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
|
||||||
|
"dependencies": {
|
||||||
|
"Silk.NET.Input.Common": "2.23.0",
|
||||||
|
"Silk.NET.Windowing.Glfw": "2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Silk.NET.Maths": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
|
||||||
|
},
|
||||||
|
"Silk.NET.Windowing.Common": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Silk.NET.Core": "2.23.0",
|
||||||
|
"Silk.NET.Maths": "2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Silk.NET.Windowing.Glfw": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
|
||||||
|
"dependencies": {
|
||||||
|
"Silk.NET.GLFW": "2.23.0",
|
||||||
|
"Silk.NET.Windowing.Common": "2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"SkiaSharp.NativeAssets.Win32": "2.88.9",
|
||||||
|
"SkiaSharp.NativeAssets.macOS": "2.88.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||||
|
"dependencies": {
|
||||||
|
"SkiaSharp": "2.88.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.WebAssembly": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||||
|
},
|
||||||
|
"Ultz.Native.GLFW": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.4.0",
|
||||||
|
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||||
|
},
|
||||||
|
"sharpemu.core": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"Iced": "[1.21.0, )",
|
||||||
|
"SharpEmu.HLE": "[0.0.2-beta.2, )",
|
||||||
|
"SharpEmu.Libs": "[0.0.2-beta.2, )",
|
||||||
|
"SharpEmu.Logging": "[0.0.2-beta.2, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sharpemu.gui": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "[11.3.18, )",
|
||||||
|
"Avalonia.Desktop": "[11.3.18, )",
|
||||||
|
"Avalonia.Fonts.Inter": "[11.3.18, )",
|
||||||
|
"Avalonia.Themes.Fluent": "[11.3.18, )",
|
||||||
|
"SharpEmu.Core": "[0.0.2-beta.2, )",
|
||||||
|
"SharpEmu.Libs": "[0.0.2-beta.2, )",
|
||||||
|
"SharpEmu.Logging": "[0.0.2-beta.2, )",
|
||||||
|
"Tmds.DBus.Protocol": "[0.21.3, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sharpemu.hle": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"SharpEmu.Logging": "[0.0.2-beta.2, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sharpemu.libs": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"SharpEmu.HLE": "[0.0.2-beta.2, )",
|
||||||
|
"SharpEmu.ShaderCompiler": "[0.0.2-beta.2, )",
|
||||||
|
"SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.2, )",
|
||||||
|
"Silk.NET.Input": "[2.23.0, )",
|
||||||
|
"Silk.NET.Vulkan": "[2.23.0, )",
|
||||||
|
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
|
||||||
|
"Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
|
||||||
|
"Silk.NET.Windowing": "[2.23.0, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sharpemu.logging": {
|
||||||
|
"type": "Project"
|
||||||
|
},
|
||||||
|
"sharpemu.shadercompiler": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"SharpEmu.HLE": "[0.0.2-beta.2, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sharpemu.shadercompiler.vulkan": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"SharpEmu.ShaderCompiler": "[0.0.2-beta.2, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[11.3.18, )",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "2C4UxhWUObWGgYKWic1x5BMMWGJP6SElb91WeOxs+X/iR26rtkqpxFFwwo50FXS9AyYnHfk8QKXDEfe7oT/kZA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia.BuildServices": "11.3.2",
|
||||||
|
"Avalonia.Remote.Protocol": "11.3.18",
|
||||||
|
"MicroCom.Runtime": "0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Desktop": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[11.3.18, )",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "bilMPa5vYiis6fbNovb6esKytBnOCEGojBa1XFegLCRHCP6g6PvZwS0XF/YOAGkENRlHG8dI7lohOpQ9bIkq1g==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18",
|
||||||
|
"Avalonia.Native": "11.3.18",
|
||||||
|
"Avalonia.Skia": "11.3.18",
|
||||||
|
"Avalonia.Win32": "11.3.18",
|
||||||
|
"Avalonia.X11": "11.3.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Fonts.Inter": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[11.3.18, )",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Themes.Fluent": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[11.3.18, )",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Iced": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[1.21.0, )",
|
||||||
|
"resolved": "1.21.0",
|
||||||
|
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
|
||||||
|
},
|
||||||
|
"Silk.NET.Input": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.23.0, )",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Silk.NET.Input.Common": "2.23.0",
|
||||||
|
"Silk.NET.Input.Glfw": "2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Silk.NET.Vulkan": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.23.0, )",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Silk.NET.Core": "2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Silk.NET.Vulkan.Extensions.EXT": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.23.0, )",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Silk.NET.Core": "2.23.0",
|
||||||
|
"Silk.NET.Vulkan": "2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Silk.NET.Vulkan.Extensions.KHR": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.23.0, )",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Silk.NET.Core": "2.23.0",
|
||||||
|
"Silk.NET.Vulkan": "2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Silk.NET.Windowing": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.23.0, )",
|
||||||
|
"resolved": "2.23.0",
|
||||||
|
"contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Silk.NET.Windowing.Common": "2.23.0",
|
||||||
|
"Silk.NET.Windowing.Glfw": "2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Tmds.DBus.Protocol": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[0.21.3, )",
|
||||||
|
"resolved": "0.21.3",
|
||||||
|
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"net10.0/linux-x64": {
|
||||||
|
"Avalonia.Angle.Windows.Natives": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.1.25547.20250602",
|
||||||
|
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||||
|
},
|
||||||
|
"Avalonia.Native": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||||
|
"dependencies": {
|
||||||
|
"SkiaSharp": "2.88.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||||
|
},
|
||||||
|
"Ultz.Native.GLFW": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.4.0",
|
||||||
|
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"net10.0/osx-arm64": {
|
||||||
|
"Avalonia.Angle.Windows.Natives": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.1.25547.20250602",
|
||||||
|
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||||
|
},
|
||||||
|
"Avalonia.Native": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||||
|
"dependencies": {
|
||||||
|
"SkiaSharp": "2.88.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||||
|
},
|
||||||
|
"Ultz.Native.GLFW": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.4.0",
|
||||||
|
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"net10.0/osx-x64": {
|
||||||
|
"Avalonia.Angle.Windows.Natives": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.1.25547.20250602",
|
||||||
|
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||||
|
},
|
||||||
|
"Avalonia.Native": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||||
|
"dependencies": {
|
||||||
|
"SkiaSharp": "2.88.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||||
|
},
|
||||||
|
"Ultz.Native.GLFW": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.4.0",
|
||||||
|
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"net10.0/win-x64": {
|
||||||
|
"Avalonia.Angle.Windows.Natives": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.1.25547.20250602",
|
||||||
|
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||||
|
},
|
||||||
|
"Avalonia.Native": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.18",
|
||||||
|
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.3.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.1",
|
||||||
|
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||||
|
"dependencies": {
|
||||||
|
"SkiaSharp": "2.88.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.88.9",
|
||||||
|
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||||
|
},
|
||||||
|
"Ultz.Native.GLFW": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.4.0",
|
||||||
|
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -693,6 +693,13 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
|||||||
return _virtualMemory.TryWrite(address, buffer);
|
return _virtualMemory.TryWrite(address, buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the disposed native backend left its session state alive
|
||||||
|
/// because guest workers were still executing guest code. The guest
|
||||||
|
/// address space must then stay mapped as well.
|
||||||
|
/// </summary>
|
||||||
|
internal bool NativeSessionLeaked { get; private set; }
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_nativeCpuBackend is IDisposable disposableBackend)
|
if (_nativeCpuBackend is IDisposable disposableBackend)
|
||||||
@@ -700,6 +707,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
|||||||
disposableBackend.Dispose();
|
disposableBackend.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NativeSessionLeaked = _nativeCpuBackend is DirectExecutionBackend { GuestSessionLeaked: true };
|
||||||
_nativeCpuBackend = null;
|
_nativeCpuBackend = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -718,7 +718,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
|
|
||||||
private string? _guestThreadYieldReason;
|
private string? _guestThreadYieldReason;
|
||||||
|
|
||||||
private bool _forcedGuestExit;
|
private volatile bool _forcedGuestExit;
|
||||||
|
|
||||||
private ulong _lastAvTraceRip;
|
private ulong _lastAvTraceRip;
|
||||||
|
|
||||||
@@ -917,7 +917,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
|
|
||||||
private bool ActiveForcedGuestExit
|
private bool ActiveForcedGuestExit
|
||||||
{
|
{
|
||||||
get => HasActiveExecutionThread ? _activeForcedGuestExit : _forcedGuestExit;
|
// Host shutdown is requested from a UI or VideoOut thread. Native guest
|
||||||
|
// workers have their own thread-local execution state, so they must also
|
||||||
|
// observe the backend-wide shutdown flag.
|
||||||
|
get => _forcedGuestExit || (HasActiveExecutionThread && _activeForcedGuestExit);
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (HasActiveExecutionThread)
|
if (HasActiveExecutionThread)
|
||||||
@@ -3409,6 +3412,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
{
|
{
|
||||||
returnValue = 0;
|
returnValue = 0;
|
||||||
error = null;
|
error = null;
|
||||||
|
if (_forcedGuestExit)
|
||||||
|
{
|
||||||
|
error = "guest execution is shutting down";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (entryPoint < 65536)
|
if (entryPoint < 65536)
|
||||||
{
|
{
|
||||||
error = $"invalid guest callback entry=0x{entryPoint:X16}";
|
error = $"invalid guest callback entry=0x{entryPoint:X16}";
|
||||||
@@ -3655,6 +3663,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
out string? error)
|
out string? error)
|
||||||
{
|
{
|
||||||
error = null;
|
error = null;
|
||||||
|
if (_forcedGuestExit)
|
||||||
|
{
|
||||||
|
error = "guest execution is shutting down";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (continuation.Rip < 65536 || continuation.Rsp == 0)
|
if (continuation.Rip < 65536 || continuation.Rsp == 0)
|
||||||
{
|
{
|
||||||
error = $"invalid guest continuation rip=0x{continuation.Rip:X16} rsp=0x{continuation.Rsp:X16}";
|
error = $"invalid guest continuation rip=0x{continuation.Rip:X16} rsp=0x{continuation.Rsp:X16}";
|
||||||
@@ -4685,6 +4698,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
|
|
||||||
private void RunGuestThread(GuestThreadState thread, string reason)
|
private void RunGuestThread(GuestThreadState thread, string reason)
|
||||||
{
|
{
|
||||||
|
if (_forcedGuestExit)
|
||||||
|
{
|
||||||
|
// Host shutdown: never enter guest code again. Teardown is about to
|
||||||
|
// free trampolines and the guest address space, and it only waits
|
||||||
|
// for executors that are already inside a slice.
|
||||||
|
lock (_guestThreadGate)
|
||||||
|
{
|
||||||
|
thread.State = GuestThreadRunState.Faulted;
|
||||||
|
thread.BlockReason = "host shutdown";
|
||||||
|
thread.HostThread = null;
|
||||||
|
thread.ExecutorActive = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
lock (_guestThreadGate)
|
lock (_guestThreadGate)
|
||||||
{
|
{
|
||||||
if (!thread.ExecutorActive)
|
if (!thread.ExecutorActive)
|
||||||
@@ -5526,7 +5554,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
{
|
{
|
||||||
num6 = CallNativeEntry(ptr);
|
num6 = CallNativeEntry(ptr);
|
||||||
Console.Error.WriteLine($"[LOADER][INFO] Guest returned: {num6}");
|
Console.Error.WriteLine($"[LOADER][INFO] Guest returned: {num6}");
|
||||||
PumpUntilGuestThreadsIdle(context, "entry_return");
|
// A host stop has already invalidated the session. Draining guest
|
||||||
|
// continuations here can re-enter a blocked HLE call after its owner
|
||||||
|
// has exited, preventing the embedded GUI from receiving its exit
|
||||||
|
// callback.
|
||||||
|
if (!ActiveForcedGuestExit)
|
||||||
|
{
|
||||||
|
PumpUntilGuestThreadsIdle(context, "entry_return");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (AccessViolationException ex)
|
catch (AccessViolationException ex)
|
||||||
{
|
{
|
||||||
@@ -6179,8 +6214,68 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool Win32CloseHandle(nint hObject);
|
private static extern bool Win32CloseHandle(nint hObject);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set when <see cref="Dispose"/> intentionally left the native session
|
||||||
|
/// state (import trampolines, TLS, exception handlers) alive because guest
|
||||||
|
/// worker threads were still executing guest code. The owning runtime must
|
||||||
|
/// then keep the guest address space mapped as well; freeing either under a
|
||||||
|
/// running worker faults the whole process, which hosts the GUI launcher.
|
||||||
|
/// </summary>
|
||||||
|
internal bool GuestSessionLeaked { get; private set; }
|
||||||
|
|
||||||
|
private bool WaitForGuestThreadQuiescence(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = Stopwatch.GetTimestamp() + (long)(timeout.TotalSeconds * Stopwatch.Frequency);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var busyCount = 0;
|
||||||
|
string? busyName = null;
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
using (LockGate("WaitForGuestThreadQuiescence"))
|
||||||
|
{
|
||||||
|
foreach (var thread in _guestThreads.Values)
|
||||||
|
{
|
||||||
|
if (thread.ExecutorActive)
|
||||||
|
{
|
||||||
|
busyCount++;
|
||||||
|
busyName ??= thread.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (busyCount == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= deadline)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] {busyCount} guest worker(s) (first: '{busyName}') did not leave guest code " +
|
||||||
|
"during shutdown; the native session state stays alive to avoid faulting them.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public unsafe void Dispose()
|
public unsafe void Dispose()
|
||||||
{
|
{
|
||||||
|
// Guest workers unwind cooperatively at their next import or block
|
||||||
|
// boundary once the forced-exit flag is set. Everything freed below is
|
||||||
|
// still reachable from a worker inside guest code, so drain the
|
||||||
|
// scheduler first and leak the session rather than fault a straggler.
|
||||||
|
_forcedGuestExit = true;
|
||||||
|
StopReadyThreadDispatcher();
|
||||||
|
StopStallWatchdog();
|
||||||
|
if (!WaitForGuestThreadQuiescence(TimeSpan.FromSeconds(5)))
|
||||||
|
{
|
||||||
|
GuestSessionLeaked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearGuestThreads();
|
||||||
if (ReferenceEquals(_posixSignalBackend, this))
|
if (ReferenceEquals(_posixSignalBackend, this))
|
||||||
{
|
{
|
||||||
// The signal handlers stay installed (they chain to the previous
|
// The signal handlers stay installed (they chain to the previous
|
||||||
|
|||||||
@@ -189,6 +189,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
|||||||
|
|
||||||
Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}");
|
Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}");
|
||||||
Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}");
|
Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}");
|
||||||
|
|
||||||
|
// Stop is a host operation, not an emulation failure. The detailed
|
||||||
|
// trace and session-summary builders can traverse a partially torn
|
||||||
|
// down native backend, delaying the GUI exit callback indefinitely.
|
||||||
|
if (HostSessionControl.IsShutdownRequested)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[RUNTIME] Skipping post-exit diagnostics for host shutdown.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
|
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
|
||||||
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
|
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
|
||||||
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
|
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
|
||||||
@@ -1152,6 +1162,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
|||||||
disposableDispatcher.Dispose();
|
disposableDispatcher.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_cpuDispatcher is CpuDispatcher { NativeSessionLeaked: true })
|
||||||
|
{
|
||||||
|
// A guest worker is still inside guest code; unmapping the guest
|
||||||
|
// address space under it would fault the whole process, which
|
||||||
|
// hosts the GUI launcher in embedded sessions.
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[RUNTIME] Guest workers were still active at teardown; keeping the guest address space mapped.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_virtualMemory is IDisposable disposableMemory)
|
if (_virtualMemory is IDisposable disposableMemory)
|
||||||
{
|
{
|
||||||
disposableMemory.Dispose();
|
disposableMemory.Dispose();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
||||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
||||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
||||||
|
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
|
||||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
||||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
@@ -55,6 +56,22 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<Setter Property="Padding" Value="10,3" />
|
<Setter Property="Padding" Value="10,3" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Session status/hotkey badges: the title-id pill geometry with a
|
||||||
|
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
|
||||||
|
<Style Selector="Border.badge">
|
||||||
|
<Setter Property="CornerRadius" Value="999" />
|
||||||
|
<Setter Property="Padding" Value="8,2" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.badge.running">
|
||||||
|
<Setter Property="Background" Value="#1E46C46B" />
|
||||||
|
<Setter Property="BorderBrush" Value="#5546C46B" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.badge.key">
|
||||||
|
<Setter Property="Background" Value="#1E58A6FF" />
|
||||||
|
<Setter Property="BorderBrush" Value="#5558A6FF" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.sectionTitle">
|
<Style Selector="TextBlock.sectionTitle">
|
||||||
<Setter Property="FontSize" Value="11" />
|
<Setter Property="FontSize" Value="11" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
|||||||
+331
-316
@@ -1,7 +1,6 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -10,32 +9,36 @@ using Microsoft.Win32.SafeHandles;
|
|||||||
namespace SharpEmu.GUI;
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Launches the SharpEmu CLI as a child process with the same CET/CFG mitigation
|
/// Owns an isolated emulator process. Guest virtual memory is fixed-address and
|
||||||
/// opt-outs the CLI would apply to its own relaunched child, while capturing
|
/// cannot be reliably reused while guest-created host threads are still alive,
|
||||||
/// stdout/stderr through pipes. The CLI's internal relaunch is suppressed via
|
/// so the GUI must never execute a game in its own process.
|
||||||
/// SHARPEMU_DISABLE_MITIGATION_RELAUNCH so output is not lost to a detached
|
|
||||||
/// console. A kill-on-close job object ties the emulator's lifetime to the GUI.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class EmulatorProcess : IDisposable
|
internal sealed class EmulatorProcess : IDisposable
|
||||||
{
|
{
|
||||||
private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
|
public const int HostStopExitCode = -2;
|
||||||
private const uint CREATE_NO_WINDOW = 0x08000000;
|
|
||||||
private const int STARTF_USESTDHANDLES = 0x00000100;
|
private const uint ExtendedStartupInfoPresent = 0x00080000;
|
||||||
private const uint HANDLE_FLAG_INHERIT = 0x00000001;
|
private const uint CreateNoWindow = 0x08000000;
|
||||||
private const uint INFINITE = 0xFFFFFFFF;
|
private const int StartfUseStdHandles = 0x00000100;
|
||||||
private const int PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY = 0x00020007;
|
private const uint HandleFlagInherit = 0x00000001;
|
||||||
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
|
private const uint Infinite = 0xFFFFFFFF;
|
||||||
private const int JobObjectExtendedLimitInformation = 9;
|
private const int ProcThreadAttributeMitigationPolicy = 0x00020007;
|
||||||
private const ulong PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
|
private const uint JobObjectLimitKillOnJobClose = 0x00002000;
|
||||||
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF = 0x00000002UL << 28;
|
private const int JobObjectExtendedLimitInformationClass = 9;
|
||||||
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF = 0x00000002UL << 32;
|
private const string MitigatedChildFlag = "--sharpemu-mitigated-child";
|
||||||
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
|
private const string MitigatedChildEnvironment = "SHARPEMU_MITIGATED_CHILD";
|
||||||
|
private const ulong ControlFlowGuardAlwaysOff = 0x00000002UL << 40;
|
||||||
|
private const ulong CetUserShadowStacksAlwaysOff = 0x00000002UL << 28;
|
||||||
|
private const ulong UserCetSetContextIpValidationAlwaysOff = 0x00000002UL << 32;
|
||||||
|
|
||||||
|
private static readonly object EnvironmentGate = new();
|
||||||
|
|
||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
private nint _processHandle;
|
private nint _processHandle;
|
||||||
private nint _jobHandle;
|
private nint _jobHandle;
|
||||||
private Process? _fallbackProcess;
|
private Process? _fallbackProcess;
|
||||||
private bool _running;
|
private bool _running;
|
||||||
|
private bool _stopRequested;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public event Action<string, bool>? OutputReceived;
|
public event Action<string, bool>? OutputReceived;
|
||||||
@@ -55,29 +58,34 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
|
|
||||||
public void Start(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
public void Start(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
||||||
{
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(exePath);
|
||||||
|
ArgumentNullException.ThrowIfNull(arguments);
|
||||||
|
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ThrowIfDisposed();
|
||||||
if (_running)
|
if (_running)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The emulator process is already running.");
|
throw new InvalidOperationException("The emulator process is already running.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows())
|
_stopRequested = false;
|
||||||
{
|
|
||||||
StartWindows(exePath, arguments, workingDirectory);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
StartFallback(exePath, arguments, workingDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
_running = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
StartWindows(exePath, arguments, workingDirectory);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StartFallback(exePath, arguments, workingDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
|
nint processHandle;
|
||||||
|
nint jobHandle;
|
||||||
|
Process? fallbackProcess;
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
if (!_running)
|
if (!_running)
|
||||||
@@ -85,28 +93,35 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer terminating the job: it kills the whole tree, including
|
_stopRequested = true;
|
||||||
// any children the emulator spawned, even when the main process
|
processHandle = _processHandle;
|
||||||
// is wedged in a GPU driver call.
|
jobHandle = _jobHandle;
|
||||||
if (_jobHandle != 0)
|
fallbackProcess = _fallbackProcess;
|
||||||
{
|
}
|
||||||
_ = TerminateJobObject(_jobHandle, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_processHandle != 0)
|
if (jobHandle != 0)
|
||||||
{
|
{
|
||||||
_ = TerminateProcess(_processHandle, 1);
|
_ = TerminateJobObject(jobHandle, unchecked((uint)HostStopExitCode));
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
if (processHandle != 0)
|
||||||
|
{
|
||||||
|
_ = TerminateProcess(processHandle, unchecked((uint)HostStopExitCode));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (fallbackProcess is { HasExited: false })
|
||||||
{
|
{
|
||||||
_fallbackProcess?.Kill(entireProcessTree: true);
|
fallbackProcess.Kill(entireProcessTree: true);
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
// Already exited.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// The process exited while Stop was handling the request.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -124,121 +139,206 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
Stop();
|
Stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartWindows(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
private void StartFallback(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
||||||
{
|
{
|
||||||
// The CLI would otherwise relaunch itself into a mitigated child whose
|
var startInfo = new ProcessStartInfo(exePath)
|
||||||
// console output cannot flow through our pipes.
|
|
||||||
Environment.SetEnvironmentVariable("SHARPEMU_DISABLE_MITIGATION_RELAUNCH", "1");
|
|
||||||
|
|
||||||
var securityAttributes = new SECURITY_ATTRIBUTES
|
|
||||||
{
|
{
|
||||||
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
|
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory)
|
||||||
bInheritHandle = 1,
|
? Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory
|
||||||
|
: workingDirectory,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8,
|
||||||
};
|
};
|
||||||
|
foreach (var argument in arguments)
|
||||||
if (!CreatePipe(out var stdoutRead, out var stdoutWrite, ref securityAttributes, 0) ||
|
|
||||||
!CreatePipe(out var stderrRead, out var stderrWrite, ref securityAttributes, 0))
|
|
||||||
{
|
{
|
||||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create output pipes.");
|
startInfo.ArgumentList.Add(argument);
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0);
|
var process = Process.Start(startInfo)
|
||||||
_ = SetHandleInformation(stderrRead, HANDLE_FLAG_INHERIT, 0);
|
?? throw new InvalidOperationException("Could not start the emulator process.");
|
||||||
|
process.EnableRaisingEvents = true;
|
||||||
|
process.OutputDataReceived += (_, eventArgs) => ForwardOutput(eventArgs.Data, isError: false);
|
||||||
|
process.ErrorDataReceived += (_, eventArgs) => ForwardOutput(eventArgs.Data, isError: true);
|
||||||
|
process.Exited += (_, _) => OnExited(process.ExitCode);
|
||||||
|
|
||||||
var startupInfoEx = new STARTUPINFOEX();
|
lock (_sync)
|
||||||
startupInfoEx.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
|
{
|
||||||
startupInfoEx.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
_fallbackProcess = process;
|
||||||
startupInfoEx.StartupInfo.hStdOutput = stdoutWrite;
|
_running = true;
|
||||||
startupInfoEx.StartupInfo.hStdError = stderrWrite;
|
}
|
||||||
|
|
||||||
|
process.BeginOutputReadLine();
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe void StartWindows(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
||||||
|
{
|
||||||
|
nint stdoutRead = 0;
|
||||||
|
nint stdoutWrite = 0;
|
||||||
|
nint stderrRead = 0;
|
||||||
|
nint stderrWrite = 0;
|
||||||
nint attributeList = 0;
|
nint attributeList = 0;
|
||||||
nint mitigationPolicies = 0;
|
nint mitigationPolicies = 0;
|
||||||
|
nint processHandle = 0;
|
||||||
|
nint threadHandle = 0;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var security = new SecurityAttributes
|
||||||
|
{
|
||||||
|
Size = Marshal.SizeOf<SecurityAttributes>(),
|
||||||
|
InheritHandle = 1,
|
||||||
|
};
|
||||||
|
if (!CreatePipe(out stdoutRead, out stdoutWrite, ref security, 0) ||
|
||||||
|
!CreatePipe(out stderrRead, out stderrWrite, ref security, 0))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not create emulator output pipes (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SetHandleInformation(stdoutRead, HandleFlagInherit, 0) ||
|
||||||
|
!SetHandleInformation(stderrRead, HandleFlagInherit, 0))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not configure emulator output pipes (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||||
|
}
|
||||||
|
|
||||||
nuint attributeListSize = 0;
|
nuint attributeListSize = 0;
|
||||||
_ = InitializeProcThreadAttributeList(0, 1, 0, ref attributeListSize);
|
_ = InitializeProcThreadAttributeList(0, 1, 0, ref attributeListSize);
|
||||||
attributeList = Marshal.AllocHGlobal((nint)attributeListSize);
|
attributeList = Marshal.AllocHGlobal((nint)attributeListSize);
|
||||||
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize))
|
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize))
|
||||||
{
|
{
|
||||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize the process attribute list.");
|
throw new InvalidOperationException($"Could not initialize process mitigation attributes (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||||
}
|
}
|
||||||
|
|
||||||
startupInfoEx.lpAttributeList = attributeList;
|
|
||||||
|
|
||||||
var policy1 = PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF;
|
|
||||||
var policy2 =
|
|
||||||
PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF |
|
|
||||||
PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF;
|
|
||||||
|
|
||||||
mitigationPolicies = Marshal.AllocHGlobal(sizeof(ulong) * 2);
|
mitigationPolicies = Marshal.AllocHGlobal(sizeof(ulong) * 2);
|
||||||
Marshal.WriteInt64(mitigationPolicies, unchecked((long)policy1));
|
Marshal.WriteInt64(mitigationPolicies, unchecked((long)ControlFlowGuardAlwaysOff));
|
||||||
Marshal.WriteInt64(nint.Add(mitigationPolicies, sizeof(long)), unchecked((long)policy2));
|
Marshal.WriteInt64(
|
||||||
|
nint.Add(mitigationPolicies, sizeof(long)),
|
||||||
|
unchecked((long)(CetUserShadowStacksAlwaysOff | UserCetSetContextIpValidationAlwaysOff)));
|
||||||
if (!UpdateProcThreadAttribute(
|
if (!UpdateProcThreadAttribute(
|
||||||
attributeList,
|
attributeList,
|
||||||
0,
|
0,
|
||||||
PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY,
|
(nint)ProcThreadAttributeMitigationPolicy,
|
||||||
mitigationPolicies,
|
mitigationPolicies,
|
||||||
(nuint)(sizeof(ulong) * 2),
|
(nuint)(sizeof(ulong) * 2),
|
||||||
0,
|
0,
|
||||||
0))
|
0))
|
||||||
{
|
{
|
||||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to apply the mitigation policy.");
|
throw new InvalidOperationException($"Could not apply process mitigations (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentDirectory = workingDirectory ?? Environment.CurrentDirectory;
|
var startup = new StartupInfoEx();
|
||||||
var created = CreateProcessW(
|
startup.StartupInfo.Size = Marshal.SizeOf<StartupInfoEx>();
|
||||||
exePath,
|
startup.StartupInfo.Flags = StartfUseStdHandles;
|
||||||
new StringBuilder(BuildCommandLine(exePath, arguments)),
|
startup.StartupInfo.StdOutput = stdoutWrite;
|
||||||
0,
|
startup.StartupInfo.StdError = stderrWrite;
|
||||||
0,
|
startup.AttributeList = attributeList;
|
||||||
true,
|
|
||||||
EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW,
|
|
||||||
0,
|
|
||||||
currentDirectory,
|
|
||||||
ref startupInfoEx,
|
|
||||||
out var processInfo);
|
|
||||||
|
|
||||||
if (!created)
|
var childArguments = new List<string>(arguments.Count + 1) { MitigatedChildFlag };
|
||||||
|
childArguments.AddRange(arguments);
|
||||||
|
var commandLine = new StringBuilder(BuildCommandLine(exePath, childArguments));
|
||||||
|
ProcessInformation processInfo;
|
||||||
|
lock (EnvironmentGate)
|
||||||
{
|
{
|
||||||
var error = Marshal.GetLastWin32Error();
|
var previousValue = Environment.GetEnvironmentVariable(MitigatedChildEnvironment);
|
||||||
throw new Win32Exception(error, $"Failed to start '{exePath}' with CET/CFG mitigation disabled (Win32 error {error}: {new Win32Exception(error).Message}).");
|
try
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||||
|
if (!CreateProcessW(
|
||||||
|
exePath,
|
||||||
|
commandLine,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
ExtendedStartupInfoPresent | CreateNoWindow,
|
||||||
|
0,
|
||||||
|
string.IsNullOrWhiteSpace(workingDirectory)
|
||||||
|
? Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory
|
||||||
|
: workingDirectory,
|
||||||
|
ref startup,
|
||||||
|
out processInfo))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not start the emulator process (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, previousValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CloseHandle(processInfo.hThread);
|
processHandle = processInfo.Process;
|
||||||
_processHandle = processInfo.hProcess;
|
threadHandle = processInfo.Thread;
|
||||||
|
CloseHandle(stdoutWrite);
|
||||||
|
stdoutWrite = 0;
|
||||||
|
CloseHandle(stderrWrite);
|
||||||
|
stderrWrite = 0;
|
||||||
|
|
||||||
_jobHandle = CreateJobObjectW(0, null);
|
var jobHandle = CreateJobObjectW(0, null);
|
||||||
if (_jobHandle != 0 &&
|
if (jobHandle != 0 &&
|
||||||
(!TryEnableKillOnJobClose(_jobHandle) || !AssignProcessToJobObject(_jobHandle, processInfo.hProcess)))
|
(!TryEnableKillOnJobClose(jobHandle) || !AssignProcessToJobObject(jobHandle, processHandle)))
|
||||||
{
|
{
|
||||||
CloseHandle(_jobHandle);
|
CloseHandle(jobHandle);
|
||||||
_jobHandle = 0;
|
jobHandle = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
StartReaderThread(stdoutRead, isError: false);
|
lock (_sync)
|
||||||
StartReaderThread(stderrRead, isError: true);
|
{
|
||||||
StartExitWatcherThread();
|
_processHandle = processHandle;
|
||||||
|
_jobHandle = jobHandle;
|
||||||
|
_running = true;
|
||||||
|
}
|
||||||
|
processHandle = 0;
|
||||||
|
|
||||||
|
StartPipeReader(stdoutRead, isError: false);
|
||||||
|
stdoutRead = 0;
|
||||||
|
StartPipeReader(stderrRead, isError: true);
|
||||||
|
stderrRead = 0;
|
||||||
|
StartWindowsExitWatcher(_processHandle);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
CloseHandle(stdoutRead);
|
if (processHandle != 0)
|
||||||
CloseHandle(stderrRead);
|
{
|
||||||
|
_ = TerminateProcess(processHandle, 1);
|
||||||
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// The child owns duplicated pipe write ends; closing ours lets the
|
if (threadHandle != 0)
|
||||||
// readers observe EOF when the child exits.
|
{
|
||||||
CloseHandle(stdoutWrite);
|
CloseHandle(threadHandle);
|
||||||
CloseHandle(stderrWrite);
|
}
|
||||||
|
if (processHandle != 0)
|
||||||
|
{
|
||||||
|
CloseHandle(processHandle);
|
||||||
|
}
|
||||||
|
if (stdoutRead != 0)
|
||||||
|
{
|
||||||
|
CloseHandle(stdoutRead);
|
||||||
|
}
|
||||||
|
if (stdoutWrite != 0)
|
||||||
|
{
|
||||||
|
CloseHandle(stdoutWrite);
|
||||||
|
}
|
||||||
|
if (stderrRead != 0)
|
||||||
|
{
|
||||||
|
CloseHandle(stderrRead);
|
||||||
|
}
|
||||||
|
if (stderrWrite != 0)
|
||||||
|
{
|
||||||
|
CloseHandle(stderrWrite);
|
||||||
|
}
|
||||||
if (attributeList != 0)
|
if (attributeList != 0)
|
||||||
{
|
{
|
||||||
DeleteProcThreadAttributeList(attributeList);
|
DeleteProcThreadAttributeList(attributeList);
|
||||||
Marshal.FreeHGlobal(attributeList);
|
Marshal.FreeHGlobal(attributeList);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mitigationPolicies != 0)
|
if (mitigationPolicies != 0)
|
||||||
{
|
{
|
||||||
Marshal.FreeHGlobal(mitigationPolicies);
|
Marshal.FreeHGlobal(mitigationPolicies);
|
||||||
@@ -246,103 +346,56 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartFallback(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
private void StartPipeReader(nint handle, bool isError)
|
||||||
{
|
{
|
||||||
var startInfo = new ProcessStartInfo
|
var readerThread = new Thread(() =>
|
||||||
{
|
{
|
||||||
FileName = exePath,
|
using var safeHandle = new SafeFileHandle(handle, ownsHandle: true);
|
||||||
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
|
using var stream = new FileStream(safeHandle, FileAccess.Read, 4096, isAsync: false);
|
||||||
UseShellExecute = false,
|
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||||
CreateNoWindow = true,
|
while (reader.ReadLine() is { } line)
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
};
|
|
||||||
foreach (var argument in arguments)
|
|
||||||
{
|
|
||||||
startInfo.ArgumentList.Add(argument);
|
|
||||||
}
|
|
||||||
|
|
||||||
var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
|
||||||
process.OutputDataReceived += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.Data is not null)
|
|
||||||
{
|
{
|
||||||
OutputReceived?.Invoke(e.Data, false);
|
ForwardOutput(line, isError);
|
||||||
}
|
}
|
||||||
};
|
}, 256 * 1024)
|
||||||
process.ErrorDataReceived += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.Data is not null)
|
|
||||||
{
|
|
||||||
OutputReceived?.Invoke(e.Data, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
process.Exited += (_, _) =>
|
|
||||||
{
|
|
||||||
int exitCode;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
exitCode = process.ExitCode;
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
exitCode = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnExited(exitCode);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
process.BeginOutputReadLine();
|
|
||||||
process.BeginErrorReadLine();
|
|
||||||
_fallbackProcess = process;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartReaderThread(nint readHandle, bool isError)
|
|
||||||
{
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
using var stream = new FileStream(new SafeFileHandle(readHandle, ownsHandle: true), FileAccess.Read);
|
|
||||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (reader.ReadLine() is { } line)
|
|
||||||
{
|
|
||||||
OutputReceived?.Invoke(line, isError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
|
||||||
// Pipe broken on process teardown.
|
|
||||||
}
|
|
||||||
})
|
|
||||||
{
|
{
|
||||||
IsBackground = true,
|
IsBackground = true,
|
||||||
Name = isError ? "SharpEmu stderr reader" : "SharpEmu stdout reader",
|
Name = isError ? "SharpEmu stderr reader" : "SharpEmu stdout reader",
|
||||||
};
|
};
|
||||||
thread.Start();
|
readerThread.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartExitWatcherThread()
|
private void StartWindowsExitWatcher(nint processHandle)
|
||||||
{
|
{
|
||||||
var processHandle = _processHandle;
|
var watcher = new Thread(() =>
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
{
|
||||||
_ = WaitForSingleObject(processHandle, INFINITE);
|
_ = WaitForSingleObject(processHandle, Infinite);
|
||||||
var exitCode = GetExitCodeProcess(processHandle, out var rawExitCode)
|
var exitCode = 1;
|
||||||
? unchecked((int)rawExitCode)
|
if (GetExitCodeProcess(processHandle, out var nativeExitCode))
|
||||||
: -1;
|
{
|
||||||
|
exitCode = unchecked((int)nativeExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
OnExited(exitCode);
|
OnExited(exitCode);
|
||||||
})
|
}, 128 * 1024)
|
||||||
{
|
{
|
||||||
IsBackground = true,
|
IsBackground = true,
|
||||||
Name = "SharpEmu exit watcher",
|
Name = "SharpEmu exit watcher",
|
||||||
};
|
};
|
||||||
thread.Start();
|
watcher.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnExited(int exitCode)
|
private void ForwardOutput(string? line, bool isError)
|
||||||
{
|
{
|
||||||
|
if (!string.IsNullOrEmpty(line))
|
||||||
|
{
|
||||||
|
OutputReceived?.Invoke(line, isError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExited(int nativeExitCode)
|
||||||
|
{
|
||||||
|
int exitCode;
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
if (!_running)
|
if (!_running)
|
||||||
@@ -350,13 +403,13 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exitCode = _stopRequested ? HostStopExitCode : nativeExitCode;
|
||||||
_running = false;
|
_running = false;
|
||||||
if (_processHandle != 0)
|
if (_processHandle != 0)
|
||||||
{
|
{
|
||||||
CloseHandle(_processHandle);
|
CloseHandle(_processHandle);
|
||||||
_processHandle = 0;
|
_processHandle = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_jobHandle != 0)
|
if (_jobHandle != 0)
|
||||||
{
|
{
|
||||||
CloseHandle(_jobHandle);
|
CloseHandle(_jobHandle);
|
||||||
@@ -372,24 +425,19 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
|
|
||||||
private static bool TryEnableKillOnJobClose(nint jobHandle)
|
private static bool TryEnableKillOnJobClose(nint jobHandle)
|
||||||
{
|
{
|
||||||
var extendedLimitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
var info = new JobObjectExtendedLimitInformation
|
||||||
{
|
{
|
||||||
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
|
BasicLimitInformation = new JobObjectBasicLimitInformation
|
||||||
{
|
{
|
||||||
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
LimitFlags = JobObjectLimitKillOnJobClose,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
var size = Marshal.SizeOf<JobObjectExtendedLimitInformation>();
|
||||||
var size = Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
|
|
||||||
var memory = Marshal.AllocHGlobal(size);
|
var memory = Marshal.AllocHGlobal(size);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Marshal.StructureToPtr(extendedLimitInfo, memory, false);
|
Marshal.StructureToPtr(info, memory, false);
|
||||||
return SetInformationJobObject(
|
return SetInformationJobObject(jobHandle, JobObjectExtendedLimitInformationClass, memory, unchecked((uint)size));
|
||||||
jobHandle,
|
|
||||||
JobObjectExtendedLimitInformation,
|
|
||||||
memory,
|
|
||||||
unchecked((uint)size));
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -397,128 +445,124 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildCommandLine(string processPath, IReadOnlyList<string> args)
|
private static string BuildCommandLine(string processPath, IReadOnlyList<string> arguments)
|
||||||
{
|
{
|
||||||
var builder = new StringBuilder();
|
var builder = new StringBuilder(QuoteArgument(processPath));
|
||||||
builder.Append(QuoteArgument(processPath));
|
foreach (var argument in arguments)
|
||||||
for (var i = 0; i < args.Count; i++)
|
|
||||||
{
|
{
|
||||||
builder.Append(' ');
|
builder.Append(' ');
|
||||||
builder.Append(QuoteArgument(args[i]));
|
builder.Append(QuoteArgument(argument));
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.ToString();
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string QuoteArgument(string argument)
|
private static string QuoteArgument(string value)
|
||||||
{
|
{
|
||||||
if (argument.Length == 0)
|
if (value.Length == 0)
|
||||||
{
|
{
|
||||||
return "\"\"";
|
return "\"\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
var needsQuotes = false;
|
if (!value.Any(static c => char.IsWhiteSpace(c) || c == '"'))
|
||||||
foreach (var c in argument)
|
|
||||||
{
|
{
|
||||||
if (char.IsWhiteSpace(c) || c == '"')
|
return value;
|
||||||
{
|
|
||||||
needsQuotes = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!needsQuotes)
|
var builder = new StringBuilder(value.Length + 2);
|
||||||
{
|
|
||||||
return argument;
|
|
||||||
}
|
|
||||||
|
|
||||||
var builder = new StringBuilder(argument.Length + 2);
|
|
||||||
builder.Append('"');
|
builder.Append('"');
|
||||||
|
var slashCount = 0;
|
||||||
var backslashCount = 0;
|
foreach (var character in value)
|
||||||
foreach (var c in argument)
|
|
||||||
{
|
{
|
||||||
if (c == '\\')
|
if (character == '\\')
|
||||||
{
|
{
|
||||||
backslashCount++;
|
slashCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c == '"')
|
if (character == '"')
|
||||||
{
|
{
|
||||||
builder.Append('\\', (backslashCount * 2) + 1);
|
builder.Append('\\', (slashCount * 2) + 1);
|
||||||
builder.Append('"');
|
builder.Append(character);
|
||||||
backslashCount = 0;
|
slashCount = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backslashCount > 0)
|
if (slashCount > 0)
|
||||||
{
|
{
|
||||||
builder.Append('\\', backslashCount);
|
builder.Append('\\', slashCount);
|
||||||
backslashCount = 0;
|
slashCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.Append(c);
|
builder.Append(character);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backslashCount > 0)
|
if (slashCount > 0)
|
||||||
{
|
{
|
||||||
builder.Append('\\', backslashCount * 2);
|
builder.Append('\\', slashCount * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.Append('"');
|
builder.Append('"');
|
||||||
return builder.ToString();
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
private void ThrowIfDisposed()
|
||||||
private struct SECURITY_ATTRIBUTES
|
|
||||||
{
|
{
|
||||||
public int nLength;
|
if (_disposed)
|
||||||
public nint lpSecurityDescriptor;
|
{
|
||||||
public int bInheritHandle;
|
throw new ObjectDisposedException(nameof(EmulatorProcess));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct SecurityAttributes
|
||||||
|
{
|
||||||
|
public int Size;
|
||||||
|
public nint SecurityDescriptor;
|
||||||
|
public int InheritHandle;
|
||||||
}
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
private struct STARTUPINFO
|
private struct StartupInfo
|
||||||
{
|
{
|
||||||
public int cb;
|
public int Size;
|
||||||
public nint lpReserved;
|
public nint Reserved;
|
||||||
public nint lpDesktop;
|
public nint Desktop;
|
||||||
public nint lpTitle;
|
public nint Title;
|
||||||
public int dwX;
|
public int X;
|
||||||
public int dwY;
|
public int Y;
|
||||||
public int dwXSize;
|
public int XSize;
|
||||||
public int dwYSize;
|
public int YSize;
|
||||||
public int dwXCountChars;
|
public int XCountChars;
|
||||||
public int dwYCountChars;
|
public int YCountChars;
|
||||||
public int dwFillAttribute;
|
public int FillAttribute;
|
||||||
public int dwFlags;
|
public int Flags;
|
||||||
public short wShowWindow;
|
public short ShowWindow;
|
||||||
public short cbReserved2;
|
public short Reserved2Count;
|
||||||
public nint lpReserved2;
|
public nint Reserved2;
|
||||||
public nint hStdInput;
|
public nint StdInput;
|
||||||
public nint hStdOutput;
|
public nint StdOutput;
|
||||||
public nint hStdError;
|
public nint StdError;
|
||||||
}
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
private struct STARTUPINFOEX
|
private struct StartupInfoEx
|
||||||
{
|
{
|
||||||
public STARTUPINFO StartupInfo;
|
public StartupInfo StartupInfo;
|
||||||
public nint lpAttributeList;
|
public nint AttributeList;
|
||||||
}
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
private struct PROCESS_INFORMATION
|
private struct ProcessInformation
|
||||||
{
|
{
|
||||||
public nint hProcess;
|
public nint Process;
|
||||||
public nint hThread;
|
public nint Thread;
|
||||||
public int dwProcessId;
|
public int ProcessId;
|
||||||
public int dwThreadId;
|
public int ThreadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
private struct JobObjectBasicLimitInformation
|
||||||
{
|
{
|
||||||
public long PerProcessUserTimeLimit;
|
public long PerProcessUserTimeLimit;
|
||||||
public long PerJobUserTimeLimit;
|
public long PerJobUserTimeLimit;
|
||||||
@@ -532,7 +576,7 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
private struct IO_COUNTERS
|
private struct IoCounters
|
||||||
{
|
{
|
||||||
public ulong ReadOperationCount;
|
public ulong ReadOperationCount;
|
||||||
public ulong WriteOperationCount;
|
public ulong WriteOperationCount;
|
||||||
@@ -543,10 +587,10 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
private struct JobObjectExtendedLimitInformation
|
||||||
{
|
{
|
||||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
public JobObjectBasicLimitInformation BasicLimitInformation;
|
||||||
public IO_COUNTERS IoInfo;
|
public IoCounters IoInfo;
|
||||||
public nuint ProcessMemoryLimit;
|
public nuint ProcessMemoryLimit;
|
||||||
public nuint JobMemoryLimit;
|
public nuint JobMemoryLimit;
|
||||||
public nuint PeakProcessMemoryUsed;
|
public nuint PeakProcessMemoryUsed;
|
||||||
@@ -555,66 +599,37 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool CreatePipe(
|
private static extern bool CreatePipe(out nint readPipe, out nint writePipe, ref SecurityAttributes attributes, uint size);
|
||||||
out nint hReadPipe,
|
|
||||||
out nint hWritePipe,
|
|
||||||
ref SECURITY_ATTRIBUTES lpPipeAttributes,
|
|
||||||
uint nSize);
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool SetHandleInformation(nint hObject, uint dwMask, uint dwFlags);
|
private static extern bool SetHandleInformation(nint handle, uint mask, uint flags);
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool InitializeProcThreadAttributeList(
|
private static extern bool InitializeProcThreadAttributeList(nint list, int count, int flags, ref nuint size);
|
||||||
nint lpAttributeList,
|
|
||||||
int dwAttributeCount,
|
|
||||||
int dwFlags,
|
|
||||||
ref nuint lpSize);
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool UpdateProcThreadAttribute(
|
private static extern bool UpdateProcThreadAttribute(nint list, uint flags, nint attribute, nint value, nuint size, nint previousValue, nint returnSize);
|
||||||
nint lpAttributeList,
|
|
||||||
uint dwFlags,
|
|
||||||
nint attribute,
|
|
||||||
nint lpValue,
|
|
||||||
nuint cbSize,
|
|
||||||
nint lpPreviousValue,
|
|
||||||
nint lpReturnSize);
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll")]
|
[DllImport("kernel32.dll")]
|
||||||
private static extern void DeleteProcThreadAttributeList(nint lpAttributeList);
|
private static extern void DeleteProcThreadAttributeList(nint list);
|
||||||
|
|
||||||
[DllImport("kernel32.dll", EntryPoint = "CreateJobObjectW", SetLastError = true, CharSet = CharSet.Unicode)]
|
[DllImport("kernel32.dll", EntryPoint = "CreateJobObjectW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
private static extern nint CreateJobObjectW(nint lpJobAttributes, string? lpName);
|
private static extern nint CreateJobObjectW(nint attributes, string? name);
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool SetInformationJobObject(
|
private static extern bool SetInformationJobObject(nint job, int infoClass, nint info, uint size);
|
||||||
nint hJob,
|
|
||||||
int jobObjectInfoClass,
|
|
||||||
nint lpJobObjectInfo,
|
|
||||||
uint cbJobObjectInfoLength);
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool AssignProcessToJobObject(nint hJob, nint hProcess);
|
private static extern bool AssignProcessToJobObject(nint job, nint process);
|
||||||
|
|
||||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool CreateProcessW(
|
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||||
string applicationName,
|
|
||||||
StringBuilder commandLine,
|
|
||||||
nint processAttributes,
|
|
||||||
nint threadAttributes,
|
|
||||||
[MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
|
|
||||||
uint creationFlags,
|
|
||||||
nint environment,
|
|
||||||
string currentDirectory,
|
|
||||||
ref STARTUPINFOEX startupInfo,
|
|
||||||
out PROCESS_INFORMATION processInformation);
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
|
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using SharpEmu.Libs.VideoOut;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Native child surface owned by Avalonia. The isolated emulator process uses
|
||||||
|
/// its platform handle to create the Vulkan presentation surface, keeping the
|
||||||
|
/// guest address space out of the GUI process.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameSurfaceHost : NativeControlHost
|
||||||
|
{
|
||||||
|
private const uint SwpNoSize = 0x0001;
|
||||||
|
private const uint SwpNoMove = 0x0002;
|
||||||
|
private const uint SwpNoZOrder = 0x0004;
|
||||||
|
private const uint SwpNoActivate = 0x0010;
|
||||||
|
private const uint SwpShowWindow = 0x0040;
|
||||||
|
private const uint SwpHideWindow = 0x0080;
|
||||||
|
private const uint WsChild = 0x40000000;
|
||||||
|
private const uint WsVisible = 0x10000000;
|
||||||
|
private const uint WsClipSiblings = 0x04000000;
|
||||||
|
private const uint WsClipChildren = 0x02000000;
|
||||||
|
private const uint CsOwnDc = 0x0020;
|
||||||
|
private const uint WmSetCursor = 0x0020;
|
||||||
|
private const uint WmMouseMove = 0x0200;
|
||||||
|
private const int IdcArrow = 32512;
|
||||||
|
private const int CursorHideDelayMs = 2500;
|
||||||
|
|
||||||
|
private VulkanHostSurface? _surface;
|
||||||
|
private nint _windowHandle;
|
||||||
|
private nint _x11Display;
|
||||||
|
private string? _win32ClassName;
|
||||||
|
private WindowProcedure? _windowProcedure;
|
||||||
|
private nint _metalLayer;
|
||||||
|
private bool _presentationVisible = true;
|
||||||
|
private DispatcherTimer? _cursorIdleTimer;
|
||||||
|
private bool _cursorAutoHide;
|
||||||
|
private bool _cursorHidden;
|
||||||
|
private long _lastPointerActivity;
|
||||||
|
|
||||||
|
public GameSurfaceHost()
|
||||||
|
{
|
||||||
|
PropertyChanged += (_, change) =>
|
||||||
|
{
|
||||||
|
if (change.Property == BoundsProperty)
|
||||||
|
{
|
||||||
|
UpdateSurfaceSize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
LayoutUpdated += (_, _) =>
|
||||||
|
{
|
||||||
|
// Fullscreen can change a monitor's DPI scale without changing
|
||||||
|
// the logical Bounds. Refresh the native child from physical size.
|
||||||
|
UpdateSurfaceSize();
|
||||||
|
|
||||||
|
// NativeControlHost may make its HWND visible again as part of a
|
||||||
|
// later arrange pass. Keep a loading surface hidden until its
|
||||||
|
// child process reports a real first frame.
|
||||||
|
if (!_presentationVisible)
|
||||||
|
{
|
||||||
|
ApplyPresentationVisibility();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<VulkanHostSurface>? SurfaceAvailable;
|
||||||
|
|
||||||
|
public event EventHandler<VulkanHostSurface>? SurfaceDestroyed;
|
||||||
|
|
||||||
|
public VulkanHostSurface? Surface => _surface;
|
||||||
|
|
||||||
|
public void RefreshSurfaceSize() => UpdateSurfaceSize();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hides the platform child without detaching the Vulkan surface. This
|
||||||
|
/// allows the launcher to return to its library while guest teardown is
|
||||||
|
/// still finishing on the render thread.
|
||||||
|
/// </summary>
|
||||||
|
public void SetPresentationVisible(bool visible)
|
||||||
|
{
|
||||||
|
_presentationVisible = visible;
|
||||||
|
ApplyPresentationVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-hides the mouse cursor over the game surface after a short idle
|
||||||
|
/// period; any pointer movement brings it back. Enabling (again) restarts
|
||||||
|
/// the idle countdown, so both "first frame presented" and "entered
|
||||||
|
/// fullscreen" can arm it. Windows-only; a no-op elsewhere.
|
||||||
|
/// </summary>
|
||||||
|
public void SetCursorAutoHide(bool enabled)
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cursorAutoHide = enabled;
|
||||||
|
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
_cursorIdleTimer ??= CreateCursorIdleTimer();
|
||||||
|
_cursorIdleTimer.Start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cursorIdleTimer?.Stop();
|
||||||
|
ShowCursorNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DispatcherTimer CreateCursorIdleTimer()
|
||||||
|
{
|
||||||
|
var timer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(250),
|
||||||
|
};
|
||||||
|
timer.Tick += (_, _) => HideCursorWhenIdle();
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HideCursorWhenIdle()
|
||||||
|
{
|
||||||
|
if (!_cursorAutoHide || _cursorHidden || _windowHandle == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var idleMs = (System.Diagnostics.Stopwatch.GetTimestamp() - _lastPointerActivity) *
|
||||||
|
1000 / System.Diagnostics.Stopwatch.Frequency;
|
||||||
|
if (idleMs < CursorHideDelayMs)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only swallow the cursor while it is actually over the game surface;
|
||||||
|
// hovering launcher chrome (console, toolbar) must keep the arrow.
|
||||||
|
if (!GetCursorPos(out var point) || WindowFromPoint(point) != _windowHandle)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cursorHidden = true;
|
||||||
|
_ = SetCursor(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowCursorNow()
|
||||||
|
{
|
||||||
|
if (!_cursorHidden)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cursorHidden = false;
|
||||||
|
_ = SetCursor(LoadCursorW(0, IdcArrow));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyPresentationVisibility()
|
||||||
|
{
|
||||||
|
if (_windowHandle == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var visible = _presentationVisible;
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
// SW_HIDE can be ignored for a window's initial show state. Force
|
||||||
|
// the state through SetWindowPos so an old child swapchain cannot
|
||||||
|
// remain composed while the next game is loading.
|
||||||
|
var flags = SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate |
|
||||||
|
(visible ? SwpShowWindow : SwpHideWindow);
|
||||||
|
_ = SetWindowPos(_windowHandle, 0, 0, 0, 0, 0, flags);
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsLinux() && _x11Display != 0)
|
||||||
|
{
|
||||||
|
_ = visible
|
||||||
|
? XMapWindow(_x11Display, _windowHandle)
|
||||||
|
: XUnmapWindow(_x11Display, _windowHandle);
|
||||||
|
_ = XFlush(_x11Display);
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
SendBool(_windowHandle, "setHidden:", !visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control)
|
||||||
|
{
|
||||||
|
PlatformHandle handle;
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
handle = CreateWin32(control);
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
handle = CreateX11(control);
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
handle = CreateMacOS();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new PlatformNotSupportedException("SharpEmu's embedded Vulkan surface is unsupported on this platform.");
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateSurfaceSize();
|
||||||
|
if (_surface is { } surface)
|
||||||
|
{
|
||||||
|
SurfaceAvailable?.Invoke(this, surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void DestroyNativeControlCore(IPlatformHandle control)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
SetCursorAutoHide(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var surface = _surface;
|
||||||
|
_surface = null;
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
DestroyWin32();
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
DestroyX11();
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
DestroyMacOS();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (surface is not null)
|
||||||
|
{
|
||||||
|
SurfaceDestroyed?.Invoke(this, surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlatformHandle CreateWin32(IPlatformHandle control)
|
||||||
|
{
|
||||||
|
_win32ClassName = $"SharpEmuGameSurface-{Guid.NewGuid():N}";
|
||||||
|
_windowProcedure = WindowProcedureImpl;
|
||||||
|
var classInfo = new WndClassEx
|
||||||
|
{
|
||||||
|
Size = (uint)Marshal.SizeOf<WndClassEx>(),
|
||||||
|
Style = CsOwnDc,
|
||||||
|
WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure),
|
||||||
|
Instance = GetModuleHandleW(null),
|
||||||
|
ClassName = _win32ClassName,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (RegisterClassExW(ref classInfo) == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not register the embedded game window class (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowHandle = CreateWindowExW(
|
||||||
|
0,
|
||||||
|
_win32ClassName,
|
||||||
|
"SharpEmu Game Surface",
|
||||||
|
WsChild | (_presentationVisible ? WsVisible : 0) | WsClipSiblings | WsClipChildren,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
control.Handle,
|
||||||
|
0,
|
||||||
|
classInfo.Instance,
|
||||||
|
0);
|
||||||
|
if (_windowHandle == 0)
|
||||||
|
{
|
||||||
|
var error = Marshal.GetLastWin32Error();
|
||||||
|
_ = UnregisterClassW(_win32ClassName, classInfo.Instance);
|
||||||
|
throw new InvalidOperationException($"Could not create the embedded game window (Win32 error {error}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
_surface = new VulkanHostSurface(
|
||||||
|
VulkanHostSurfaceKind.Win32,
|
||||||
|
_windowHandle,
|
||||||
|
classInfo.Instance);
|
||||||
|
return new PlatformHandle(_windowHandle, "HWND");
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlatformHandle CreateX11(IPlatformHandle control)
|
||||||
|
{
|
||||||
|
_x11Display = XOpenDisplay(0);
|
||||||
|
if (_x11Display == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Could not connect to the X11 server for the embedded game surface.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowHandle = XCreateSimpleWindow(
|
||||||
|
_x11Display,
|
||||||
|
control.Handle,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0);
|
||||||
|
if (_windowHandle == 0)
|
||||||
|
{
|
||||||
|
XCloseDisplay(_x11Display);
|
||||||
|
_x11Display = 0;
|
||||||
|
throw new InvalidOperationException("Could not create the X11 embedded game surface.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_presentationVisible)
|
||||||
|
{
|
||||||
|
_ = XMapWindow(_x11Display, _windowHandle);
|
||||||
|
}
|
||||||
|
_ = XFlush(_x11Display);
|
||||||
|
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Xlib, _windowHandle, _x11Display);
|
||||||
|
return new PlatformHandle(_windowHandle, "X11");
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlatformHandle CreateMacOS()
|
||||||
|
{
|
||||||
|
_metalLayer = CreateObjectiveCObject("CAMetalLayer");
|
||||||
|
_windowHandle = CreateObjectiveCObject("NSView");
|
||||||
|
SendBool(_windowHandle, "setWantsLayer:", true);
|
||||||
|
SendPointer(_windowHandle, "setLayer:", _metalLayer);
|
||||||
|
SendBool(_windowHandle, "setHidden:", !_presentationVisible);
|
||||||
|
|
||||||
|
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Metal, _windowHandle, metalLayerHandle: _metalLayer);
|
||||||
|
return new PlatformHandle(_windowHandle, "NSView");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSurfaceSize()
|
||||||
|
{
|
||||||
|
if (_surface is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var renderScale = (VisualRoot as TopLevel)?.RenderScaling ?? 1.0;
|
||||||
|
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
|
||||||
|
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
|
||||||
|
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
|
||||||
|
_surface.UpdatePixelSize(width, height);
|
||||||
|
|
||||||
|
if (!sizeChanged)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows() && _windowHandle != 0)
|
||||||
|
{
|
||||||
|
_ = SetWindowPos(
|
||||||
|
_windowHandle,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
SwpNoMove | SwpNoZOrder | SwpNoActivate);
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsLinux() && _x11Display != 0 && _windowHandle != 0)
|
||||||
|
{
|
||||||
|
_ = XResizeWindow(_x11Display, _windowHandle, (uint)width, (uint)height);
|
||||||
|
_ = XFlush(_x11Display);
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsMacOS() && _metalLayer != 0)
|
||||||
|
{
|
||||||
|
SendDouble(_metalLayer, "setContentsScale:", renderScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DestroyWin32()
|
||||||
|
{
|
||||||
|
if (_windowHandle != 0)
|
||||||
|
{
|
||||||
|
_ = DestroyWindow(_windowHandle);
|
||||||
|
_windowHandle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_win32ClassName))
|
||||||
|
{
|
||||||
|
_ = UnregisterClassW(_win32ClassName, GetModuleHandleW(null));
|
||||||
|
_win32ClassName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowProcedure = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DestroyX11()
|
||||||
|
{
|
||||||
|
if (_x11Display != 0 && _windowHandle != 0)
|
||||||
|
{
|
||||||
|
_ = XDestroyWindow(_x11Display, _windowHandle);
|
||||||
|
}
|
||||||
|
if (_x11Display != 0)
|
||||||
|
{
|
||||||
|
_ = XCloseDisplay(_x11Display);
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowHandle = 0;
|
||||||
|
_x11Display = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DestroyMacOS()
|
||||||
|
{
|
||||||
|
if (_windowHandle != 0)
|
||||||
|
{
|
||||||
|
SendVoid(_windowHandle, "release");
|
||||||
|
}
|
||||||
|
if (_metalLayer != 0)
|
||||||
|
{
|
||||||
|
SendVoid(_metalLayer, "release");
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowHandle = 0;
|
||||||
|
_metalLayer = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nint WindowProcedureImpl(nint window, uint message, nint wParam, nint lParam)
|
||||||
|
{
|
||||||
|
if (message == WmMouseMove)
|
||||||
|
{
|
||||||
|
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||||
|
ShowCursorNow();
|
||||||
|
}
|
||||||
|
else if (message == WmSetCursor && _cursorHidden)
|
||||||
|
{
|
||||||
|
// Win32 re-resolves the cursor on every mouse message; returning
|
||||||
|
// TRUE here keeps the parent chain from restoring the arrow.
|
||||||
|
_ = SetCursor(0);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefWindowProcW(window, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static nint CreateObjectiveCObject(string className)
|
||||||
|
{
|
||||||
|
var classHandle = objc_getClass(className);
|
||||||
|
if (classHandle == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Objective-C class '{className}' is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var instance = objc_msgSend_id(classHandle, sel_registerName("alloc"));
|
||||||
|
instance = objc_msgSend_id(instance, sel_registerName("init"));
|
||||||
|
if (instance == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not create Objective-C '{className}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SendVoid(nint receiver, string selector) =>
|
||||||
|
objc_msgSend_void(receiver, sel_registerName(selector));
|
||||||
|
|
||||||
|
private static void SendBool(nint receiver, string selector, bool value) =>
|
||||||
|
objc_msgSend_bool(receiver, sel_registerName(selector), value ? (byte)1 : (byte)0);
|
||||||
|
|
||||||
|
private static void SendPointer(nint receiver, string selector, nint value) =>
|
||||||
|
objc_msgSend_pointer(receiver, sel_registerName(selector), value);
|
||||||
|
|
||||||
|
private static void SendDouble(nint receiver, string selector, double value) =>
|
||||||
|
objc_msgSend_double(receiver, sel_registerName(selector), value);
|
||||||
|
|
||||||
|
private delegate nint WindowProcedure(nint window, uint message, nint wParam, nint lParam);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct WndClassEx
|
||||||
|
{
|
||||||
|
public uint Size;
|
||||||
|
public uint Style;
|
||||||
|
public nint WindowProcedure;
|
||||||
|
public int ClassExtra;
|
||||||
|
public int WindowExtra;
|
||||||
|
public nint Instance;
|
||||||
|
public nint Icon;
|
||||||
|
public nint Cursor;
|
||||||
|
public nint Background;
|
||||||
|
public string? MenuName;
|
||||||
|
public string? ClassName;
|
||||||
|
public nint IconSmall;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern nint GetModuleHandleW(string? moduleName);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern ushort RegisterClassExW(ref WndClassEx classInfo);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool UnregisterClassW(string className, nint instance);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern nint CreateWindowExW(
|
||||||
|
uint extendedStyle,
|
||||||
|
string className,
|
||||||
|
string windowName,
|
||||||
|
uint style,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
nint parent,
|
||||||
|
nint menu,
|
||||||
|
nint instance,
|
||||||
|
nint parameter);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "DestroyWindow", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool DestroyWindow(nint window);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool SetWindowPos(
|
||||||
|
nint window,
|
||||||
|
nint insertAfter,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
uint flags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "SetCursor")]
|
||||||
|
private static extern nint SetCursor(nint cursor);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "LoadCursorW", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern nint LoadCursorW(nint instance, nint cursorName);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "GetCursorPos")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool GetCursorPos(out NativePoint point);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "WindowFromPoint")]
|
||||||
|
private static extern nint WindowFromPoint(NativePoint point);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct NativePoint
|
||||||
|
{
|
||||||
|
public int X;
|
||||||
|
public int Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
|
||||||
|
private static extern nint XOpenDisplay(nint displayName);
|
||||||
|
|
||||||
|
[DllImport("libX11.so.6", EntryPoint = "XCreateSimpleWindow")]
|
||||||
|
private static extern nint XCreateSimpleWindow(
|
||||||
|
nint display,
|
||||||
|
nint parent,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
uint width,
|
||||||
|
uint height,
|
||||||
|
uint borderWidth,
|
||||||
|
ulong border,
|
||||||
|
ulong background);
|
||||||
|
|
||||||
|
[DllImport("libX11.so.6", EntryPoint = "XMapWindow")]
|
||||||
|
private static extern int XMapWindow(nint display, nint window);
|
||||||
|
|
||||||
|
[DllImport("libX11.so.6", EntryPoint = "XUnmapWindow")]
|
||||||
|
private static extern int XUnmapWindow(nint display, nint window);
|
||||||
|
|
||||||
|
[DllImport("libX11.so.6", EntryPoint = "XResizeWindow")]
|
||||||
|
private static extern int XResizeWindow(nint display, nint window, uint width, uint height);
|
||||||
|
|
||||||
|
[DllImport("libX11.so.6", EntryPoint = "XDestroyWindow")]
|
||||||
|
private static extern int XDestroyWindow(nint display, nint window);
|
||||||
|
|
||||||
|
[DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
|
||||||
|
private static extern int XCloseDisplay(nint display);
|
||||||
|
|
||||||
|
[DllImport("libX11.so.6", EntryPoint = "XFlush")]
|
||||||
|
private static extern int XFlush(nint display);
|
||||||
|
|
||||||
|
[DllImport("/usr/lib/libobjc.A.dylib")]
|
||||||
|
private static extern nint objc_getClass(string name);
|
||||||
|
|
||||||
|
[DllImport("/usr/lib/libobjc.A.dylib")]
|
||||||
|
private static extern nint sel_registerName(string name);
|
||||||
|
|
||||||
|
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||||
|
private static extern nint objc_msgSend_id(nint receiver, nint selector);
|
||||||
|
|
||||||
|
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||||
|
private static extern void objc_msgSend_void(nint receiver, nint selector);
|
||||||
|
|
||||||
|
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||||
|
private static extern void objc_msgSend_bool(nint receiver, nint selector, byte value);
|
||||||
|
|
||||||
|
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||||
|
private static extern void objc_msgSend_pointer(nint receiver, nint selector, nint value);
|
||||||
|
|
||||||
|
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||||
|
private static extern void objc_msgSend_double(nint receiver, nint selector, double value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors process-wide console output into the launcher console while
|
||||||
|
/// retaining the original streams for shell users and file logging.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GuiConsoleMirror : IDisposable
|
||||||
|
{
|
||||||
|
private readonly TextWriter _originalOut;
|
||||||
|
private readonly TextWriter _originalError;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
private GuiConsoleMirror(Action<string, bool> writeLine)
|
||||||
|
{
|
||||||
|
_originalOut = Console.Out;
|
||||||
|
_originalError = Console.Error;
|
||||||
|
Console.SetOut(new MirroringWriter(_originalOut, line => writeLine(line, false)));
|
||||||
|
Console.SetError(new MirroringWriter(_originalError, line => writeLine(line, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GuiConsoleMirror Install(Action<string, bool> writeLine) => new(writeLine);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.SetOut(_originalOut);
|
||||||
|
Console.SetError(_originalError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MirroringWriter : TextWriter
|
||||||
|
{
|
||||||
|
private readonly TextWriter _inner;
|
||||||
|
private readonly Action<string> _writeLine;
|
||||||
|
private readonly StringBuilder _line = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
public MirroringWriter(TextWriter inner, Action<string> writeLine)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
_writeLine = writeLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Encoding Encoding => _inner.Encoding;
|
||||||
|
|
||||||
|
public override void Write(char value)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_inner.Write(value);
|
||||||
|
Append(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(string? value)
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_inner.Write(value);
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
Append(character);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void WriteLine(string? value)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_inner.WriteLine(value);
|
||||||
|
if (!string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
_line.Append(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
FlushLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Append(char value)
|
||||||
|
{
|
||||||
|
if (value == '\r')
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == '\n')
|
||||||
|
{
|
||||||
|
FlushLine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_line.Append(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushLine()
|
||||||
|
{
|
||||||
|
_writeLine(_line.ToString());
|
||||||
|
_line.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="clr-namespace:SharpEmu.GUI"
|
||||||
|
xmlns:primitives="clr-namespace:Avalonia.Controls.Primitives;assembly=Avalonia.Controls"
|
||||||
x:Class="SharpEmu.GUI.MainWindow"
|
x:Class="SharpEmu.GUI.MainWindow"
|
||||||
Title="SharpEmu"
|
Title="SharpEmu"
|
||||||
Width="1280" Height="820"
|
Width="1280" Height="820"
|
||||||
@@ -18,7 +20,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
|
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
|
||||||
KeyDown="OnKeyDown">
|
KeyDown="OnKeyDown">
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid x:Name="RootLayout" RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<!-- Selected-game backdrop: key art behind the main content, dimmed by
|
<!-- Selected-game backdrop: key art behind the main content, dimmed by
|
||||||
a scrim so tiles and text stay readable. Fades on selection. -->
|
a scrim so tiles and text stay readable. Fades on selection. -->
|
||||||
@@ -56,13 +58,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<Grid Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
|
<!-- The game owns the full client area while running. Session controls
|
||||||
|
use a native popup so they can stay above this native child surface. -->
|
||||||
|
<Border x:Name="GameView" Grid.Row="0" Grid.RowSpan="4" IsVisible="False" Background="#000000" ClipToBounds="True">
|
||||||
|
<Grid x:Name="GameSurfaceContainer" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Library / Options page switcher, with the library toolbar sharing
|
<!-- Library / Options page switcher, with the library toolbar sharing
|
||||||
the same row on the right. Plain buttons (not TabItem) so there is
|
the same row on the right. Plain buttons (not TabItem) so there is
|
||||||
no underline; LB/RB hint chips flank the pair and the gamepad's
|
no underline; LB/RB hint chips flank the pair and the gamepad's
|
||||||
shoulder buttons actually switch pages from anywhere. -->
|
shoulder buttons actually switch pages from anywhere. -->
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
|
<Grid x:Name="ContentToolbar" Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
||||||
<Border Classes="padHint" VerticalAlignment="Center">
|
<Border Classes="padHint" VerticalAlignment="Center">
|
||||||
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
|
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
|
||||||
@@ -568,7 +576,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Launch bar; capped so it does not sprawl on a maximized window. -->
|
<!-- Launch bar; capped so it does not sprawl on a maximized window. -->
|
||||||
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
|
<Border Grid.Row="3" x:Name="LaunchBar" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
@@ -633,6 +641,66 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Avalonia's regular overlay layer cannot appear over a native child
|
||||||
|
HWND/X11/Metal surface. Keep the running-session controls in a native
|
||||||
|
popup so the game reaches the bottom status bar without losing Stop. -->
|
||||||
|
<primitives:Popup x:Name="SessionBarPopup"
|
||||||
|
IsOpen="False"
|
||||||
|
PlacementTarget="{Binding #GameView}"
|
||||||
|
Placement="Bottom"
|
||||||
|
VerticalOffset="-66"
|
||||||
|
Topmost="True"
|
||||||
|
ShouldUseOverlayLayer="False"
|
||||||
|
TakesFocusFromNativeControl="False"
|
||||||
|
IsLightDismissEnabled="False">
|
||||||
|
<Border Classes="card" Width="598" Height="58" CornerRadius="16" Padding="14,8">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock x:Name="SessionGameTitle" Text="GAME RUNNING" FontSize="13" FontWeight="SemiBold"
|
||||||
|
MaxWidth="240" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
<Border Classes="badge running" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="RUNNING" FontSize="9" FontWeight="Bold" LetterSpacing="1"
|
||||||
|
Foreground="{StaticResource SuccessBrush}" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||||
|
<Border x:Name="SessionF11Badge" Classes="badge key" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="F11" FontSize="9" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource InfoBrush}" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock x:Name="SessionHintText" Text="Fullscreen" FontSize="11"
|
||||||
|
Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
|
<Button x:Name="SessionConsoleButton" Classes="ghost" Content="≡ Console" />
|
||||||
|
<Button x:Name="SessionStopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</primitives:Popup>
|
||||||
|
|
||||||
|
<!-- This is a native popup rather than an Avalonia overlay because the
|
||||||
|
emulated Vulkan surface is a native child window. -->
|
||||||
|
<primitives:Popup x:Name="SessionLoadingPopup"
|
||||||
|
IsOpen="False"
|
||||||
|
PlacementTarget="{Binding #GameView}"
|
||||||
|
Placement="Center"
|
||||||
|
Topmost="True"
|
||||||
|
ShouldUseOverlayLayer="False"
|
||||||
|
TakesFocusFromNativeControl="False"
|
||||||
|
IsLightDismissEnabled="False">
|
||||||
|
<Border Classes="card" Width="380" CornerRadius="18" Padding="22,18">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
|
||||||
|
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
|
||||||
|
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<ProgressBar IsIndeterminate="True" Height="5" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</primitives:Popup>
|
||||||
|
|
||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
|
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
|
||||||
ColumnDefinitions="*,Auto">
|
ColumnDefinitions="*,Auto">
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ using Avalonia.Platform;
|
|||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
|
using SharpEmu.Core.Cpu;
|
||||||
|
using SharpEmu.Core.Runtime;
|
||||||
using SharpEmu.HLE.Host;
|
using SharpEmu.HLE.Host;
|
||||||
using SharpEmu.HLE.Host.Windows;
|
using SharpEmu.HLE.Host.Windows;
|
||||||
|
using SharpEmu.Libs.VideoOut;
|
||||||
using SharpEmu.Logging;
|
using SharpEmu.Logging;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
@@ -28,6 +31,8 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
private const int MaxConsoleLines = 4000;
|
private const int MaxConsoleLines = 4000;
|
||||||
private const int MaxConsoleLinesPerFlush = 500;
|
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 DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
|
||||||
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
|
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 List<LogLine> _allConsoleLines = new();
|
||||||
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
|
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
|
||||||
private readonly DispatcherTimer _consoleFlushTimer;
|
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 GuiSettings _settings = new();
|
||||||
private EmulatorProcess? _emulator;
|
private EmulatorProcess? _emulator;
|
||||||
|
private GameSurfaceHost? _gameSurfaceHost;
|
||||||
private ConsoleWindow? _consoleWindow;
|
private ConsoleWindow? _consoleWindow;
|
||||||
|
private GuiConsoleMirror? _consoleMirror;
|
||||||
private StreamWriter? _fileLog;
|
private StreamWriter? _fileLog;
|
||||||
private readonly SndPreviewPlayer _sndPreview = new();
|
private readonly SndPreviewPlayer _sndPreview = new();
|
||||||
private string? _emulatorExePath;
|
private string? _emulatorExePath;
|
||||||
|
private PendingLaunch? _pendingLaunch;
|
||||||
|
private bool _gameFullscreen;
|
||||||
private bool _isRunning;
|
private bool _isRunning;
|
||||||
|
private bool _isStopping;
|
||||||
|
private bool _awaitingFirstFrame;
|
||||||
private int _autoScrollTicks;
|
private int _autoScrollTicks;
|
||||||
private int _activePageIndex;
|
private int _activePageIndex;
|
||||||
private Updater.UpdateInfo? _availableUpdate;
|
private Updater.UpdateInfo? _availableUpdate;
|
||||||
@@ -83,12 +100,21 @@ public partial class MainWindow : Window
|
|||||||
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
|
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
|
||||||
private string? _latestCommitSha;
|
private string? _latestCommitSha;
|
||||||
|
|
||||||
|
private sealed record PendingLaunch(
|
||||||
|
string EbootPath,
|
||||||
|
string DisplayName,
|
||||||
|
string? TitleId,
|
||||||
|
SharpEmuRuntimeOptions RuntimeOptions);
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
GameList.ItemsSource = _visibleGames;
|
GameList.ItemsSource = _visibleGames;
|
||||||
ConsoleList.ItemsSource = _consoleLines;
|
ConsoleList.ItemsSource = _consoleLines;
|
||||||
|
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
|
||||||
|
_pendingLines.Enqueue((line, isError)));
|
||||||
|
Closed += (_, _) => _emulator?.Stop();
|
||||||
|
|
||||||
_consoleFlushTimer = new DispatcherTimer
|
_consoleFlushTimer = new DispatcherTimer
|
||||||
{
|
{
|
||||||
@@ -101,6 +127,15 @@ public partial class MainWindow : Window
|
|||||||
};
|
};
|
||||||
_consoleFlushTimer.Start();
|
_consoleFlushTimer.Start();
|
||||||
|
|
||||||
|
_libraryBlurTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(16),
|
||||||
|
};
|
||||||
|
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
|
||||||
|
|
||||||
|
Activated += (_, _) => UpdateSessionBarVisibility();
|
||||||
|
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
|
||||||
|
|
||||||
TitleBar.PointerPressed += OnTitleBarPointerPressed;
|
TitleBar.PointerPressed += OnTitleBarPointerPressed;
|
||||||
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
|
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
|
||||||
GameList.DoubleTapped += (_, _) => LaunchSelected();
|
GameList.DoubleTapped += (_, _) => LaunchSelected();
|
||||||
@@ -111,10 +146,10 @@ public partial class MainWindow : Window
|
|||||||
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
||||||
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
||||||
LaunchButton.Click += (_, _) => LaunchSelected();
|
LaunchButton.Click += (_, _) => LaunchSelected();
|
||||||
StopButton.Click += (_, _) => _emulator?.Stop();
|
|
||||||
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
|
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
|
||||||
StopButton.Click += (_, _) => StopEmulator();
|
StopButton.Click += (_, _) => StopEmulator();
|
||||||
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
|
SessionStopButton.Click += (_, _) => StopEmulator();
|
||||||
|
SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
||||||
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
||||||
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
||||||
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
||||||
@@ -162,6 +197,7 @@ public partial class MainWindow : Window
|
|||||||
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
|
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
|
||||||
|
|
||||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||||
|
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
|
||||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||||
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||||
CtxCopyPath.Click += async (_, _) =>
|
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)
|
private void OnWindowFullScreen(object sender, RoutedEventArgs args)
|
||||||
{
|
{
|
||||||
if (WindowState == WindowState.FullScreen)
|
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;
|
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
|
||||||
TitleBar.IsVisible = true;
|
TitleBar.IsVisible = true;
|
||||||
StatusBar.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
|
else
|
||||||
{
|
{
|
||||||
@@ -698,18 +766,45 @@ public partial class MainWindow : Window
|
|||||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
||||||
TitleBar.IsVisible = false;
|
TitleBar.IsVisible = false;
|
||||||
StatusBar.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()
|
private void OnWindowClosing()
|
||||||
{
|
{
|
||||||
_settings.Save();
|
_settings.Save();
|
||||||
_consoleFlushTimer.Stop();
|
_consoleFlushTimer.Stop();
|
||||||
|
_libraryBlurTimer.Stop();
|
||||||
_gamepadTimer.Stop();
|
_gamepadTimer.Stop();
|
||||||
_sndPreview.Stop();
|
_sndPreview.Stop();
|
||||||
_discord?.Dispose();
|
_discord?.Dispose();
|
||||||
_consoleWindow?.Close();
|
_consoleWindow?.Close();
|
||||||
_emulator?.Dispose();
|
_emulator?.Dispose();
|
||||||
|
_consoleMirror?.Dispose();
|
||||||
DropFileLog();
|
DropFileLog();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,8 +991,8 @@ public partial class MainWindow : Window
|
|||||||
candidates.Add(_settings.EmulatorPath);
|
candidates.Add(_settings.EmulatorPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The GUI and the CLI are the same executable: with arguments it runs
|
// The GUI and CLI share one executable. The selected path is the
|
||||||
// the emulator, so the preferred child process is this process itself.
|
// isolated child executable and also defines the portable data root.
|
||||||
if (Environment.ProcessPath is { } selfPath &&
|
if (Environment.ProcessPath is { } selfPath &&
|
||||||
Path.GetFileNameWithoutExtension(selfPath).Equals("SharpEmu", StringComparison.OrdinalIgnoreCase))
|
Path.GetFileNameWithoutExtension(selfPath).Equals("SharpEmu", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -1576,89 +1671,18 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_emulatorExePath is null)
|
|
||||||
{
|
|
||||||
LocateEmulator();
|
|
||||||
if (_emulatorExePath is null)
|
|
||||||
{
|
|
||||||
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_sndPreview.Stop();
|
_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();
|
_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();
|
DropFileLog();
|
||||||
if (_settings.LogToFile)
|
if (_settings.LogToFile)
|
||||||
{
|
{
|
||||||
string filePath;
|
OpenFileLog(titleId);
|
||||||
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);
|
// The isolated game child inherits these diagnostics. Keep them on the
|
||||||
|
// launcher process so every platform receives the same launch options.
|
||||||
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.
|
|
||||||
foreach (var staleName in _appliedEnvironmentVariables)
|
foreach (var staleName in _appliedEnvironmentVariables)
|
||||||
{
|
{
|
||||||
if (!_settings.EnvironmentToggles.Contains(staleName))
|
if (!_settings.EnvironmentToggles.Contains(staleName))
|
||||||
@@ -1674,34 +1698,41 @@ public partial class MainWindow : Window
|
|||||||
_appliedEnvironmentVariables.Add(name);
|
_appliedEnvironmentVariables.Add(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
var emulator = new EmulatorProcess();
|
if (SharpEmuLog.TryParseLevel(_settings.LogLevel, out var logLevel))
|
||||||
emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError));
|
|
||||||
emulator.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code));
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
emulator.Start(_emulatorExePath, arguments, Path.GetDirectoryName(ebootPath));
|
SharpEmuLog.MinimumLevel = logLevel;
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
emulator.Dispose();
|
|
||||||
AppendConsoleLine(Localization.Instance.Format("Launch.StartFailed", ex.Message), ErrorLineBrush);
|
|
||||||
DropFileLog();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_emulator = emulator;
|
var runtimeOptions = new SharpEmuRuntimeOptions
|
||||||
|
{
|
||||||
|
CpuEngine = CpuExecutionEngine.NativeOnly,
|
||||||
|
StrictDynlibResolution = _settings.StrictDynlibResolution,
|
||||||
|
ImportTraceLimit = Math.Max(0, _settings.ImportTraceLimit),
|
||||||
|
};
|
||||||
|
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
_runningGameName = displayName;
|
_runningGameName = displayName;
|
||||||
_runningGameTitleId = _allGames
|
SessionGameTitle.Text = displayName;
|
||||||
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?
|
_runningGameTitleId = titleId ?? _allGames
|
||||||
.TitleId;
|
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId;
|
||||||
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
StatusDot.Fill = SuccessLineBrush;
|
StatusDot.Fill = SuccessLineBrush;
|
||||||
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
|
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
|
||||||
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
|
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
|
||||||
UpdateRunButtons();
|
UpdateRunButtons();
|
||||||
UpdateDiscordPresence();
|
UpdateDiscordPresence();
|
||||||
|
|
||||||
|
ShowGameView();
|
||||||
|
_pendingLaunch = new PendingLaunch(
|
||||||
|
Path.GetFullPath(ebootPath),
|
||||||
|
displayName,
|
||||||
|
_runningGameTitleId,
|
||||||
|
runtimeOptions);
|
||||||
|
|
||||||
|
if (_gameSurfaceHost?.Surface is { } surface)
|
||||||
|
{
|
||||||
|
StartPendingSession(surface);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1713,17 +1744,35 @@ public partial class MainWindow : Window
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void StopEmulator()
|
private void StopEmulator()
|
||||||
{
|
{
|
||||||
if (!_isRunning)
|
if (!_isRunning || _isStopping)
|
||||||
{
|
{
|
||||||
return;
|
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;
|
_runningGameName = null;
|
||||||
_runningGameTitleId = null;
|
_runningGameTitleId = null;
|
||||||
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
|
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
|
||||||
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
|
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
|
||||||
UpdateDiscordPresence();
|
UpdateDiscordPresence();
|
||||||
|
UpdateSessionBarVisibility();
|
||||||
|
ReturnToLibraryWhileStopping();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1734,7 +1783,7 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var exeDirectory = Path.GetDirectoryName(_emulatorExePath);
|
var exeDirectory = Path.GetDirectoryName(_emulatorExePath) ?? AppContext.BaseDirectory;
|
||||||
if (string.IsNullOrEmpty(exeDirectory))
|
if (string.IsNullOrEmpty(exeDirectory))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -1761,8 +1810,12 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
FlushPendingConsoleLines();
|
FlushPendingConsoleLines();
|
||||||
_isRunning = false;
|
_isRunning = false;
|
||||||
|
_isStopping = false;
|
||||||
_emulator?.Dispose();
|
_emulator?.Dispose();
|
||||||
_emulator = null;
|
_emulator = null;
|
||||||
|
_pendingLaunch = null;
|
||||||
|
DisposeGameSurfaceHost();
|
||||||
|
HideGameView();
|
||||||
|
|
||||||
var meaningKey = exitCode switch
|
var meaningKey = exitCode switch
|
||||||
{
|
{
|
||||||
@@ -1774,13 +1827,20 @@ public partial class MainWindow : Window
|
|||||||
-1073741819 => "Exit.EmulationError",
|
-1073741819 => "Exit.EmulationError",
|
||||||
_ => "Exit.Unknown",
|
_ => "Exit.Unknown",
|
||||||
};
|
};
|
||||||
|
var stoppedByUser = exitCode == EmulatorProcess.HostStopExitCode;
|
||||||
var meaning = Localization.Instance.Get(meaningKey);
|
var meaning = Localization.Instance.Get(meaningKey);
|
||||||
var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush;
|
var brush = exitCode == 0 || stoppedByUser ? SuccessLineBrush : ErrorLineBrush;
|
||||||
AppendConsoleLine(Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning), brush);
|
AppendConsoleLine(
|
||||||
|
stoppedByUser
|
||||||
|
? "Game closed by the user."
|
||||||
|
: Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning),
|
||||||
|
brush);
|
||||||
CloseFileLogSoon();
|
CloseFileLogSoon();
|
||||||
|
|
||||||
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
|
StatusDot.Fill = exitCode == 0 || stoppedByUser ? (IBrush)SuccessLineBrush : ErrorLineBrush;
|
||||||
StatusText.Text = Localization.Instance.Format("Launch.Exited", exitCode, meaning);
|
StatusText.Text = stoppedByUser
|
||||||
|
? "Game closed by the user."
|
||||||
|
: Localization.Instance.Format("Launch.Exited", exitCode, meaning);
|
||||||
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
|
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
|
||||||
_runningGameName = null;
|
_runningGameName = null;
|
||||||
_runningGameTitleId = null;
|
_runningGameTitleId = null;
|
||||||
@@ -1788,13 +1848,368 @@ public partial class MainWindow : Window
|
|||||||
UpdateDiscordPresence();
|
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()
|
private void UpdateRunButtons()
|
||||||
{
|
{
|
||||||
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
|
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
|
||||||
StopButton.IsEnabled = _isRunning;
|
StopButton.IsEnabled = _isRunning && !_isStopping;
|
||||||
|
SessionStopButton.IsEnabled = _isRunning && !_isStopping;
|
||||||
OpenFileButton.IsEnabled = !_isRunning;
|
OpenFileButton.IsEnabled = !_isRunning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateSessionBarVisibility()
|
||||||
|
{
|
||||||
|
SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
|
||||||
|
!_gameFullscreen && WindowState != WindowState.FullScreen;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Console ----
|
// ---- Console ----
|
||||||
|
|
||||||
private void FlushPendingConsoleLines()
|
private void FlushPendingConsoleLines()
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<!-- Dependency-free; provides the BuildInfo provenance shown in the
|
<!-- Dependency-free; provides the BuildInfo provenance shown in the
|
||||||
title bar. -->
|
title bar. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<!-- The GUI owns the native presentation control while each game runs in
|
||||||
|
an isolated emulator process. -->
|
||||||
|
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -41,18 +45,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
|
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- The controller readers (DualSense raw HID + Xbox XInput) are shared
|
|
||||||
with the emulator's host input backend. They are dependency-free, so
|
|
||||||
they are compiled in directly rather than pulling a reference to all
|
|
||||||
of SharpEmu.HLE into the launcher. -->
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="..\SharpEmu.HLE\Host\HostGamepadState.cs" Link="Input/HostGamepadState.cs" />
|
|
||||||
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsHidNative.cs" Link="Input/WindowsHidNative.cs" />
|
|
||||||
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsDualSenseReader.cs" Link="Input/WindowsDualSenseReader.cs" />
|
|
||||||
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsXInputReader.cs" Link="Input/WindowsXInputReader.cs" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -69,11 +69,118 @@ public sealed class PosixHostInput : IHostInput
|
|||||||
{
|
{
|
||||||
// GLFW only delivers key events to the focused window, so a
|
// GLFW only delivers key events to the focused window, so a
|
||||||
// delivering keyboard implies focus.
|
// delivering keyboard implies focus.
|
||||||
return _source?.HasKeyboardFocus ?? false;
|
return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused();
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsKeyDown(int virtualKey)
|
public bool IsKeyDown(int virtualKey)
|
||||||
{
|
{
|
||||||
return _source?.IsKeyDown(virtualKey) ?? false;
|
var source = _source;
|
||||||
|
if (source is not null)
|
||||||
|
{
|
||||||
|
return source.IsKeyDown(virtualKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsEmbeddedX11WindowFocused() && IsEmbeddedX11KeyDown(virtualKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsEmbeddedX11WindowFocused()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var display = HostSessionControl.EmbeddedHostDisplay;
|
||||||
|
var window = HostSessionControl.EmbeddedHostWindow;
|
||||||
|
if (display == 0 || window == 0 || XGetInputFocus(display, out var focusedWindow, out _) == 0 || focusedWindow == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetTopLevelWindow(display, focusedWindow) == GetTopLevelWindow(display, window);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEmbeddedX11KeyDown(int virtualKey)
|
||||||
|
{
|
||||||
|
var display = HostSessionControl.EmbeddedHostDisplay;
|
||||||
|
var keysym = ToX11Keysym(virtualKey);
|
||||||
|
if (display == 0 || keysym == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keycode = XKeysymToKeycode(display, keysym);
|
||||||
|
if (keycode == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keymap = new byte[32];
|
||||||
|
XQueryKeymap(display, keymap);
|
||||||
|
return (keymap[keycode >> 3] & (1 << (keycode & 7))) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static nint GetTopLevelWindow(nint display, nint window)
|
||||||
|
{
|
||||||
|
var current = window;
|
||||||
|
for (var depth = 0; depth < 16; depth++)
|
||||||
|
{
|
||||||
|
if (XQueryTree(display, current, out var root, out var parent, out var children, out _) == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children != 0)
|
||||||
|
{
|
||||||
|
XFree(children);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent == 0 || parent == root)
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static nuint ToX11Keysym(int virtualKey)
|
||||||
|
{
|
||||||
|
return virtualKey switch
|
||||||
|
{
|
||||||
|
0x08 => 0xFF08, // Backspace
|
||||||
|
0x09 => 0xFF09, // Tab
|
||||||
|
0x0D => 0xFF0D, // Return
|
||||||
|
0x1B => 0xFF1B, // Escape
|
||||||
|
0x25 => 0xFF51, // Left
|
||||||
|
0x26 => 0xFF52, // Up
|
||||||
|
0x27 => 0xFF53, // Right
|
||||||
|
0x28 => 0xFF54, // Down
|
||||||
|
>= 0x41 and <= 0x5A => (nuint)virtualKey,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||||
|
private static extern int XGetInputFocus(nint display, out nint focus, out int revertTo);
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||||
|
private static extern int XQueryKeymap(nint display, [System.Runtime.InteropServices.Out] byte[] keysReturn);
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||||
|
private static extern byte XKeysymToKeycode(nint display, nuint keysym);
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||||
|
private static extern int XQueryTree(
|
||||||
|
nint display,
|
||||||
|
nint window,
|
||||||
|
out nint root,
|
||||||
|
out nint parent,
|
||||||
|
out nint children,
|
||||||
|
out uint childCount);
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||||
|
private static extern int XFree(nint data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace SharpEmu.HLE.Host.Windows;
|
|||||||
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
|
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
|
||||||
/// activated by requesting feature report 0x05), with hot-plug retry.
|
/// activated by requesting feature report 0x05), with hot-plug retry.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class WindowsDualSenseReader
|
public static class WindowsDualSenseReader
|
||||||
{
|
{
|
||||||
private const ushort SonyVendorId = 0x054C;
|
private const ushort SonyVendorId = 0x054C;
|
||||||
private const ushort DualSenseProductId = 0x0CE6;
|
private const ushort DualSenseProductId = 0x0CE6;
|
||||||
@@ -35,7 +35,7 @@ internal static class WindowsDualSenseReader
|
|||||||
private static byte _playerLeds = 0x04; // center LED = player 1
|
private static byte _playerLeds = 0x04; // center LED = player 1
|
||||||
|
|
||||||
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
||||||
internal static void EnsureStarted()
|
public static void EnsureStarted()
|
||||||
{
|
{
|
||||||
// The GUI source-links this reader and calls it directly, without the
|
// The GUI source-links this reader and calls it directly, without the
|
||||||
// host-platform resolution that otherwise guarantees Windows.
|
// host-platform resolution that otherwise guarantees Windows.
|
||||||
@@ -61,7 +61,7 @@ internal static class WindowsDualSenseReader
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static bool TryGetState(out HostGamepadState state)
|
public static bool TryGetState(out HostGamepadState state)
|
||||||
{
|
{
|
||||||
lock (Gate)
|
lock (Gate)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -67,7 +67,19 @@ internal sealed partial class WindowsHostInput : IHostInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
GetWindowThreadProcessId(foregroundWindow, out var processId);
|
GetWindowThreadProcessId(foregroundWindow, out var processId);
|
||||||
return processId == (uint)Environment.ProcessId;
|
if (processId == (uint)Environment.ProcessId)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The GUI runs the emulator in an isolated child process. Its native
|
||||||
|
// Vulkan surface is a child of the GUI window, so the foreground
|
||||||
|
// window belongs to the launcher process rather than this one.
|
||||||
|
var embeddedHostWindow = HostSessionControl.EmbeddedHostWindow;
|
||||||
|
var hostTopLevelWindow = embeddedHostWindow == 0
|
||||||
|
? 0
|
||||||
|
: GetAncestor(embeddedHostWindow, GetAncestorRoot);
|
||||||
|
return hostTopLevelWindow != 0 && foregroundWindow == hostTopLevelWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsKeyDown(int virtualKey) =>
|
public bool IsKeyDown(int virtualKey) =>
|
||||||
@@ -81,4 +93,9 @@ internal sealed partial class WindowsHostInput : IHostInput
|
|||||||
|
|
||||||
[LibraryImport("user32.dll")]
|
[LibraryImport("user32.dll")]
|
||||||
private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId);
|
private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll")]
|
||||||
|
private static partial nint GetAncestor(nint hWnd, uint gaFlags);
|
||||||
|
|
||||||
|
private const uint GetAncestorRoot = 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace SharpEmu.HLE.Host.Windows;
|
|||||||
/// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug
|
/// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug
|
||||||
/// retry; the first connected slot (of four) is used.
|
/// retry; the first connected slot (of four) is used.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static partial class WindowsXInputReader
|
public static partial class WindowsXInputReader
|
||||||
{
|
{
|
||||||
private const uint ErrorSuccess = 0;
|
private const uint ErrorSuccess = 0;
|
||||||
private const int SlotCount = 4;
|
private const int SlotCount = 4;
|
||||||
@@ -43,7 +43,7 @@ internal static partial class WindowsXInputReader
|
|||||||
private static byte _triggerRight;
|
private static byte _triggerRight;
|
||||||
|
|
||||||
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
||||||
internal static void EnsureStarted()
|
public static void EnsureStarted()
|
||||||
{
|
{
|
||||||
// The GUI source-links this reader and calls it directly, without the
|
// The GUI source-links this reader and calls it directly, without the
|
||||||
// host-platform resolution that otherwise guarantees Windows.
|
// host-platform resolution that otherwise guarantees Windows.
|
||||||
@@ -69,7 +69,7 @@ internal static partial class WindowsXInputReader
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static bool TryGetState(out HostGamepadState state)
|
public static bool TryGetState(out HostGamepadState state)
|
||||||
{
|
{
|
||||||
lock (Gate)
|
lock (Gate)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,17 +10,88 @@ namespace SharpEmu.HLE;
|
|||||||
public static class HostSessionControl
|
public static class HostSessionControl
|
||||||
{
|
{
|
||||||
private static Action<string>? _shutdownHandler;
|
private static Action<string>? _shutdownHandler;
|
||||||
|
private static string? _pendingShutdownReason;
|
||||||
|
private static int _shutdownRequested;
|
||||||
|
private static long _embeddedHostWindow;
|
||||||
|
private static long _embeddedHostDisplay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates that the active host session is being stopped. Runtime code
|
||||||
|
/// uses this to skip expensive post-exit diagnostics before returning the
|
||||||
|
/// GUI to its library.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Native GUI surface used by an isolated emulator child. Input backends
|
||||||
|
/// use it to treat the launcher window as the active game window.
|
||||||
|
/// </summary>
|
||||||
|
public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
|
||||||
|
|
||||||
|
/// <summary>X11 Display* paired with <see cref="EmbeddedHostWindow"/> when available.</summary>
|
||||||
|
public static nint EmbeddedHostDisplay => unchecked((nint)Interlocked.Read(ref _embeddedHostDisplay));
|
||||||
|
|
||||||
|
public static void SetEmbeddedHostSurface(nint window, nint display = 0)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _embeddedHostDisplay, unchecked((long)display));
|
||||||
|
Interlocked.Exchange(ref _embeddedHostWindow, unchecked((long)window));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a fresh session after the previous guest has fully left its
|
||||||
|
/// execution backend.
|
||||||
|
/// </summary>
|
||||||
|
public static void ResetShutdownRequest()
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _pendingShutdownReason, null);
|
||||||
|
Volatile.Write(ref _shutdownRequested, 0);
|
||||||
|
}
|
||||||
|
|
||||||
public static void SetShutdownHandler(Action<string>? handler)
|
public static void SetShutdownHandler(Action<string>? handler)
|
||||||
{
|
{
|
||||||
Volatile.Write(ref _shutdownHandler, handler);
|
Volatile.Write(ref _shutdownHandler, handler);
|
||||||
|
if (handler is null)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _pendingShutdownReason, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingReason = Interlocked.Exchange(ref _pendingShutdownReason, null);
|
||||||
|
if (pendingReason is not null)
|
||||||
|
{
|
||||||
|
Invoke(handler, pendingReason);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RequestShutdown(string reason)
|
public static void RequestShutdown(string reason)
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _shutdownRequested, 1);
|
||||||
|
var handler = Volatile.Read(ref _shutdownHandler);
|
||||||
|
if (handler is not null)
|
||||||
|
{
|
||||||
|
Invoke(handler, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop can be pressed while the GUI session is starting. Retain the
|
||||||
|
// request until the native backend installs its cooperative handler.
|
||||||
|
Volatile.Write(ref _pendingShutdownReason, reason);
|
||||||
|
handler = Volatile.Read(ref _shutdownHandler);
|
||||||
|
if (handler is not null)
|
||||||
|
{
|
||||||
|
var pendingReason = Interlocked.Exchange(ref _pendingShutdownReason, null);
|
||||||
|
if (pendingReason is not null)
|
||||||
|
{
|
||||||
|
Invoke(handler, pendingReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Invoke(Action<string> handler, string reason)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Volatile.Read(ref _shutdownHandler)?.Invoke(reason);
|
handler(reason);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ public static class KernelSemaphoreCompatExports
|
|||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
TraceSemaphore($"create handle=0x{handle:X8} name='{name}' attr=0x{attr:X} init={initialCount} max={maxCount}");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"create handle=0x{handle:X8} name='{name}' attr=0x{attr:X} init={initialCount} max={maxCount}");
|
||||||
|
}
|
||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +121,10 @@ public static class KernelSemaphoreCompatExports
|
|||||||
_ = TryWriteUInt32(ctx, timeoutAddress, timeoutUsec);
|
_ = TryWriteUInt32(ctx, timeoutAddress, timeoutUsec);
|
||||||
}
|
}
|
||||||
|
|
||||||
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
|
||||||
|
}
|
||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +164,10 @@ public static class KernelSemaphoreCompatExports
|
|||||||
|
|
||||||
if (acquired)
|
if (acquired)
|
||||||
{
|
{
|
||||||
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||||
|
}
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +176,10 @@ public static class KernelSemaphoreCompatExports
|
|||||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||||
|
}
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +191,10 @@ public static class KernelSemaphoreCompatExports
|
|||||||
WakePredicate,
|
WakePredicate,
|
||||||
deadline))
|
deadline))
|
||||||
{
|
{
|
||||||
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
||||||
|
}
|
||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,9 +216,12 @@ public static class KernelSemaphoreCompatExports
|
|||||||
: long.MaxValue;
|
: long.MaxValue;
|
||||||
lock (semaphore.Gate)
|
lock (semaphore.Gate)
|
||||||
{
|
{
|
||||||
TraceSemaphore(
|
if (_traceSema)
|
||||||
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
|
{
|
||||||
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
|
TraceSemaphore(
|
||||||
|
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
|
||||||
|
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
|
||||||
|
}
|
||||||
while (semaphore.Count < needCount)
|
while (semaphore.Count < needCount)
|
||||||
{
|
{
|
||||||
var remaining = deadlineMs - Environment.TickCount64;
|
var remaining = deadlineMs - Environment.TickCount64;
|
||||||
@@ -219,8 +237,11 @@ public static class KernelSemaphoreCompatExports
|
|||||||
|
|
||||||
semaphore.Count -= needCount;
|
semaphore.Count -= needCount;
|
||||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||||
TraceSemaphore(
|
if (_traceSema)
|
||||||
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
|
{
|
||||||
|
TraceSemaphore(
|
||||||
|
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
|
||||||
|
}
|
||||||
if (timeoutAddress != 0)
|
if (timeoutAddress != 0)
|
||||||
{
|
{
|
||||||
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||||
@@ -253,12 +274,18 @@ public static class KernelSemaphoreCompatExports
|
|||||||
{
|
{
|
||||||
if (semaphore.Count < needCount)
|
if (semaphore.Count < needCount)
|
||||||
{
|
{
|
||||||
TraceSemaphore($"poll-busy handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"poll-busy handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||||
|
}
|
||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
|
||||||
}
|
}
|
||||||
|
|
||||||
semaphore.Count -= needCount;
|
semaphore.Count -= needCount;
|
||||||
TraceSemaphore($"poll handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"poll handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||||
|
}
|
||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,7 +317,10 @@ public static class KernelSemaphoreCompatExports
|
|||||||
semaphore.Count += signalCount;
|
semaphore.Count += signalCount;
|
||||||
// Wake host-thread waiters parked in the fallback path.
|
// Wake host-thread waiters parked in the fallback path.
|
||||||
Monitor.PulseAll(semaphore.Gate);
|
Monitor.PulseAll(semaphore.Gate);
|
||||||
TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wake cooperatively-blocked guest threads; their wake predicate
|
// Wake cooperatively-blocked guest threads; their wake predicate
|
||||||
@@ -326,7 +356,10 @@ public static class KernelSemaphoreCompatExports
|
|||||||
semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount;
|
semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount;
|
||||||
semaphore.WaitingThreads = 0;
|
semaphore.WaitingThreads = 0;
|
||||||
Monitor.PulseAll(semaphore.Gate);
|
Monitor.PulseAll(semaphore.Gate);
|
||||||
TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
|
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
|
||||||
@@ -346,7 +379,10 @@ public static class KernelSemaphoreCompatExports
|
|||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'");
|
||||||
|
}
|
||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +421,10 @@ public static class KernelSemaphoreCompatExports
|
|||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
TraceSemaphore($"posix-init address=0x{semaphoreAddress:X16} handle=0x{handle:X8} count={initialCount}");
|
if (_traceSema)
|
||||||
|
{
|
||||||
|
TraceSemaphore($"posix-init address=0x{semaphoreAddress:X16} handle=0x{handle:X8} count={initialCount}");
|
||||||
|
}
|
||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,6 +621,11 @@ public static class KernelSemaphoreCompatExports
|
|||||||
|
|
||||||
private static void TraceSemaphore(string message)
|
private static void TraceSemaphore(string message)
|
||||||
{
|
{
|
||||||
|
if (!_traceSema)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Console.Error.WriteLine($"[LOADER][TRACE] sema.{message}");
|
Console.Error.WriteLine($"[LOADER][TRACE] sema.{message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,10 @@ public static class PerfOverlay
|
|||||||
_lastGen2 = gen2;
|
_lastGen2 = gen2;
|
||||||
|
|
||||||
var cpuTime = GetProcessCpuTime();
|
var cpuTime = GetProcessCpuTime();
|
||||||
_cpuPercent = (cpuTime - _lastCpuTime).TotalSeconds / seconds * 100.0;
|
// Normalize across logical processors (Task Manager convention):
|
||||||
|
// raw process time / wall time reads 100% per fully busy core.
|
||||||
|
_cpuPercent = (cpuTime - _lastCpuTime).TotalSeconds / seconds * 100.0 /
|
||||||
|
Environment.ProcessorCount;
|
||||||
_lastCpuTime = cpuTime;
|
_lastCpuTime = cpuTime;
|
||||||
|
|
||||||
var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
|
var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
|
||||||
|
|||||||
@@ -156,15 +156,29 @@ public static class VideoOutExports
|
|||||||
private static void RequestHostShutdown(string reason)
|
private static void RequestHostShutdown(string reason)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
|
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
|
||||||
VulkanVideoPresenter.RequestClose();
|
var embedded = VulkanVideoHost.IsEmbedded;
|
||||||
AudioOutExports.ShutdownAllPorts();
|
AudioOutExports.ShutdownAllPorts();
|
||||||
Interlocked.Exchange(ref _vblankStopRequested, 1);
|
Interlocked.Exchange(ref _vblankStopRequested, 1);
|
||||||
HostSessionControl.RequestShutdown(reason);
|
HostSessionControl.RequestShutdown(reason);
|
||||||
ThreadPool.QueueUserWorkItem(static _ =>
|
|
||||||
|
// A hosted game can still be issuing AGC work after it requests its
|
||||||
|
// own shutdown. Keep the Vulkan resources alive until the GUI session
|
||||||
|
// reaches its guest-safe exit path and disposes the host surface.
|
||||||
|
if (!embedded)
|
||||||
{
|
{
|
||||||
Thread.Sleep(2000);
|
VulkanVideoPresenter.RequestClose();
|
||||||
Environment.Exit(0);
|
}
|
||||||
});
|
|
||||||
|
// The embedded GUI owns the process lifetime. A guest shutdown should
|
||||||
|
// end only that session rather than terminating the launcher itself.
|
||||||
|
if (!embedded)
|
||||||
|
{
|
||||||
|
ThreadPool.QueueUserWorkItem(static _ =>
|
||||||
|
{
|
||||||
|
Thread.Sleep(2000);
|
||||||
|
Environment.Exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class VideoOutPortState
|
private sealed class VideoOutPortState
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.VideoOut;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A native child surface owned by a host UI. The presenter consumes this
|
||||||
|
/// directly, avoiding a second top-level GLFW window when the GUI is active.
|
||||||
|
/// </summary>
|
||||||
|
public enum VulkanHostSurfaceKind
|
||||||
|
{
|
||||||
|
Win32,
|
||||||
|
Xlib,
|
||||||
|
Metal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-native handles required to create a Vulkan presentation surface.
|
||||||
|
/// The GUI owns their lifetime and updates the physical pixel size on resize.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VulkanHostSurface : IDisposable
|
||||||
|
{
|
||||||
|
private int _pixelWidth;
|
||||||
|
private int _pixelHeight;
|
||||||
|
private int _resizeGeneration;
|
||||||
|
private readonly bool _ownsDisplay;
|
||||||
|
private readonly bool _pollNativeSize;
|
||||||
|
private long _nextNativeSizePoll;
|
||||||
|
|
||||||
|
public VulkanHostSurface(
|
||||||
|
VulkanHostSurfaceKind kind,
|
||||||
|
nint windowHandle,
|
||||||
|
nint displayHandle = 0,
|
||||||
|
nint metalLayerHandle = 0,
|
||||||
|
bool ownsDisplay = false,
|
||||||
|
bool pollNativeSize = false)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
WindowHandle = windowHandle;
|
||||||
|
DisplayHandle = displayHandle;
|
||||||
|
MetalLayerHandle = metalLayerHandle;
|
||||||
|
_ownsDisplay = ownsDisplay;
|
||||||
|
_pollNativeSize = pollNativeSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VulkanHostSurfaceKind Kind { get; }
|
||||||
|
|
||||||
|
public nint WindowHandle { get; }
|
||||||
|
|
||||||
|
/// <summary>X11 Display* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Xlib"/>.</summary>
|
||||||
|
public nint DisplayHandle { get; }
|
||||||
|
|
||||||
|
/// <summary>CAMetalLayer* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Metal"/>.</summary>
|
||||||
|
public nint MetalLayerHandle { get; }
|
||||||
|
|
||||||
|
public int PixelWidth => Volatile.Read(ref _pixelWidth);
|
||||||
|
|
||||||
|
public int PixelHeight => Volatile.Read(ref _pixelHeight);
|
||||||
|
|
||||||
|
internal int ResizeGeneration => Volatile.Read(ref _resizeGeneration);
|
||||||
|
|
||||||
|
public void UpdatePixelSize(int width, int height)
|
||||||
|
{
|
||||||
|
width = Math.Max(width, 1);
|
||||||
|
height = Math.Max(height, 1);
|
||||||
|
if (Volatile.Read(ref _pixelWidth) == width && Volatile.Read(ref _pixelHeight) == height)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Volatile.Write(ref _pixelWidth, width);
|
||||||
|
Volatile.Write(ref _pixelHeight, height);
|
||||||
|
Interlocked.Increment(ref _resizeGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The child emulator cannot receive Avalonia resize notifications. Poll
|
||||||
|
/// the native host at a bounded rate so embedded child swapchains still
|
||||||
|
/// follow normal resize and F11 transitions.
|
||||||
|
/// </summary>
|
||||||
|
internal void RefreshChildProcessPixelSize()
|
||||||
|
{
|
||||||
|
if (!_pollNativeSize)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||||
|
var due = Volatile.Read(ref _nextNativeSizePoll);
|
||||||
|
if (now < due || Interlocked.CompareExchange(
|
||||||
|
ref _nextNativeSizePoll,
|
||||||
|
now + (System.Diagnostics.Stopwatch.Frequency / 8),
|
||||||
|
due) != due)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Kind == VulkanHostSurfaceKind.Win32 && GetClientRect(WindowHandle, out var rect))
|
||||||
|
{
|
||||||
|
UpdatePixelSize(rect.Right - rect.Left, rect.Bottom - rect.Top);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Kind == VulkanHostSurfaceKind.Xlib && DisplayHandle != 0 &&
|
||||||
|
XGetGeometry(
|
||||||
|
DisplayHandle,
|
||||||
|
WindowHandle,
|
||||||
|
out _,
|
||||||
|
out _,
|
||||||
|
out _,
|
||||||
|
out var width,
|
||||||
|
out var height,
|
||||||
|
out _,
|
||||||
|
out _) != 0)
|
||||||
|
{
|
||||||
|
UpdatePixelSize(unchecked((int)width), unchecked((int)height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes a native child handle for a separately hosted emulator
|
||||||
|
/// process. Metal object pointers are process-local, so macOS falls back
|
||||||
|
/// to a standalone child window until an IPC Metal host is implemented.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetChildProcessDescriptor(out string descriptor)
|
||||||
|
{
|
||||||
|
descriptor = string.Empty;
|
||||||
|
if (Kind == VulkanHostSurfaceKind.Metal || WindowHandle == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var kind = Kind == VulkanHostSurfaceKind.Win32 ? "win32" : "xlib";
|
||||||
|
descriptor = string.Create(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
$"{kind}:{unchecked((ulong)WindowHandle):X}:{Math.Max(PixelWidth, 1)}:{Math.Max(PixelHeight, 1)}:{unchecked((ulong)DisplayHandle):X}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconstructs a surface in the isolated emulator process. X11 clients
|
||||||
|
/// must open their own Display connection; Display* values cannot cross a
|
||||||
|
/// process boundary.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryCreateChildProcessSurface(
|
||||||
|
string descriptor,
|
||||||
|
out VulkanHostSurface? surface,
|
||||||
|
out string? error)
|
||||||
|
{
|
||||||
|
surface = null;
|
||||||
|
error = null;
|
||||||
|
var parts = descriptor.Split(':');
|
||||||
|
if (parts.Length != 5 ||
|
||||||
|
!ulong.TryParse(parts[1], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var window) ||
|
||||||
|
!int.TryParse(parts[2], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var width) ||
|
||||||
|
!int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var height) ||
|
||||||
|
!ulong.TryParse(parts[4], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var nativeDisplay))
|
||||||
|
{
|
||||||
|
error = "invalid host-surface descriptor";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window == 0 || width <= 0 || height <= 0)
|
||||||
|
{
|
||||||
|
error = "host-surface descriptor has an invalid size or handle";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(parts[0], "win32", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
surface = new VulkanHostSurface(
|
||||||
|
VulkanHostSurfaceKind.Win32,
|
||||||
|
unchecked((nint)window),
|
||||||
|
unchecked((nint)nativeDisplay),
|
||||||
|
pollNativeSize: true);
|
||||||
|
}
|
||||||
|
else if (string.Equals(parts[0], "xlib", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var display = XOpenDisplay(0);
|
||||||
|
if (display == 0)
|
||||||
|
{
|
||||||
|
error = "could not open an X11 display for the host surface";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
surface = new VulkanHostSurface(
|
||||||
|
VulkanHostSurfaceKind.Xlib,
|
||||||
|
unchecked((nint)window),
|
||||||
|
display,
|
||||||
|
ownsDisplay: true,
|
||||||
|
pollNativeSize: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
error = $"unsupported host-surface kind '{parts[0]}'";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
surface.UpdatePixelSize(width, height);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_ownsDisplay && DisplayHandle != 0 && OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
_ = XCloseDisplay(DisplayHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
|
||||||
|
private static extern nint XOpenDisplay(nint displayName);
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
|
||||||
|
private static extern int XCloseDisplay(nint display);
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetClientRect", SetLastError = true)]
|
||||||
|
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
|
||||||
|
private static extern bool GetClientRect(nint window, out Rect rect);
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XGetGeometry")]
|
||||||
|
private static extern int XGetGeometry(
|
||||||
|
nint display,
|
||||||
|
nint drawable,
|
||||||
|
out nint root,
|
||||||
|
out int x,
|
||||||
|
out int y,
|
||||||
|
out uint width,
|
||||||
|
out uint height,
|
||||||
|
out uint borderWidth,
|
||||||
|
out uint depth);
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
|
||||||
|
private struct Rect
|
||||||
|
{
|
||||||
|
public int Left;
|
||||||
|
public int Top;
|
||||||
|
public int Right;
|
||||||
|
public int Bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Small public bridge between a desktop UI and the internal Vulkan
|
||||||
|
/// presenter. Launchers can host a surface without depending on renderer
|
||||||
|
/// submission internals.
|
||||||
|
/// </summary>
|
||||||
|
public static class VulkanVideoHost
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raised after the first successful Vulkan present to an embedded host
|
||||||
|
/// surface. UI hosts use this to retire their launch affordance only once
|
||||||
|
/// a real frame can be seen.
|
||||||
|
/// </summary>
|
||||||
|
public static event Action<VulkanHostSurface>? FirstFramePresented
|
||||||
|
{
|
||||||
|
add => VulkanVideoPresenter.FirstHostFramePresented += value;
|
||||||
|
remove => VulkanVideoPresenter.FirstHostFramePresented -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryAttachSurface(VulkanHostSurface surface) =>
|
||||||
|
VulkanVideoPresenter.TryAttachHostSurface(surface);
|
||||||
|
|
||||||
|
public static void DetachSurface(VulkanHostSurface surface) =>
|
||||||
|
VulkanVideoPresenter.DetachHostSurface(surface);
|
||||||
|
|
||||||
|
public static void RequestClose() => VulkanVideoPresenter.RequestClose();
|
||||||
|
|
||||||
|
public static bool IsEmbedded => VulkanVideoPresenter.UsesHostSurface;
|
||||||
|
}
|
||||||
@@ -100,8 +100,10 @@ internal readonly record struct VulkanGuestQueueIdentity(
|
|||||||
|
|
||||||
internal static unsafe class VulkanVideoPresenter
|
internal static unsafe class VulkanVideoPresenter
|
||||||
{
|
{
|
||||||
private const uint DefaultWindowWidth = 1280;
|
// Standalone CLI launches use a desktop-sized surface. The embedded GUI
|
||||||
private const uint DefaultWindowHeight = 720;
|
// always takes its dimensions from the native child control instead.
|
||||||
|
private const uint DefaultWindowWidth = 1920;
|
||||||
|
private const uint DefaultWindowHeight = 1080;
|
||||||
// Vulkan's portable upper bound for minStorageBufferOffsetAlignment is
|
// Vulkan's portable upper bound for minStorageBufferOffsetAlignment is
|
||||||
// 256 bytes. Using that fixed power of two (instead of racing the render
|
// 256 bytes. Using that fixed power of two (instead of racing the render
|
||||||
// thread's physical-device query) gives shader translation and descriptor
|
// thread's physical-device query) gives shader translation and descriptor
|
||||||
@@ -123,7 +125,16 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
// of draws per frame to a fraction of the display rate. The pending cap
|
// of draws per frame to a fraction of the display rate. The pending cap
|
||||||
// stays tighter than the drain budget because queued draws pin their
|
// stays tighter than the drain budget because queued draws pin their
|
||||||
// pooled guest-data arrays until the render thread uploads them.
|
// pooled guest-data arrays until the render thread uploads them.
|
||||||
private const int MaxPendingGuestWork = 64;
|
// The Cocoa event loop must stay responsive while guest work is pending,
|
||||||
|
// but Windows and Linux render on a dedicated host thread. Keeping the
|
||||||
|
// macOS item limit everywhere throttles draw-heavy games well below their
|
||||||
|
// display rate before the byte budget is remotely close to full.
|
||||||
|
private static readonly int _maxPendingGuestWorkItems =
|
||||||
|
int.TryParse(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_PENDING_GUEST_WORK_ITEMS"),
|
||||||
|
out var pendingGuestWorkItems) && pendingGuestWorkItems > 0
|
||||||
|
? pendingGuestWorkItems
|
||||||
|
: OperatingSystem.IsMacOS() ? 64 : 512;
|
||||||
private const ulong MaximumCachedHostBufferBytes = 128UL * 1024 * 1024;
|
private const ulong MaximumCachedHostBufferBytes = 128UL * 1024 * 1024;
|
||||||
// A captured 4K flip can consume tens of MiB of device-local memory.
|
// A captured 4K flip can consume tens of MiB of device-local memory.
|
||||||
// Retain only a short presentation queue while always preserving the
|
// Retain only a short presentation queue while always preserving the
|
||||||
@@ -140,30 +151,40 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
out var pendingGuestWorkMb) && pendingGuestWorkMb > 0
|
out var pendingGuestWorkMb) && pendingGuestWorkMb > 0
|
||||||
? pendingGuestWorkMb
|
? pendingGuestWorkMb
|
||||||
: 256UL) * 1024UL * 1024UL;
|
: 256UL) * 1024UL * 1024UL;
|
||||||
private const int MaxGuestWorkPerRender = 256;
|
private static readonly int _maxGuestWorkPerRender =
|
||||||
|
int.TryParse(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_MAX_GUEST_WORK_PER_RENDER"),
|
||||||
|
out var guestWorkPerRender) && guestWorkPerRender > 0
|
||||||
|
? guestWorkPerRender
|
||||||
|
: OperatingSystem.IsMacOS() ? 256 : 1024;
|
||||||
// On macOS the whole window loop — including Render() and its guest-work
|
// On macOS the whole window loop — including Render() and its guest-work
|
||||||
// drain — runs on the process main thread, so draining a large backlog of
|
// drain — runs on the process main thread, so draining a large backlog of
|
||||||
// slow guest work (heavy compute) blocks the Cocoa event pump and marks the
|
// slow guest work (heavy compute) blocks the Cocoa event pump and marks the
|
||||||
// window "Not Responding" while starving the swapchain present. Cap the
|
// window "Not Responding" while starving the swapchain present. Cap the
|
||||||
// wall-clock time spent draining per Render() call; leftover work stays
|
// wall-clock time spent draining per Render() call; leftover work stays
|
||||||
// queued for the next frame. SHARPEMU_RENDER_WORK_BUDGET_MS overrides
|
// queued for the next frame. SHARPEMU_RENDER_WORK_BUDGET_MS overrides
|
||||||
// (0 disables the cap); default 12ms keeps the window interactive at ~60Hz.
|
// (0 disables the cap); default 12ms keeps the macOS window interactive at
|
||||||
|
// ~60Hz. Windows and Linux use a dedicated render thread, so they drain
|
||||||
|
// without a time budget by default.
|
||||||
private static readonly long _renderWorkBudgetTicks =
|
private static readonly long _renderWorkBudgetTicks =
|
||||||
(long.TryParse(
|
(long.TryParse(
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_RENDER_WORK_BUDGET_MS"),
|
Environment.GetEnvironmentVariable("SHARPEMU_RENDER_WORK_BUDGET_MS"),
|
||||||
out var renderBudgetMs) && renderBudgetMs >= 0
|
out var renderBudgetMs) && renderBudgetMs >= 0
|
||||||
? renderBudgetMs
|
? renderBudgetMs
|
||||||
: 12L) * System.Diagnostics.Stopwatch.Frequency / 1000L;
|
: OperatingSystem.IsMacOS() ? 12L : 0L) *
|
||||||
|
System.Diagnostics.Stopwatch.Frequency / 1000L;
|
||||||
// Max time the main-thread Render() will block waiting for a frame slot's
|
// Max time the main-thread Render() will block waiting for a frame slot's
|
||||||
// GPU fence before skipping the frame and returning to the event pump.
|
// GPU fence before skipping the frame and returning to the event pump.
|
||||||
// Prevents the window freezing behind a slow-compute GPU backlog.
|
// Prevents the window freezing behind a slow-compute GPU backlog.
|
||||||
// SHARPEMU_FRAME_WAIT_BUDGET_MS overrides; default 8ms.
|
// SHARPEMU_FRAME_WAIT_BUDGET_MS overrides; default 8ms on macOS. The
|
||||||
|
// dedicated Windows/Linux render thread may wait for its frame slot so it
|
||||||
|
// does not drop guest-work drain opportunities under normal GPU load.
|
||||||
private static readonly ulong _frameSlotWaitBudgetNs =
|
private static readonly ulong _frameSlotWaitBudgetNs =
|
||||||
(ulong.TryParse(
|
ulong.TryParse(
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_FRAME_WAIT_BUDGET_MS"),
|
Environment.GetEnvironmentVariable("SHARPEMU_FRAME_WAIT_BUDGET_MS"),
|
||||||
out var frameWaitMs) && frameWaitMs > 0
|
out var frameWaitMs) && frameWaitMs > 0
|
||||||
? frameWaitMs
|
? frameWaitMs * 1_000_000UL
|
||||||
: 8UL) * 1_000_000UL;
|
: OperatingSystem.IsMacOS() ? 8_000_000UL : ulong.MaxValue;
|
||||||
// Cap the guest-submission fence wait so a GPU submission whose fence never
|
// Cap the guest-submission fence wait so a GPU submission whose fence never
|
||||||
// signals (a mistranslated compute shader that hangs the Metal queue) cannot
|
// signals (a mistranslated compute shader that hangs the Metal queue) cannot
|
||||||
// freeze the render thread forever and starve the swapchain present.
|
// freeze the render thread forever and starve the swapchain present.
|
||||||
@@ -263,6 +284,9 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
private static readonly HashSet<(ulong Address, uint Width, uint Height)>
|
private static readonly HashSet<(ulong Address, uint Width, uint Height)>
|
||||||
_tracedGuestImageSubmissions = [];
|
_tracedGuestImageSubmissions = [];
|
||||||
private static Thread? _thread;
|
private static Thread? _thread;
|
||||||
|
private static VulkanHostSurface? _hostSurface;
|
||||||
|
private static VulkanHostSurface? _hostSurfacePendingDetach;
|
||||||
|
internal static event Action<VulkanHostSurface>? FirstHostFramePresented;
|
||||||
private static Presentation? _latestPresentation;
|
private static Presentation? _latestPresentation;
|
||||||
private static byte[]? _copyFragmentSpirv;
|
private static byte[]? _copyFragmentSpirv;
|
||||||
private static uint _windowWidth;
|
private static uint _windowWidth;
|
||||||
@@ -463,10 +487,124 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
TranslatedDraw: null,
|
TranslatedDraw: null,
|
||||||
RequiredGuestWorkSequence: 0,
|
RequiredGuestWorkSequence: 0,
|
||||||
IsSplash: false);
|
IsSplash: false);
|
||||||
|
if (_hostSurface is not null && _latestPresentation is { IsSplash: true })
|
||||||
|
{
|
||||||
|
// The GUI keeps its native child hidden while the launcher is
|
||||||
|
// loading. Reveal it for the real VideoOut splash rather than
|
||||||
|
// substituting a launcher-side image.
|
||||||
|
Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted splash ready.");
|
||||||
|
}
|
||||||
StartPresenterLocked();
|
StartPresenterLocked();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selects a same-process native surface for the next VideoOut session.
|
||||||
|
/// This is intentionally rejected once the presenter has started: Vulkan
|
||||||
|
/// surface ownership cannot change under an active swapchain.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryAttachHostSurface(VulkanHostSurface surface)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(surface);
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_thread is not null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetHostSessionStateLocked();
|
||||||
|
_hostSurface = surface;
|
||||||
|
_closed = false;
|
||||||
|
_presenterCloseRequested = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases the host surface after the guest session has stopped.
|
||||||
|
/// </summary>
|
||||||
|
public static void DetachHostSurface(VulkanHostSurface surface)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(surface);
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(_hostSurface, surface))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_thread is null)
|
||||||
|
{
|
||||||
|
_hostSurface = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_hostSurfacePendingDetach = surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool UsesHostSurface
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _hostSurface is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void NotifyFirstHostFramePresented(VulkanHostSurface surface)
|
||||||
|
{
|
||||||
|
// This marker crosses the GUI child-process pipe. The in-process
|
||||||
|
// event remains for hosts that embed the renderer directly.
|
||||||
|
Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted first frame presented.");
|
||||||
|
var callback = FirstHostFramePresented;
|
||||||
|
if (callback is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
callback(surface);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] Embedded first-frame notification failed: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ResetHostSessionStateLocked()
|
||||||
|
{
|
||||||
|
_latestPresentation = null;
|
||||||
|
_splashHidden = false;
|
||||||
|
_pendingGuestWorkByQueue.Clear();
|
||||||
|
_pendingGuestQueueSchedule.Clear();
|
||||||
|
_pendingGuestQueueCursor = 0;
|
||||||
|
_pendingGuestWorkCount = 0;
|
||||||
|
_pendingGuestWorkBytes = 0;
|
||||||
|
_pendingGuestImagePresentations.Clear();
|
||||||
|
_guestImageWorkSequences.Clear();
|
||||||
|
_availableGuestImages.Clear();
|
||||||
|
_lastOrderedGuestFlipVersions.Clear();
|
||||||
|
_orderedGuestFlipVersionSequence = 0;
|
||||||
|
_pendingGuestImageUploads.Clear();
|
||||||
|
_pendingGuestImageInitialData.Clear();
|
||||||
|
_guestImageExtents.Clear();
|
||||||
|
_enqueuedGuestWorkSequence = 0;
|
||||||
|
_completedGuestWorkSequence = 0;
|
||||||
|
_completedGuestWorkOutOfOrder.Clear();
|
||||||
|
_lastEnqueuedGuestWorkByQueue.Clear();
|
||||||
|
_executingGuestWorkSequence = 0;
|
||||||
|
_hostSurfacePendingDetach = null;
|
||||||
|
}
|
||||||
|
|
||||||
public static void HideSplashScreen()
|
public static void HideSplashScreen()
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
@@ -1192,7 +1330,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
GuestImageAddress: address);
|
GuestImageAddress: address);
|
||||||
_latestPresentation = presentation;
|
_latestPresentation = presentation;
|
||||||
_pendingGuestImagePresentations.Enqueue(presentation);
|
_pendingGuestImagePresentations.Enqueue(presentation);
|
||||||
while (_pendingGuestImagePresentations.Count > MaxPendingGuestWork)
|
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
|
||||||
{
|
{
|
||||||
_pendingGuestImagePresentations.Dequeue();
|
_pendingGuestImagePresentations.Dequeue();
|
||||||
}
|
}
|
||||||
@@ -1588,7 +1726,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
|
|
||||||
private static void StartPresenterLocked()
|
private static void StartPresenterLocked()
|
||||||
{
|
{
|
||||||
if (HostMainThread.IsAvailable)
|
if (_hostSurface is null && HostMainThread.IsAvailable)
|
||||||
{
|
{
|
||||||
// AppKit (and therefore GLFW) traps when touched off the process
|
// AppKit (and therefore GLFW) traps when touched off the process
|
||||||
// main thread on macOS, so hand the whole window loop to the
|
// main thread on macOS, so hand the whole window loop to the
|
||||||
@@ -1756,18 +1894,23 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
{
|
{
|
||||||
uint width;
|
uint width;
|
||||||
uint height;
|
uint height;
|
||||||
|
VulkanHostSurface? hostSurface;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
width = _windowWidth == 0 ? _latestPresentation?.Width ?? 1280 : _windowWidth;
|
width = _windowWidth == 0 ? _latestPresentation?.Width ?? 1280 : _windowWidth;
|
||||||
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
|
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
|
||||||
|
hostSurface = _hostSurface;
|
||||||
}
|
}
|
||||||
|
|
||||||
PreferX11OnLinuxWayland();
|
if (hostSurface is null)
|
||||||
InitializeMacVulkanLoader();
|
{
|
||||||
|
PreferX11OnLinuxWayland();
|
||||||
|
InitializeMacVulkanLoader();
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var presenter = new Presenter(width, height);
|
using var presenter = new Presenter(width, height, hostSurface);
|
||||||
presenter.Run();
|
presenter.Run();
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -1780,6 +1923,12 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
{
|
{
|
||||||
_closed = true;
|
_closed = true;
|
||||||
_thread = null;
|
_thread = null;
|
||||||
|
if (_hostSurfacePendingDetach is not null &&
|
||||||
|
ReferenceEquals(_hostSurface, _hostSurfacePendingDetach))
|
||||||
|
{
|
||||||
|
_hostSurface = null;
|
||||||
|
_hostSurfacePendingDetach = null;
|
||||||
|
}
|
||||||
System.Threading.Monitor.PulseAll(_gate);
|
System.Threading.Monitor.PulseAll(_gate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1885,7 +2034,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
while (!_enqueueAsImmediateQueueFollowup &&
|
while (!_enqueueAsImmediateQueueFollowup &&
|
||||||
!_closed &&
|
!_closed &&
|
||||||
_thread is not null &&
|
_thread is not null &&
|
||||||
(_pendingGuestWorkCount >= MaxPendingGuestWork ||
|
(_pendingGuestWorkCount >= _maxPendingGuestWorkItems ||
|
||||||
// Always admit one item when no payload is outstanding, even
|
// Always admit one item when no payload is outstanding, even
|
||||||
// when that single item exceeds the configured budget. This
|
// when that single item exceeds the configured budget. This
|
||||||
// avoids an impossible wait while still bounding the normal
|
// avoids an impossible wait while still bounding the normal
|
||||||
@@ -1965,6 +2114,9 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
RecordGuestImageWritersLocked(work, sequence);
|
RecordGuestImageWritersLocked(work, sequence);
|
||||||
_pendingGuestWorkCount++;
|
_pendingGuestWorkCount++;
|
||||||
_pendingGuestWorkBytes = SaturatingAdd(_pendingGuestWorkBytes, payloadBytes);
|
_pendingGuestWorkBytes = SaturatingAdd(_pendingGuestWorkBytes, payloadBytes);
|
||||||
|
// Wake the embedded render loop parked in WaitForRenderWork; without a
|
||||||
|
// pulse it only notices new work when its timed wait expires.
|
||||||
|
System.Threading.Monitor.PulseAll(_gate);
|
||||||
return sequence;
|
return sequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2287,7 +2439,10 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
private const string FullscreenBarycentricFragmentSpirv =
|
private const string FullscreenBarycentricFragmentSpirv =
|
||||||
"AwIjBwAAAQALAAgAEgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ABwAEAAAABAAAAG1haW4AAAAACQAAAAwAAAAQAAMABAAAAAcAAAADAAMAAgAAAMIBAAAFAAQABAAAAG1haW4AAAAABQAFAAkAAABvdXRDb2xvcgAAAAAFAAUADAAAAGJhcnljZW50cmljAEcABAAJAAAAHgAAAAAAAABHAAQADAAAAB4AAAAAAAAAEwACAAIAAAAhAAMAAwAAAAIAAAAWAAMABgAAACAAAAAXAAQABwAAAAYAAAAEAAAAIAAEAAgAAAADAAAABwAAADsABAAIAAAACQAAAAMAAAAXAAQACgAAAAYAAAACAAAAIAAEAAsAAAABAAAACgAAADsABAALAAAADAAAAAEAAAArAAQABgAAAA4AAAAAAAAANgAFAAIAAAAEAAAAAAAAAAMAAAD4AAIABQAAAD0ABAAKAAAADQAAAAwAAABRAAUABgAAAA8AAAANAAAAAAAAAFEABQAGAAAAEAAAAA0AAAABAAAAUAAHAAcAAAARAAAADwAAABAAAAAOAAAADgAAAD4AAwAJAAAAEQAAAP0AAQA4AAEA";
|
"AwIjBwAAAQALAAgAEgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ABwAEAAAABAAAAG1haW4AAAAACQAAAAwAAAAQAAMABAAAAAcAAAADAAMAAgAAAMIBAAAFAAQABAAAAG1haW4AAAAABQAFAAkAAABvdXRDb2xvcgAAAAAFAAUADAAAAGJhcnljZW50cmljAEcABAAJAAAAHgAAAAAAAABHAAQADAAAAB4AAAAAAAAAEwACAAIAAAAhAAMAAwAAAAIAAAAWAAMABgAAACAAAAAXAAQABwAAAAYAAAAEAAAAIAAEAAgAAAADAAAABwAAADsABAAIAAAACQAAAAMAAAAXAAQACgAAAAYAAAACAAAAIAAEAAsAAAABAAAACgAAADsABAALAAAADAAAAAEAAAArAAQABgAAAA4AAAAAAAAANgAFAAIAAAAEAAAAAAAAAAMAAAD4AAIABQAAAD0ABAAKAAAADQAAAAwAAABRAAUABgAAAA8AAAANAAAAAAAAAFEABQAGAAAAEAAAAA0AAAABAAAAUAAHAAcAAAARAAAADwAAABAAAAAOAAAADgAAAD4AAwAJAAAAEQAAAP0AAQA4AAEA";
|
||||||
|
|
||||||
private readonly IWindow _window;
|
private readonly IWindow? _window;
|
||||||
|
private readonly VulkanHostSurface? _hostSurface;
|
||||||
|
private int _lastHostResizeGeneration;
|
||||||
|
private bool _embeddedLoopClosed;
|
||||||
private const int MaxInFlightGuestSubmissions = 8;
|
private const int MaxInFlightGuestSubmissions = 8;
|
||||||
private Vk _vk = null!;
|
private Vk _vk = null!;
|
||||||
private KhrSurface _surfaceApi = null!;
|
private KhrSurface _surfaceApi = null!;
|
||||||
@@ -2380,8 +2535,11 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
private long _presentNotTakenLoggedSequence = long.MinValue;
|
private long _presentNotTakenLoggedSequence = long.MinValue;
|
||||||
private bool _vulkanReady;
|
private bool _vulkanReady;
|
||||||
private bool _firstFramePresented;
|
private bool _firstFramePresented;
|
||||||
|
private bool _firstHostFramePresented;
|
||||||
private bool _firstGuestDrawPresented;
|
private bool _firstGuestDrawPresented;
|
||||||
private bool _splashPresented;
|
private bool _splashPresented;
|
||||||
|
private Presentation? _lastHostSplashPresentation;
|
||||||
|
private Presentation? _pendingHostSplashReplay;
|
||||||
private bool _swapchainRecreateDeferred;
|
private bool _swapchainRecreateDeferred;
|
||||||
private bool _tracedPresentedSwapchain;
|
private bool _tracedPresentedSwapchain;
|
||||||
private bool _swapchainReadbackPending;
|
private bool _swapchainReadbackPending;
|
||||||
@@ -2668,11 +2826,22 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
VulkanGuestQueueIdentity Queue,
|
VulkanGuestQueueIdentity Queue,
|
||||||
long WorkSequence);
|
long WorkSequence);
|
||||||
|
|
||||||
public Presenter(uint width, uint height)
|
public Presenter(uint width, uint height, VulkanHostSurface? hostSurface)
|
||||||
{
|
{
|
||||||
|
_hostSurface = hostSurface;
|
||||||
_hostBufferPool = new VulkanHostBufferPool(
|
_hostBufferPool = new VulkanHostBufferPool(
|
||||||
MaximumCachedHostBufferBytes,
|
MaximumCachedHostBufferBytes,
|
||||||
DestroyHostBufferAllocation);
|
DestroyHostBufferAllocation);
|
||||||
|
|
||||||
|
if (_hostSurface is not null)
|
||||||
|
{
|
||||||
|
_hostSurface.UpdatePixelSize(
|
||||||
|
_hostSurface.PixelWidth > 0 ? _hostSurface.PixelWidth : (int)width,
|
||||||
|
_hostSurface.PixelHeight > 0 ? _hostSurface.PixelHeight : (int)height);
|
||||||
|
_lastHostResizeGeneration = _hostSurface.ResizeGeneration;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var options = WindowOptions.DefaultVulkan;
|
var options = WindowOptions.DefaultVulkan;
|
||||||
options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
|
options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
|
||||||
options.Title = VideoOutExports.GetWindowTitle();
|
options.Title = VideoOutExports.GetWindowTitle();
|
||||||
@@ -2694,14 +2863,32 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Run() => _window.Run();
|
public void Run()
|
||||||
|
{
|
||||||
|
if (_window is not null)
|
||||||
|
{
|
||||||
|
_window.Run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Initialize();
|
||||||
|
while (!_embeddedLoopClosed)
|
||||||
|
{
|
||||||
|
Render(0);
|
||||||
|
// Guest draws and flips execute on this thread. An unconditional
|
||||||
|
// Thread.Sleep here is quantized to the Windows timer period
|
||||||
|
// (~15.6ms) and throttles every hosted game far below the GLFW
|
||||||
|
// path. Wait only while there is genuinely no render work.
|
||||||
|
WaitForRenderWork();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
DisposeVulkan();
|
DisposeVulkan();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_window.Dispose();
|
_window?.Dispose();
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException exception)
|
catch (InvalidOperationException exception)
|
||||||
when (exception.Message.Contains("render loop", StringComparison.OrdinalIgnoreCase))
|
when (exception.Message.Contains("render loop", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -2713,24 +2900,27 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
|
|
||||||
private void Initialize()
|
private void Initialize()
|
||||||
{
|
{
|
||||||
LogGlfwPlatformInUse();
|
if (_window is not null)
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
{
|
{
|
||||||
try
|
LogGlfwPlatformInUse();
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
Pad.HostWindowInput.Attach(_window.CreateInput());
|
try
|
||||||
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
|
{
|
||||||
|
Pad.HostWindowInput.Attach(_window.CreateInput());
|
||||||
|
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[LOADER][WARN] Window keyboard input unavailable: {exception.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[LOADER][WARN] Window keyboard input unavailable: {exception.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
|
if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
|
||||||
{
|
{
|
||||||
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
|
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
|
||||||
_window.SetWindowIcon(ref icon);
|
_window.SetWindowIcon(ref icon);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
WaitForRenderDocAttachIfRequested();
|
WaitForRenderDocAttachIfRequested();
|
||||||
@@ -2986,15 +3176,45 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
ApiVersion = Vk.Version12,
|
ApiVersion = Vk.Version12,
|
||||||
};
|
};
|
||||||
|
|
||||||
var extensions = _window.VkSurface!.GetRequiredExtensions(out var extensionCount);
|
var hostExtensionNames = _hostSurface is null
|
||||||
|
? null
|
||||||
|
: GetHostSurfaceExtensions(_hostSurface.Kind);
|
||||||
|
byte** extensions;
|
||||||
|
uint extensionCount;
|
||||||
|
if (hostExtensionNames is not null)
|
||||||
|
{
|
||||||
|
extensions = null;
|
||||||
|
extensionCount = (uint)hostExtensionNames.Length;
|
||||||
|
}
|
||||||
|
else if (_window?.VkSurface is { } glfwSurface)
|
||||||
|
{
|
||||||
|
extensions = glfwSurface.GetRequiredExtensions(out extensionCount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("GLFW did not provide Vulkan surface extensions.");
|
||||||
|
}
|
||||||
|
|
||||||
byte* debugUtilsExtension = null;
|
byte* debugUtilsExtension = null;
|
||||||
byte* portabilityExtension = null;
|
byte* portabilityExtension = null;
|
||||||
var instanceCreateFlags = InstanceCreateFlags.None;
|
var instanceCreateFlags = InstanceCreateFlags.None;
|
||||||
var enabledExtensionCount = (int)extensionCount;
|
var enabledExtensionCount = (int)extensionCount;
|
||||||
var enabledExtensions = stackalloc byte*[(int)extensionCount + 2];
|
var enabledExtensions = stackalloc byte*[(int)extensionCount + 2];
|
||||||
|
var allocatedHostExtensions = hostExtensionNames is null
|
||||||
|
? null
|
||||||
|
: new nint[hostExtensionNames.Length];
|
||||||
for (var index = 0; index < (int)extensionCount; index++)
|
for (var index = 0; index < (int)extensionCount; index++)
|
||||||
{
|
{
|
||||||
enabledExtensions[index] = extensions[index];
|
if (hostExtensionNames is null)
|
||||||
|
{
|
||||||
|
enabledExtensions[index] = extensions[index];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var extension = (byte*)SilkMarshal.StringToPtr(hostExtensionNames[index]);
|
||||||
|
allocatedHostExtensions![index] = (nint)extension;
|
||||||
|
enabledExtensions[index] = extension;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_vulkanDebugUtilsEnabled &&
|
if (_vulkanDebugUtilsEnabled &&
|
||||||
@@ -3065,6 +3285,13 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
{
|
{
|
||||||
SilkMarshal.Free((nint)portabilityExtension);
|
SilkMarshal.Free((nint)portabilityExtension);
|
||||||
}
|
}
|
||||||
|
if (allocatedHostExtensions is not null)
|
||||||
|
{
|
||||||
|
foreach (var extension in allocatedHostExtensions)
|
||||||
|
{
|
||||||
|
SilkMarshal.Free(extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -3174,11 +3401,99 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
private void CreateSurface()
|
private void CreateSurface()
|
||||||
{
|
{
|
||||||
|
if (_hostSurface is not null)
|
||||||
|
{
|
||||||
|
CreateHostSurface(_hostSurface);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var instanceHandle = new VkHandle(_instance.Handle);
|
var instanceHandle = new VkHandle(_instance.Handle);
|
||||||
var surfaceHandle = _window.VkSurface!.Create<AllocationCallbacks>(instanceHandle, null);
|
var surfaceHandle = _window!.VkSurface!.Create<AllocationCallbacks>(instanceHandle, null);
|
||||||
_surface = new SurfaceKHR(surfaceHandle.Handle);
|
_surface = new SurfaceKHR(surfaceHandle.Handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string[] GetHostSurfaceExtensions(VulkanHostSurfaceKind kind) => kind switch
|
||||||
|
{
|
||||||
|
VulkanHostSurfaceKind.Win32 => ["VK_KHR_surface", "VK_KHR_win32_surface"],
|
||||||
|
VulkanHostSurfaceKind.Xlib => ["VK_KHR_surface", "VK_KHR_xlib_surface"],
|
||||||
|
VulkanHostSurfaceKind.Metal => ["VK_KHR_surface", "VK_EXT_metal_surface"],
|
||||||
|
_ => throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {kind}."),
|
||||||
|
};
|
||||||
|
|
||||||
|
private void CreateHostSurface(VulkanHostSurface hostSurface)
|
||||||
|
{
|
||||||
|
switch (hostSurface.Kind)
|
||||||
|
{
|
||||||
|
case VulkanHostSurfaceKind.Win32:
|
||||||
|
CreateWin32HostSurface(hostSurface);
|
||||||
|
return;
|
||||||
|
case VulkanHostSurfaceKind.Xlib:
|
||||||
|
CreateXlibHostSurface(hostSurface);
|
||||||
|
return;
|
||||||
|
case VulkanHostSurfaceKind.Metal:
|
||||||
|
CreateMetalHostSurface(hostSurface);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {hostSurface.Kind}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateWin32HostSurface(VulkanHostSurface hostSurface)
|
||||||
|
{
|
||||||
|
if (!_vk.TryGetInstanceExtension(_instance, out KhrWin32Surface win32Surface))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("VK_KHR_win32_surface is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var createInfo = new Win32SurfaceCreateInfoKHR
|
||||||
|
{
|
||||||
|
SType = StructureType.Win32SurfaceCreateInfoKhr,
|
||||||
|
Hinstance = hostSurface.DisplayHandle != 0
|
||||||
|
? hostSurface.DisplayHandle
|
||||||
|
: GetModuleHandleW(null),
|
||||||
|
Hwnd = hostSurface.WindowHandle,
|
||||||
|
};
|
||||||
|
Check(win32Surface.CreateWin32Surface(_instance, &createInfo, null, out _surface), "vkCreateWin32SurfaceKHR");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateXlibHostSurface(VulkanHostSurface hostSurface)
|
||||||
|
{
|
||||||
|
if (!_vk.TryGetInstanceExtension(_instance, out KhrXlibSurface xlibSurface))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("VK_KHR_xlib_surface is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var createInfo = new XlibSurfaceCreateInfoKHR
|
||||||
|
{
|
||||||
|
SType = StructureType.XlibSurfaceCreateInfoKhr,
|
||||||
|
Dpy = (nint*)hostSurface.DisplayHandle,
|
||||||
|
Window = hostSurface.WindowHandle,
|
||||||
|
};
|
||||||
|
Check(xlibSurface.CreateXlibSurface(_instance, &createInfo, null, out _surface), "vkCreateXlibSurfaceKHR");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateMetalHostSurface(VulkanHostSurface hostSurface)
|
||||||
|
{
|
||||||
|
var proc = _vk.GetInstanceProcAddr(_instance, "vkCreateMetalSurfaceEXT");
|
||||||
|
if (proc == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("VK_EXT_metal_surface is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var createInfo = new MetalSurfaceCreateInfoEXT
|
||||||
|
{
|
||||||
|
SType = StructureType.MetalSurfaceCreateInfoExt,
|
||||||
|
PLayer = (nint*)hostSurface.MetalLayerHandle,
|
||||||
|
};
|
||||||
|
var createSurface = (delegate* unmanaged<Instance, MetalSurfaceCreateInfoEXT*, AllocationCallbacks*, SurfaceKHR*, Result>)proc.Handle;
|
||||||
|
SurfaceKHR surface;
|
||||||
|
Check(createSurface(_instance, &createInfo, null, &surface), "vkCreateMetalSurfaceEXT");
|
||||||
|
_surface = surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern nint GetModuleHandleW(string? moduleName);
|
||||||
|
|
||||||
private void SelectPhysicalDevice()
|
private void SelectPhysicalDevice()
|
||||||
{
|
{
|
||||||
uint deviceCount = 0;
|
uint deviceCount = 0;
|
||||||
@@ -3245,7 +3560,10 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})");
|
$"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})");
|
||||||
VideoOutExports.SetSelectedGpuName(selectedName);
|
VideoOutExports.SetSelectedGpuName(selectedName);
|
||||||
_window.Title = VideoOutExports.GetWindowTitle();
|
if (_window is not null)
|
||||||
|
{
|
||||||
|
_window.Title = VideoOutExports.GetWindowTitle();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadComputeDeviceLimits()
|
private void LoadComputeDeviceLimits()
|
||||||
@@ -7674,15 +7992,19 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
WaitForActiveGuestQueueSubmissionsForCpuVisibility();
|
WaitForActiveGuestQueueSubmissionsForCpuVisibility();
|
||||||
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
|
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
|
||||||
}
|
}
|
||||||
var mapped = new Span<byte>(
|
// Populate the cached shadow copy first and write it out to the
|
||||||
(void*)(allocation.Mapped + checked((nint)guestOffset)),
|
// mapped allocation in one pass. The mapped memory is
|
||||||
guestBuffer.Length);
|
// HOST_VISIBLE|HOST_COHERENT (write-combined on most drivers),
|
||||||
if (_guestMemory?.TryRead(guestBuffer.BaseAddress, mapped) != true)
|
// so CPU reads from it are uncached and orders of magnitude
|
||||||
|
// slower than heap reads — never use it as a copy source.
|
||||||
|
if (_guestMemory?.TryRead(guestBuffer.BaseAddress, shadow) != true)
|
||||||
{
|
{
|
||||||
source.CopyTo(mapped);
|
source.CopyTo(shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
mapped.CopyTo(shadow);
|
shadow.CopyTo(new Span<byte>(
|
||||||
|
(void*)(allocation.Mapped + checked((nint)guestOffset)),
|
||||||
|
guestBuffer.Length));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ShouldTraceVulkanResources() &&
|
if (ShouldTraceVulkanResources() &&
|
||||||
@@ -11219,6 +11541,30 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool _overlayHotkeyWasDown;
|
||||||
|
|
||||||
|
private void PollPerfOverlayHotkey()
|
||||||
|
{
|
||||||
|
// POSIX hosts route F1 through the GLFW window's keyboard events
|
||||||
|
// (HostWindowInput.Attach). Windows never attaches that path — its
|
||||||
|
// input comes from user32 polling — so sample the hotkey here for
|
||||||
|
// both the standalone window and the embedded host surface.
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int VkF1 = 0x70;
|
||||||
|
var input = SharpEmu.HLE.Host.HostPlatform.Current.Input;
|
||||||
|
var down = input.IsKeyDown(VkF1) && input.IsHostWindowFocused();
|
||||||
|
if (down && !_overlayHotkeyWasDown)
|
||||||
|
{
|
||||||
|
PerfOverlay.Toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
_overlayHotkeyWasDown = down;
|
||||||
|
}
|
||||||
|
|
||||||
private void Render(double _)
|
private void Render(double _)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -11241,10 +11587,30 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
if (Volatile.Read(ref _presenterCloseRequested))
|
if (Volatile.Read(ref _presenterCloseRequested))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("[LOADER][WARN] Vulkan VideoOut closing on host shutdown request.");
|
Console.Error.WriteLine("[LOADER][WARN] Vulkan VideoOut closing on host shutdown request.");
|
||||||
_window.Close();
|
if (_window is not null)
|
||||||
|
{
|
||||||
|
_window.Close();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_embeddedLoopClosed = true;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PollPerfOverlayHotkey();
|
||||||
|
|
||||||
|
if (_hostSurface is not null)
|
||||||
|
{
|
||||||
|
_hostSurface.RefreshChildProcessPixelSize();
|
||||||
|
if (_lastHostResizeGeneration != _hostSurface.ResizeGeneration)
|
||||||
|
{
|
||||||
|
_lastHostResizeGeneration = _hostSurface.ResizeGeneration;
|
||||||
|
RecreateSwapchainResources("embedded host resize", Result.SuboptimalKhr);
|
||||||
|
_pendingHostSplashReplay = _lastHostSplashPresentation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!_vulkanReady)
|
if (!_vulkanReady)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -11287,7 +11653,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
var renderWorkDeadline = _renderWorkBudgetTicks > 0
|
var renderWorkDeadline = _renderWorkBudgetTicks > 0
|
||||||
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
|
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
|
||||||
: long.MaxValue;
|
: long.MaxValue;
|
||||||
while (completedWork < MaxGuestWorkPerRender)
|
while (completedWork < _maxGuestWorkPerRender)
|
||||||
{
|
{
|
||||||
// Never block the macOS main thread waiting for in-flight GPU
|
// Never block the macOS main thread waiting for in-flight GPU
|
||||||
// work to drain. If submission is at capacity (a slow-compute
|
// work to drain. If submission is at capacity (a slow-compute
|
||||||
@@ -11295,7 +11661,8 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
// remaining queued work is picked up on later frames as the GPU
|
// remaining queued work is picked up on later frames as the GPU
|
||||||
// completions free up capacity (collected non-blockingly here).
|
// completions free up capacity (collected non-blockingly here).
|
||||||
CollectCompletedGuestSubmissions(waitForOldest: false);
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
||||||
if (_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
|
if (OperatingSystem.IsMacOS() &&
|
||||||
|
_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -11412,6 +11779,13 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
|
|
||||||
if (!TryTakePresentation(_presentedSequence, out var presentation))
|
if (!TryTakePresentation(_presentedSequence, out var presentation))
|
||||||
{
|
{
|
||||||
|
if (_pendingHostSplashReplay is { } splash)
|
||||||
|
{
|
||||||
|
presentation = splash;
|
||||||
|
_pendingHostSplashReplay = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
// A render-loop tick with no newer flip is normal. Warn only when
|
// A render-loop tick with no newer flip is normal. Warn only when
|
||||||
// an actual queued presentation is waiting on unfinished guest work.
|
// an actual queued presentation is waiting on unfinished guest work.
|
||||||
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
|
||||||
@@ -11425,6 +11799,20 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hostSurface is not null)
|
||||||
|
{
|
||||||
|
if (presentation.IsSplash && presentation.Pixels is not null)
|
||||||
|
{
|
||||||
|
_lastHostSplashPresentation = presentation;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lastHostSplashPresentation = null;
|
||||||
|
_pendingHostSplashReplay = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
|
||||||
@@ -11449,6 +11837,13 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
{
|
{
|
||||||
pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height
|
pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height
|
||||||
? sourcePixels
|
? sourcePixels
|
||||||
|
: _hostSurface is not null
|
||||||
|
? ScaleBgraCoverBilinear(
|
||||||
|
sourcePixels,
|
||||||
|
presentation.Width,
|
||||||
|
presentation.Height,
|
||||||
|
_extent.Width,
|
||||||
|
_extent.Height)
|
||||||
: ScaleBgra(
|
: ScaleBgra(
|
||||||
sourcePixels,
|
sourcePixels,
|
||||||
presentation.Width,
|
presentation.Width,
|
||||||
@@ -11715,6 +12110,11 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
|
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
|
||||||
VideoOutExports.ReportPresentedFrame();
|
VideoOutExports.ReportPresentedFrame();
|
||||||
PerfOverlay.RecordPresent();
|
PerfOverlay.RecordPresent();
|
||||||
|
if (_hostSurface is not null && !_firstHostFramePresented)
|
||||||
|
{
|
||||||
|
_firstHostFramePresented = true;
|
||||||
|
NotifyFirstHostFramePresented(_hostSurface);
|
||||||
|
}
|
||||||
if (_swapchainReadbackPending || !_pendingAliasImageDumps.IsEmpty)
|
if (_swapchainReadbackPending || !_pendingAliasImageDumps.IsEmpty)
|
||||||
{
|
{
|
||||||
// Diagnostics read back GPU memory and need this frame done.
|
// Diagnostics read back GPU memory and need this frame done.
|
||||||
@@ -13551,12 +13951,35 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
2,
|
2,
|
||||||
barriers);
|
barriers);
|
||||||
|
|
||||||
|
var sourceX = 0u;
|
||||||
|
var sourceY = 0u;
|
||||||
|
var sourceWidth = source.Width;
|
||||||
|
var sourceHeight = source.Height;
|
||||||
|
if (_hostSurface is not null)
|
||||||
|
{
|
||||||
|
// The embedded GUI fills its game surface like CSS
|
||||||
|
// object-fit: cover. Preserve the guest aspect ratio and crop
|
||||||
|
// only the excess instead of distorting every video frame.
|
||||||
|
var sourceIsWider = (ulong)sourceWidth * _extent.Height >
|
||||||
|
(ulong)_extent.Width * sourceHeight;
|
||||||
|
if (sourceIsWider)
|
||||||
|
{
|
||||||
|
sourceWidth = Math.Max(1u, (uint)((ulong)sourceHeight * _extent.Width / _extent.Height));
|
||||||
|
sourceX = (source.Width - sourceWidth) / 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sourceHeight = Math.Max(1u, (uint)((ulong)sourceWidth * _extent.Height / _extent.Width));
|
||||||
|
sourceY = (source.Height - sourceHeight) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var sourceOffsets = new ImageBlit.SrcOffsetsBuffer
|
var sourceOffsets = new ImageBlit.SrcOffsetsBuffer
|
||||||
{
|
{
|
||||||
Element0 = new Offset3D(0, 0, 0),
|
Element0 = new Offset3D(checked((int)sourceX), checked((int)sourceY), 0),
|
||||||
Element1 = new Offset3D(
|
Element1 = new Offset3D(
|
||||||
checked((int)source.Width),
|
checked((int)(sourceX + sourceWidth)),
|
||||||
checked((int)source.Height),
|
checked((int)(sourceY + sourceHeight)),
|
||||||
1),
|
1),
|
||||||
};
|
};
|
||||||
var destinationOffsets = new ImageBlit.DstOffsetsBuffer
|
var destinationOffsets = new ImageBlit.DstOffsetsBuffer
|
||||||
@@ -13587,9 +14010,9 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
// must blend neighbours or it silently drops every Nth source
|
// must blend neighbours or it silently drops every Nth source
|
||||||
// row/column, which shreds 1-2px features in the guest frame.
|
// row/column, which shreds 1-2px features in the guest frame.
|
||||||
var isIntegerUpscale =
|
var isIntegerUpscale =
|
||||||
source.Width != 0 && source.Height != 0 &&
|
sourceWidth != 0 && sourceHeight != 0 &&
|
||||||
_extent.Width >= source.Width && _extent.Height >= source.Height &&
|
_extent.Width >= sourceWidth && _extent.Height >= sourceHeight &&
|
||||||
_extent.Width % source.Width == 0 && _extent.Height % source.Height == 0;
|
_extent.Width % sourceWidth == 0 && _extent.Height % sourceHeight == 0;
|
||||||
_vk.CmdBlitImage(
|
_vk.CmdBlitImage(
|
||||||
_commandBuffer,
|
_commandBuffer,
|
||||||
source.Image,
|
source.Image,
|
||||||
@@ -13786,7 +14209,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
capabilities.MaxImageExtent.Height));
|
capabilities.MaxImageExtent.Height));
|
||||||
}
|
}
|
||||||
|
|
||||||
var size = _window.FramebufferSize;
|
var size = GetFramebufferSize();
|
||||||
return new Extent2D(
|
return new Extent2D(
|
||||||
ClampSurfaceExtent(
|
ClampSurfaceExtent(
|
||||||
(uint)Math.Max(size.X, 1),
|
(uint)Math.Max(size.X, 1),
|
||||||
@@ -13800,6 +14223,21 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
capabilities.MaxImageExtent.Height));
|
capabilities.MaxImageExtent.Height));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Vector2D<int> GetFramebufferSize()
|
||||||
|
{
|
||||||
|
if (_window is not null)
|
||||||
|
{
|
||||||
|
return _window.FramebufferSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hostSurface is not null)
|
||||||
|
{
|
||||||
|
return new Vector2D<int>(_hostSurface.PixelWidth, _hostSurface.PixelHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
|
||||||
|
}
|
||||||
|
|
||||||
private static uint ClampSurfaceExtent(
|
private static uint ClampSurfaceExtent(
|
||||||
uint value,
|
uint value,
|
||||||
uint fallback,
|
uint fallback,
|
||||||
@@ -13876,6 +14314,88 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
return destination;
|
return destination;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static byte[] ScaleBgraCoverBilinear(
|
||||||
|
byte[] source,
|
||||||
|
uint sourceWidth,
|
||||||
|
uint sourceHeight,
|
||||||
|
uint width,
|
||||||
|
uint height)
|
||||||
|
{
|
||||||
|
var destination = new byte[checked((int)(width * height * 4))];
|
||||||
|
var sourceIsWider = (ulong)sourceWidth * height > (ulong)width * sourceHeight;
|
||||||
|
var cropWidth = sourceIsWider
|
||||||
|
? Math.Max(1u, (uint)((ulong)sourceHeight * width / height))
|
||||||
|
: sourceWidth;
|
||||||
|
var cropHeight = sourceIsWider
|
||||||
|
? sourceHeight
|
||||||
|
: Math.Max(1u, (uint)((ulong)sourceWidth * height / width));
|
||||||
|
var offsetX = (sourceWidth - cropWidth) / 2;
|
||||||
|
var offsetY = (sourceHeight - cropHeight) / 2;
|
||||||
|
var maxSourceX = offsetX + cropWidth - 1;
|
||||||
|
var maxSourceY = offsetY + cropHeight - 1;
|
||||||
|
|
||||||
|
for (uint y = 0; y < height; y++)
|
||||||
|
{
|
||||||
|
for (uint x = 0; x < width; x++)
|
||||||
|
{
|
||||||
|
var destinationOffset = checked((int)(((ulong)y * width + x) * 4));
|
||||||
|
float blue = 0;
|
||||||
|
float green = 0;
|
||||||
|
float red = 0;
|
||||||
|
float alpha = 0;
|
||||||
|
for (var sampleY = 0; sampleY < 2; sampleY++)
|
||||||
|
{
|
||||||
|
var scaledY = offsetY + (((y + ((sampleY + 0.5f) / 2)) * cropHeight) / height) - 0.5f;
|
||||||
|
var sourceY0 = (uint)Math.Clamp((int)MathF.Floor(scaledY), (int)offsetY, (int)maxSourceY);
|
||||||
|
var sourceY1 = Math.Min(sourceY0 + 1, maxSourceY);
|
||||||
|
var fractionY = scaledY - MathF.Floor(scaledY);
|
||||||
|
for (var sampleX = 0; sampleX < 2; sampleX++)
|
||||||
|
{
|
||||||
|
var scaledX = offsetX + (((x + ((sampleX + 0.5f) / 2)) * cropWidth) / width) - 0.5f;
|
||||||
|
var sourceX0 = (uint)Math.Clamp((int)MathF.Floor(scaledX), (int)offsetX, (int)maxSourceX);
|
||||||
|
var sourceX1 = Math.Min(sourceX0 + 1, maxSourceX);
|
||||||
|
var fractionX = scaledX - MathF.Floor(scaledX);
|
||||||
|
var sourceOffset00 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX0) * 4));
|
||||||
|
var sourceOffset10 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX1) * 4));
|
||||||
|
var sourceOffset01 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX0) * 4));
|
||||||
|
var sourceOffset11 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX1) * 4));
|
||||||
|
|
||||||
|
var topBlue = source[sourceOffset00] +
|
||||||
|
((source[sourceOffset10] - source[sourceOffset00]) * fractionX);
|
||||||
|
var bottomBlue = source[sourceOffset01] +
|
||||||
|
((source[sourceOffset11] - source[sourceOffset01]) * fractionX);
|
||||||
|
blue += topBlue + ((bottomBlue - topBlue) * fractionY);
|
||||||
|
|
||||||
|
var topGreen = source[sourceOffset00 + 1] +
|
||||||
|
((source[sourceOffset10 + 1] - source[sourceOffset00 + 1]) * fractionX);
|
||||||
|
var bottomGreen = source[sourceOffset01 + 1] +
|
||||||
|
((source[sourceOffset11 + 1] - source[sourceOffset01 + 1]) * fractionX);
|
||||||
|
green += topGreen + ((bottomGreen - topGreen) * fractionY);
|
||||||
|
|
||||||
|
var topRed = source[sourceOffset00 + 2] +
|
||||||
|
((source[sourceOffset10 + 2] - source[sourceOffset00 + 2]) * fractionX);
|
||||||
|
var bottomRed = source[sourceOffset01 + 2] +
|
||||||
|
((source[sourceOffset11 + 2] - source[sourceOffset01 + 2]) * fractionX);
|
||||||
|
red += topRed + ((bottomRed - topRed) * fractionY);
|
||||||
|
|
||||||
|
var topAlpha = source[sourceOffset00 + 3] +
|
||||||
|
((source[sourceOffset10 + 3] - source[sourceOffset00 + 3]) * fractionX);
|
||||||
|
var bottomAlpha = source[sourceOffset01 + 3] +
|
||||||
|
((source[sourceOffset11 + 3] - source[sourceOffset01 + 3]) * fractionX);
|
||||||
|
alpha += topAlpha + ((bottomAlpha - topAlpha) * fractionY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destination[destinationOffset] = (byte)MathF.Round(blue * 0.25f);
|
||||||
|
destination[destinationOffset + 1] = (byte)MathF.Round(green * 0.25f);
|
||||||
|
destination[destinationOffset + 2] = (byte)MathF.Round(red * 0.25f);
|
||||||
|
destination[destinationOffset + 3] = (byte)MathF.Round(alpha * 0.25f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
private void DisposeVulkan()
|
private void DisposeVulkan()
|
||||||
{
|
{
|
||||||
if (!_vulkanReady)
|
if (!_vulkanReady)
|
||||||
@@ -13994,7 +14514,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
_surface,
|
_surface,
|
||||||
out var capabilities),
|
out var capabilities),
|
||||||
"vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
|
"vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
|
||||||
var framebufferSize = _window.FramebufferSize;
|
var framebufferSize = GetFramebufferSize();
|
||||||
var hasFixedExtent = capabilities.CurrentExtent.Width != uint.MaxValue;
|
var hasFixedExtent = capabilities.CurrentExtent.Width != uint.MaxValue;
|
||||||
var surfaceWidth = hasFixedExtent
|
var surfaceWidth = hasFixedExtent
|
||||||
? capabilities.CurrentExtent.Width
|
? capabilities.CurrentExtent.Width
|
||||||
|
|||||||
Reference in New Issue
Block a user