[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:
Berk
2026-07-17 18:57:10 +03:00
committed by GitHub
parent aa25f6e978
commit fbafd3f429
25 changed files with 3661 additions and 585 deletions
+1
View File
@@ -5,6 +5,7 @@ path = [
"REUSE.toml",
"nuget.config",
"global.json",
"**/packages.lock.json",
"scripts/ps5_names.txt",
"src/SharpEmu.GUI/Languages/**",
"_logs/**",
+121 -54
View File
@@ -69,10 +69,9 @@ internal static partial class Program
}
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();
}
@@ -228,7 +227,17 @@ internal static partial class Program
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();
return 1;
@@ -255,66 +264,122 @@ internal static partial class Program
Console.Error.WriteLine("[DEBUG] Creating runtime...");
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result;
ConsoleCancelEventHandler? cancelHandler = null;
try
{
cancelHandler = (_, eventArgs) =>
if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
{
eventArgs.Cancel = true;
VideoOutExports.NotifyHostInterrupt();
};
Console.CancelKeyPress += cancelHandler;
Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
return 3;
}
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;
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result;
ConsoleCancelEventHandler? cancelHandler = null;
try
{
cancelHandler = (_, eventArgs) =>
{
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
{
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})");
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;
emulatorArgs = remaining.ToArray();
return true;
}
private static void EnsureCliConsole()
@@ -411,7 +476,9 @@ internal static partial class Program
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;
var trustedMitigatedChild = string.Equals(
+1
View File
@@ -54,6 +54,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
<ApplicationIcon>..\..\assets\images\SharpEmu.ico</ApplicationIcon>
<Win32Icon>..\..\assets\images\SharpEmu.ico</Win32Icon>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup>
+16
View File
@@ -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>
+588
View File
@@ -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=="
}
}
}
}
+8
View File
@@ -693,6 +693,13 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
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()
{
if (_nativeCpuBackend is IDisposable disposableBackend)
@@ -700,6 +707,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
disposableBackend.Dispose();
}
NativeSessionLeaked = _nativeCpuBackend is DirectExecutionBackend { GuestSessionLeaked: true };
_nativeCpuBackend = null;
}
}
@@ -718,7 +718,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private string? _guestThreadYieldReason;
private bool _forcedGuestExit;
private volatile bool _forcedGuestExit;
private ulong _lastAvTraceRip;
@@ -917,7 +917,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
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
{
if (HasActiveExecutionThread)
@@ -3409,6 +3412,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
returnValue = 0;
error = null;
if (_forcedGuestExit)
{
error = "guest execution is shutting down";
return false;
}
if (entryPoint < 65536)
{
error = $"invalid guest callback entry=0x{entryPoint:X16}";
@@ -3655,6 +3663,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
out string? error)
{
error = null;
if (_forcedGuestExit)
{
error = "guest execution is shutting down";
return false;
}
if (continuation.Rip < 65536 || continuation.Rsp == 0)
{
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)
{
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)
{
if (!thread.ExecutorActive)
@@ -5526,7 +5554,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
num6 = CallNativeEntry(ptr);
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)
{
@@ -6179,8 +6214,68 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
[return: MarshalAs(UnmanagedType.Bool)]
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()
{
// 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))
{
// 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] 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;
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
@@ -1152,6 +1162,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
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)
{
disposableMemory.Dispose();
+17
View File
@@ -29,6 +29,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
</Application.Resources>
@@ -55,6 +56,22 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Padding" Value="10,3" />
</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">
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
+331 -316
View File
@@ -1,7 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
@@ -10,32 +9,36 @@ using Microsoft.Win32.SafeHandles;
namespace SharpEmu.GUI;
/// <summary>
/// Launches the SharpEmu CLI as a child process with the same CET/CFG mitigation
/// opt-outs the CLI would apply to its own relaunched child, while capturing
/// stdout/stderr through pipes. The CLI's internal relaunch is suppressed via
/// 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.
/// Owns an isolated emulator process. Guest virtual memory is fixed-address and
/// cannot be reliably reused while guest-created host threads are still alive,
/// so the GUI must never execute a game in its own process.
/// </summary>
internal sealed class EmulatorProcess : IDisposable
{
private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
private const uint CREATE_NO_WINDOW = 0x08000000;
private const int STARTF_USESTDHANDLES = 0x00000100;
private const uint HANDLE_FLAG_INHERIT = 0x00000001;
private const uint INFINITE = 0xFFFFFFFF;
private const int PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY = 0x00020007;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
private const int JobObjectExtendedLimitInformation = 9;
private const ulong PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF = 0x00000002UL << 28;
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF = 0x00000002UL << 32;
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
public const int HostStopExitCode = -2;
private const uint ExtendedStartupInfoPresent = 0x00080000;
private const uint CreateNoWindow = 0x08000000;
private const int StartfUseStdHandles = 0x00000100;
private const uint HandleFlagInherit = 0x00000001;
private const uint Infinite = 0xFFFFFFFF;
private const int ProcThreadAttributeMitigationPolicy = 0x00020007;
private const uint JobObjectLimitKillOnJobClose = 0x00002000;
private const int JobObjectExtendedLimitInformationClass = 9;
private const string MitigatedChildFlag = "--sharpemu-mitigated-child";
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 nint _processHandle;
private nint _jobHandle;
private Process? _fallbackProcess;
private bool _running;
private bool _stopRequested;
private bool _disposed;
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)
{
ArgumentException.ThrowIfNullOrWhiteSpace(exePath);
ArgumentNullException.ThrowIfNull(arguments);
lock (_sync)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ThrowIfDisposed();
if (_running)
{
throw new InvalidOperationException("The emulator process is already running.");
}
if (OperatingSystem.IsWindows())
{
StartWindows(exePath, arguments, workingDirectory);
}
else
{
StartFallback(exePath, arguments, workingDirectory);
}
_running = true;
_stopRequested = false;
}
if (OperatingSystem.IsWindows())
{
StartWindows(exePath, arguments, workingDirectory);
return;
}
StartFallback(exePath, arguments, workingDirectory);
}
public void Stop()
{
nint processHandle;
nint jobHandle;
Process? fallbackProcess;
lock (_sync)
{
if (!_running)
@@ -85,28 +93,35 @@ internal sealed class EmulatorProcess : IDisposable
return;
}
// Prefer terminating the job: it kills the whole tree, including
// any children the emulator spawned, even when the main process
// is wedged in a GPU driver call.
if (_jobHandle != 0)
{
_ = TerminateJobObject(_jobHandle, 1);
}
_stopRequested = true;
processHandle = _processHandle;
jobHandle = _jobHandle;
fallbackProcess = _fallbackProcess;
}
if (_processHandle != 0)
{
_ = TerminateProcess(_processHandle, 1);
}
if (jobHandle != 0)
{
_ = 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);
}
catch (InvalidOperationException)
{
// Already exited.
fallbackProcess.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
// The process exited while Stop was handling the request.
}
}
public void Dispose()
@@ -124,121 +139,206 @@ internal sealed class EmulatorProcess : IDisposable
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
// console output cannot flow through our pipes.
Environment.SetEnvironmentVariable("SHARPEMU_DISABLE_MITIGATION_RELAUNCH", "1");
var securityAttributes = new SECURITY_ATTRIBUTES
var startInfo = new ProcessStartInfo(exePath)
{
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
bInheritHandle = 1,
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory)
? Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory
: workingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
if (!CreatePipe(out var stdoutRead, out var stdoutWrite, ref securityAttributes, 0) ||
!CreatePipe(out var stderrRead, out var stderrWrite, ref securityAttributes, 0))
foreach (var argument in arguments)
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create output pipes.");
startInfo.ArgumentList.Add(argument);
}
_ = SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0);
_ = SetHandleInformation(stderrRead, HANDLE_FLAG_INHERIT, 0);
var process = Process.Start(startInfo)
?? 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();
startupInfoEx.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
startupInfoEx.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startupInfoEx.StartupInfo.hStdOutput = stdoutWrite;
startupInfoEx.StartupInfo.hStdError = stderrWrite;
lock (_sync)
{
_fallbackProcess = process;
_running = true;
}
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 mitigationPolicies = 0;
nint processHandle = 0;
nint threadHandle = 0;
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;
_ = InitializeProcThreadAttributeList(0, 1, 0, ref attributeListSize);
attributeList = Marshal.AllocHGlobal((nint)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);
Marshal.WriteInt64(mitigationPolicies, unchecked((long)policy1));
Marshal.WriteInt64(nint.Add(mitigationPolicies, sizeof(long)), unchecked((long)policy2));
Marshal.WriteInt64(mitigationPolicies, unchecked((long)ControlFlowGuardAlwaysOff));
Marshal.WriteInt64(
nint.Add(mitigationPolicies, sizeof(long)),
unchecked((long)(CetUserShadowStacksAlwaysOff | UserCetSetContextIpValidationAlwaysOff)));
if (!UpdateProcThreadAttribute(
attributeList,
0,
PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY,
mitigationPolicies,
(nuint)(sizeof(ulong) * 2),
0,
0))
attributeList,
0,
(nint)ProcThreadAttributeMitigationPolicy,
mitigationPolicies,
(nuint)(sizeof(ulong) * 2),
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 created = CreateProcessW(
exePath,
new StringBuilder(BuildCommandLine(exePath, arguments)),
0,
0,
true,
EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW,
0,
currentDirectory,
ref startupInfoEx,
out var processInfo);
var startup = new StartupInfoEx();
startup.StartupInfo.Size = Marshal.SizeOf<StartupInfoEx>();
startup.StartupInfo.Flags = StartfUseStdHandles;
startup.StartupInfo.StdOutput = stdoutWrite;
startup.StartupInfo.StdError = stderrWrite;
startup.AttributeList = attributeList;
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();
throw new Win32Exception(error, $"Failed to start '{exePath}' with CET/CFG mitigation disabled (Win32 error {error}: {new Win32Exception(error).Message}).");
var previousValue = Environment.GetEnvironmentVariable(MitigatedChildEnvironment);
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.hProcess;
processHandle = processInfo.Process;
threadHandle = processInfo.Thread;
CloseHandle(stdoutWrite);
stdoutWrite = 0;
CloseHandle(stderrWrite);
stderrWrite = 0;
_jobHandle = CreateJobObjectW(0, null);
if (_jobHandle != 0 &&
(!TryEnableKillOnJobClose(_jobHandle) || !AssignProcessToJobObject(_jobHandle, processInfo.hProcess)))
var jobHandle = CreateJobObjectW(0, null);
if (jobHandle != 0 &&
(!TryEnableKillOnJobClose(jobHandle) || !AssignProcessToJobObject(jobHandle, processHandle)))
{
CloseHandle(_jobHandle);
_jobHandle = 0;
CloseHandle(jobHandle);
jobHandle = 0;
}
StartReaderThread(stdoutRead, isError: false);
StartReaderThread(stderrRead, isError: true);
StartExitWatcherThread();
lock (_sync)
{
_processHandle = processHandle;
_jobHandle = jobHandle;
_running = true;
}
processHandle = 0;
StartPipeReader(stdoutRead, isError: false);
stdoutRead = 0;
StartPipeReader(stderrRead, isError: true);
stderrRead = 0;
StartWindowsExitWatcher(_processHandle);
}
catch
{
CloseHandle(stdoutRead);
CloseHandle(stderrRead);
if (processHandle != 0)
{
_ = TerminateProcess(processHandle, 1);
}
throw;
}
finally
{
// The child owns duplicated pipe write ends; closing ours lets the
// readers observe EOF when the child exits.
CloseHandle(stdoutWrite);
CloseHandle(stderrWrite);
if (threadHandle != 0)
{
CloseHandle(threadHandle);
}
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)
{
DeleteProcThreadAttributeList(attributeList);
Marshal.FreeHGlobal(attributeList);
}
if (mitigationPolicies != 0)
{
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,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
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)
using var safeHandle = new SafeFileHandle(handle, ownsHandle: true);
using var stream = new FileStream(safeHandle, FileAccess.Read, 4096, isAsync: false);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
while (reader.ReadLine() is { } line)
{
OutputReceived?.Invoke(e.Data, false);
ForwardOutput(line, isError);
}
};
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.
}
})
}, 256 * 1024)
{
IsBackground = true,
Name = isError ? "SharpEmu stderr reader" : "SharpEmu stdout reader",
};
thread.Start();
readerThread.Start();
}
private void StartExitWatcherThread()
private void StartWindowsExitWatcher(nint processHandle)
{
var processHandle = _processHandle;
var thread = new Thread(() =>
var watcher = new Thread(() =>
{
_ = WaitForSingleObject(processHandle, INFINITE);
var exitCode = GetExitCodeProcess(processHandle, out var rawExitCode)
? unchecked((int)rawExitCode)
: -1;
_ = WaitForSingleObject(processHandle, Infinite);
var exitCode = 1;
if (GetExitCodeProcess(processHandle, out var nativeExitCode))
{
exitCode = unchecked((int)nativeExitCode);
}
OnExited(exitCode);
})
}, 128 * 1024)
{
IsBackground = true,
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)
{
if (!_running)
@@ -350,13 +403,13 @@ internal sealed class EmulatorProcess : IDisposable
return;
}
exitCode = _stopRequested ? HostStopExitCode : nativeExitCode;
_running = false;
if (_processHandle != 0)
{
CloseHandle(_processHandle);
_processHandle = 0;
}
if (_jobHandle != 0)
{
CloseHandle(_jobHandle);
@@ -372,24 +425,19 @@ internal sealed class EmulatorProcess : IDisposable
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<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
var size = Marshal.SizeOf<JobObjectExtendedLimitInformation>();
var memory = Marshal.AllocHGlobal(size);
try
{
Marshal.StructureToPtr(extendedLimitInfo, memory, false);
return SetInformationJobObject(
jobHandle,
JobObjectExtendedLimitInformation,
memory,
unchecked((uint)size));
Marshal.StructureToPtr(info, memory, false);
return SetInformationJobObject(jobHandle, JobObjectExtendedLimitInformationClass, memory, unchecked((uint)size));
}
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();
builder.Append(QuoteArgument(processPath));
for (var i = 0; i < args.Count; i++)
var builder = new StringBuilder(QuoteArgument(processPath));
foreach (var argument in arguments)
{
builder.Append(' ');
builder.Append(QuoteArgument(args[i]));
builder.Append(QuoteArgument(argument));
}
return builder.ToString();
}
private static string QuoteArgument(string argument)
private static string QuoteArgument(string value)
{
if (argument.Length == 0)
if (value.Length == 0)
{
return "\"\"";
}
var needsQuotes = false;
foreach (var c in argument)
if (!value.Any(static c => char.IsWhiteSpace(c) || c == '"'))
{
if (char.IsWhiteSpace(c) || c == '"')
{
needsQuotes = true;
break;
}
return value;
}
if (!needsQuotes)
{
return argument;
}
var builder = new StringBuilder(argument.Length + 2);
var builder = new StringBuilder(value.Length + 2);
builder.Append('"');
var backslashCount = 0;
foreach (var c in argument)
var slashCount = 0;
foreach (var character in value)
{
if (c == '\\')
if (character == '\\')
{
backslashCount++;
slashCount++;
continue;
}
if (c == '"')
if (character == '"')
{
builder.Append('\\', (backslashCount * 2) + 1);
builder.Append('"');
backslashCount = 0;
builder.Append('\\', (slashCount * 2) + 1);
builder.Append(character);
slashCount = 0;
continue;
}
if (backslashCount > 0)
if (slashCount > 0)
{
builder.Append('\\', backslashCount);
backslashCount = 0;
builder.Append('\\', slashCount);
slashCount = 0;
}
builder.Append(c);
builder.Append(character);
}
if (backslashCount > 0)
if (slashCount > 0)
{
builder.Append('\\', backslashCount * 2);
builder.Append('\\', slashCount * 2);
}
builder.Append('"');
return builder.ToString();
}
[StructLayout(LayoutKind.Sequential)]
private struct SECURITY_ATTRIBUTES
private void ThrowIfDisposed()
{
public int nLength;
public nint lpSecurityDescriptor;
public int bInheritHandle;
if (_disposed)
{
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)]
private struct STARTUPINFO
private struct StartupInfo
{
public int cb;
public nint lpReserved;
public nint lpDesktop;
public nint lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public nint lpReserved2;
public nint hStdInput;
public nint hStdOutput;
public nint hStdError;
public int Size;
public nint Reserved;
public nint Desktop;
public nint Title;
public int X;
public int Y;
public int XSize;
public int YSize;
public int XCountChars;
public int YCountChars;
public int FillAttribute;
public int Flags;
public short ShowWindow;
public short Reserved2Count;
public nint Reserved2;
public nint StdInput;
public nint StdOutput;
public nint StdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFOEX
private struct StartupInfoEx
{
public STARTUPINFO StartupInfo;
public nint lpAttributeList;
public StartupInfo StartupInfo;
public nint AttributeList;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
private struct ProcessInformation
{
public nint hProcess;
public nint hThread;
public int dwProcessId;
public int dwThreadId;
public nint Process;
public nint Thread;
public int ProcessId;
public int ThreadId;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
private struct JobObjectBasicLimitInformation
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
@@ -532,7 +576,7 @@ internal sealed class EmulatorProcess : IDisposable
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
private struct IoCounters
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
@@ -543,10 +587,10 @@ internal sealed class EmulatorProcess : IDisposable
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
private struct JobObjectExtendedLimitInformation
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public JobObjectBasicLimitInformation BasicLimitInformation;
public IoCounters IoInfo;
public nuint ProcessMemoryLimit;
public nuint JobMemoryLimit;
public nuint PeakProcessMemoryUsed;
@@ -555,66 +599,37 @@ internal sealed class EmulatorProcess : IDisposable
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreatePipe(
out nint hReadPipe,
out nint hWritePipe,
ref SECURITY_ATTRIBUTES lpPipeAttributes,
uint nSize);
private static extern bool CreatePipe(out nint readPipe, out nint writePipe, ref SecurityAttributes attributes, uint size);
[DllImport("kernel32.dll", SetLastError = true)]
[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)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool InitializeProcThreadAttributeList(
nint lpAttributeList,
int dwAttributeCount,
int dwFlags,
ref nuint lpSize);
private static extern bool InitializeProcThreadAttributeList(nint list, int count, int flags, ref nuint size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UpdateProcThreadAttribute(
nint lpAttributeList,
uint dwFlags,
nint attribute,
nint lpValue,
nuint cbSize,
nint lpPreviousValue,
nint lpReturnSize);
private static extern bool UpdateProcThreadAttribute(nint list, uint flags, nint attribute, nint value, nuint size, nint previousValue, nint returnSize);
[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)]
private static extern nint CreateJobObjectW(nint lpJobAttributes, string? lpName);
private static extern nint CreateJobObjectW(nint attributes, string? name);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(
nint hJob,
int jobObjectInfoClass,
nint lpJobObjectInfo,
uint cbJobObjectInfoLength);
private static extern bool SetInformationJobObject(nint job, int infoClass, nint info, uint size);
[DllImport("kernel32.dll", SetLastError = true)]
[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)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(
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);
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);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
+613
View File
@@ -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);
}
+116
View File
@@ -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();
}
}
}
+72 -4
View File
@@ -5,6 +5,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Window xmlns="https://github.com/avaloniaui"
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"
Title="SharpEmu"
Width="1280" Height="820"
@@ -18,7 +20,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
KeyDown="OnKeyDown">
<Grid RowDefinitions="Auto,*,Auto">
<Grid x:Name="RootLayout" RowDefinitions="Auto,*,Auto">
<!-- Selected-game backdrop: key art behind the main content, dimmed by
a scrim so tiles and text stay readable. Fades on selection. -->
@@ -56,13 +58,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid>
<!-- 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
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
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">
<Border Classes="padHint" VerticalAlignment="Center">
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
@@ -568,7 +576,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border>
<!-- 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">
<Grid ColumnDefinitions="Auto,*,Auto">
@@ -633,6 +641,66 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border>
</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 -->
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
ColumnDefinitions="*,Auto">
+520 -105
View File
@@ -12,8 +12,11 @@ using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Avalonia.VisualTree;
using SharpEmu.Core.Cpu;
using SharpEmu.Core.Runtime;
using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Windows;
using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging;
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
@@ -28,6 +31,8 @@ public partial class MainWindow : Window
{
private const int MaxConsoleLines = 4000;
private const int MaxConsoleLinesPerFlush = 500;
private const double LaunchBlurRadius = 12;
private const double BlurTransitionSeconds = 0.24;
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
@@ -48,14 +53,26 @@ public partial class MainWindow : Window
private readonly List<LogLine> _allConsoleLines = new();
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
private readonly DispatcherTimer _consoleFlushTimer;
private readonly DispatcherTimer _libraryBlurTimer;
private BlurEffect? _libraryBlur;
private double _libraryBlurStartRadius;
private double _libraryBlurTargetRadius;
private long _libraryBlurStartedAt;
private bool _clearLibraryBlurWhenComplete;
private GuiSettings _settings = new();
private EmulatorProcess? _emulator;
private GameSurfaceHost? _gameSurfaceHost;
private ConsoleWindow? _consoleWindow;
private GuiConsoleMirror? _consoleMirror;
private StreamWriter? _fileLog;
private readonly SndPreviewPlayer _sndPreview = new();
private string? _emulatorExePath;
private PendingLaunch? _pendingLaunch;
private bool _gameFullscreen;
private bool _isRunning;
private bool _isStopping;
private bool _awaitingFirstFrame;
private int _autoScrollTicks;
private int _activePageIndex;
private Updater.UpdateInfo? _availableUpdate;
@@ -83,12 +100,21 @@ public partial class MainWindow : Window
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
private string? _latestCommitSha;
private sealed record PendingLaunch(
string EbootPath,
string DisplayName,
string? TitleId,
SharpEmuRuntimeOptions RuntimeOptions);
public MainWindow()
{
InitializeComponent();
GameList.ItemsSource = _visibleGames;
ConsoleList.ItemsSource = _consoleLines;
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
_pendingLines.Enqueue((line, isError)));
Closed += (_, _) => _emulator?.Stop();
_consoleFlushTimer = new DispatcherTimer
{
@@ -101,6 +127,15 @@ public partial class MainWindow : Window
};
_consoleFlushTimer.Start();
_libraryBlurTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(16),
};
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
Activated += (_, _) => UpdateSessionBarVisibility();
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
TitleBar.PointerPressed += OnTitleBarPointerPressed;
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
GameList.DoubleTapped += (_, _) => LaunchSelected();
@@ -111,10 +146,10 @@ public partial class MainWindow : Window
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected();
StopButton.Click += (_, _) => _emulator?.Stop();
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
StopButton.Click += (_, _) => StopEmulator();
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
SessionStopButton.Click += (_, _) => StopEmulator();
SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
LibraryTabButton.Click += (_, _) => SetActivePage(0);
@@ -162,6 +197,7 @@ public partial class MainWindow : Window
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
CtxLaunch.Click += (_, _) => LaunchSelected();
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
CtxCopyPath.Click += async (_, _) =>
@@ -683,14 +719,46 @@ public partial class MainWindow : Window
}
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
{
// While a session is on screen, Enter and Space are game input
// (Cross button). Keyboard focus stays on the launcher window, so a
// previously clicked, still-focused button (console toggle, session
// bar) would also activate and reshape the game view. Swallow the
// keys before button activation; the emulator process reads raw key
// state and is unaffected. Fullscreen hides those buttons, which is
// why this only manifested in windowed sessions.
if (_isRunning && GameView.IsVisible &&
args.Key is Key.Enter or Key.Space)
{
args.Handled = true;
}
}
private void OnWindowFullScreen(object sender, RoutedEventArgs args)
{
if (WindowState == WindowState.FullScreen)
{
WindowState = WindowState.Normal;
// Leaving F11 should restore a monitor-sized window with the
// launcher chrome, not fall back to the design-time window size.
WindowState = WindowState.Maximized;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
TitleBar.IsVisible = true;
StatusBar.IsVisible = true;
if (_gameFullscreen)
{
_gameFullscreen = false;
Grid.SetRow(MainContent, 1);
Grid.SetRowSpan(MainContent, 1);
MainContent.Margin = _isRunning
? new Thickness(0)
: new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = !_isRunning;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
QueueGameSurfaceResize();
UpdateSessionBarVisibility();
}
}
else
{
@@ -698,18 +766,45 @@ public partial class MainWindow : Window
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
TitleBar.IsVisible = false;
StatusBar.IsVisible = false;
if (_isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible)
{
// The native child receives its new physical Bounds as soon
// as this grid spans the monitor. The presenter recreates its
// swapchain from that size, rather than stretching 720p.
_gameFullscreen = true;
// Re-arming restarts the idle countdown, so the cursor also
// hides a moment after F11 even without further mouse motion.
_gameSurfaceHost?.SetCursorAutoHide(true);
Grid.SetRow(MainContent, 0);
Grid.SetRowSpan(MainContent, 3);
MainContent.Margin = new Thickness(0);
ContentToolbar.IsVisible = false;
ConsolePanel.IsVisible = false;
LaunchBar.IsVisible = false;
QueueGameSurfaceResize();
UpdateSessionBarVisibility();
}
}
}
private void QueueGameSurfaceResize()
{
Dispatcher.UIThread.Post(
() => _gameSurfaceHost?.RefreshSurfaceSize(),
DispatcherPriority.Render);
}
private void OnWindowClosing()
{
_settings.Save();
_consoleFlushTimer.Stop();
_libraryBlurTimer.Stop();
_gamepadTimer.Stop();
_sndPreview.Stop();
_discord?.Dispose();
_consoleWindow?.Close();
_emulator?.Dispose();
_consoleMirror?.Dispose();
DropFileLog();
}
@@ -896,8 +991,8 @@ public partial class MainWindow : Window
candidates.Add(_settings.EmulatorPath);
}
// The GUI and the CLI are the same executable: with arguments it runs
// the emulator, so the preferred child process is this process itself.
// The GUI and CLI share one executable. The selected path is the
// isolated child executable and also defines the portable data root.
if (Environment.ProcessPath is { } selfPath &&
Path.GetFileNameWithoutExtension(selfPath).Equals("SharpEmu", StringComparison.OrdinalIgnoreCase))
{
@@ -1576,89 +1671,18 @@ public partial class MainWindow : Window
return;
}
if (_emulatorExePath is null)
{
LocateEmulator();
if (_emulatorExePath is null)
{
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
return;
}
}
_sndPreview.Stop();
var arguments = new List<string>
{
"--cpu-engine=native",
$"--log-level={_settings.LogLevel.ToLowerInvariant()}",
};
if (_settings.StrictDynlibResolution)
{
arguments.Add("--strict");
}
if (_settings.ImportTraceLimit > 0)
{
arguments.Add($"--trace-imports={_settings.ImportTraceLimit}");
}
_consoleLines.Clear();
_allConsoleLines.Clear();
// Let the CLI mirror stdout/stderr itself; it sees loader/native
// diagnostics before the GUI pipe reader can filter or batch them.
DropFileLog();
if (_settings.LogToFile)
{
string filePath;
if (!string.IsNullOrWhiteSpace(_settings.LogFilePath))
{
if (_settings.OverrideLogFile)
{
filePath = _settings.LogFilePath;
}
else
{
string path = _settings.LogFilePath;
string id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId;
foreach (var invalidChar in Path.GetInvalidFileNameChars())
{
id = id.Replace(invalidChar.ToString(), "");
}
string identifier = $"{id}-{DateTime.Now:yyyyMMdd-HHmmss}";
string? dir = Path.GetDirectoryName(path);
string? fileName = Path.GetFileNameWithoutExtension(path);
string? extension = Path.GetExtension(path);
string newFileName = $"{fileName}-{identifier}{extension}";
filePath = string.IsNullOrEmpty(dir)
? newFileName
: Path.Combine(dir, newFileName);
}
}
else
{
filePath = BuildLogFilePath(titleId) ?? string.Empty;
}
if (!string.IsNullOrEmpty(filePath))
{
arguments.Add("--log-file");
arguments.Add(filePath);
AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush);
}
OpenFileLog(titleId);
}
arguments.Add(ebootPath);
AppendConsoleLine(
Localization.Instance.Format("Launch.Command", string.Join(' ', arguments)),
DimLineBrush);
// Apply the enabled switches to this process; both emulator launch paths
// (CreateProcessW and Process.Start) inherit it. Clear switches turned
// off since the previous launch.
// The isolated game child inherits these diagnostics. Keep them on the
// launcher process so every platform receives the same launch options.
foreach (var staleName in _appliedEnvironmentVariables)
{
if (!_settings.EnvironmentToggles.Contains(staleName))
@@ -1674,34 +1698,41 @@ public partial class MainWindow : Window
_appliedEnvironmentVariables.Add(name);
}
var emulator = new EmulatorProcess();
emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError));
emulator.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code));
try
if (SharpEmuLog.TryParseLevel(_settings.LogLevel, out var logLevel))
{
emulator.Start(_emulatorExePath, arguments, Path.GetDirectoryName(ebootPath));
}
catch (Exception ex)
{
emulator.Dispose();
AppendConsoleLine(Localization.Instance.Format("Launch.StartFailed", ex.Message), ErrorLineBrush);
DropFileLog();
return;
SharpEmuLog.MinimumLevel = logLevel;
}
_emulator = emulator;
var runtimeOptions = new SharpEmuRuntimeOptions
{
CpuEngine = CpuExecutionEngine.NativeOnly,
StrictDynlibResolution = _settings.StrictDynlibResolution,
ImportTraceLimit = Math.Max(0, _settings.ImportTraceLimit),
};
_isRunning = true;
_runningGameName = displayName;
_runningGameTitleId = _allGames
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?
.TitleId;
SessionGameTitle.Text = displayName;
_runningGameTitleId = titleId ?? _allGames
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush;
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
UpdateRunButtons();
UpdateDiscordPresence();
ShowGameView();
_pendingLaunch = new PendingLaunch(
Path.GetFullPath(ebootPath),
displayName,
_runningGameTitleId,
runtimeOptions);
if (_gameSurfaceHost?.Surface is { } surface)
{
StartPendingSession(surface);
}
}
/// <summary>
@@ -1713,17 +1744,35 @@ public partial class MainWindow : Window
/// </summary>
private void StopEmulator()
{
if (!_isRunning)
if (!_isRunning || _isStopping)
{
return;
}
_emulator?.Stop();
if (_emulator is null)
{
// The native host can be created a moment after Launch. Do not
// let that delayed callback start a session the user already
// cancelled.
_pendingLaunch = null;
OnEmulatorExited(0);
return;
}
_isStopping = true;
StopButton.IsEnabled = false;
SessionStopButton.IsEnabled = false;
SessionHintText.Text = Localization.Instance.Get("Launch.Stopping");
SessionF11Badge.IsVisible = false;
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
_emulator.Stop();
_runningGameName = null;
_runningGameTitleId = null;
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence();
UpdateSessionBarVisibility();
ReturnToLibraryWhileStopping();
}
/// <summary>
@@ -1734,7 +1783,7 @@ public partial class MainWindow : Window
{
try
{
var exeDirectory = Path.GetDirectoryName(_emulatorExePath);
var exeDirectory = Path.GetDirectoryName(_emulatorExePath) ?? AppContext.BaseDirectory;
if (string.IsNullOrEmpty(exeDirectory))
{
return null;
@@ -1761,8 +1810,12 @@ public partial class MainWindow : Window
{
FlushPendingConsoleLines();
_isRunning = false;
_isStopping = false;
_emulator?.Dispose();
_emulator = null;
_pendingLaunch = null;
DisposeGameSurfaceHost();
HideGameView();
var meaningKey = exitCode switch
{
@@ -1774,13 +1827,20 @@ public partial class MainWindow : Window
-1073741819 => "Exit.EmulationError",
_ => "Exit.Unknown",
};
var stoppedByUser = exitCode == EmulatorProcess.HostStopExitCode;
var meaning = Localization.Instance.Get(meaningKey);
var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush;
AppendConsoleLine(Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning), brush);
var brush = exitCode == 0 || stoppedByUser ? SuccessLineBrush : ErrorLineBrush;
AppendConsoleLine(
stoppedByUser
? "Game closed by the user."
: Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning),
brush);
CloseFileLogSoon();
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
StatusText.Text = Localization.Instance.Format("Launch.Exited", exitCode, meaning);
StatusDot.Fill = exitCode == 0 || stoppedByUser ? (IBrush)SuccessLineBrush : ErrorLineBrush;
StatusText.Text = stoppedByUser
? "Game closed by the user."
: Localization.Instance.Format("Launch.Exited", exitCode, meaning);
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
_runningGameName = null;
_runningGameTitleId = null;
@@ -1788,13 +1848,368 @@ public partial class MainWindow : Window
UpdateDiscordPresence();
}
private void StartPendingSession(VulkanHostSurface surface)
{
if (_pendingLaunch is not { } launch || _emulator is not null)
{
return;
}
if (string.IsNullOrWhiteSpace(_emulatorExePath))
{
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
OnEmulatorExited(3);
return;
}
var process = new EmulatorProcess();
process.OutputReceived += OnEmulatorOutput;
process.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code));
try
{
var arguments = BuildEmulatorArguments(launch, surface);
_emulator = process;
_pendingLaunch = null;
process.Start(
_emulatorExePath,
arguments,
Path.GetDirectoryName(_emulatorExePath));
AppendConsoleLine(
Localization.Instance.Format("Launch.Command", launch.EbootPath),
DimLineBrush);
}
catch (Exception exception)
{
_emulator = null;
process.Dispose();
AppendConsoleLine(
Localization.Instance.Format("Launch.StartFailed", exception.Message),
ErrorLineBrush);
OnEmulatorExited(3);
}
}
private List<string> BuildEmulatorArguments(PendingLaunch launch, VulkanHostSurface surface)
{
var arguments = new List<string>
{
"--cpu-engine=native",
$"--log-level={_settings.LogLevel}",
};
if (launch.RuntimeOptions.StrictDynlibResolution)
{
arguments.Add("--strict");
}
if (launch.RuntimeOptions.ImportTraceLimit > 0)
{
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
}
if (surface.TryGetChildProcessDescriptor(out var descriptor))
{
arguments.Add($"--host-surface={descriptor}");
}
else
{
AppendConsoleLine(
"[GUI][WARN] Embedded child surfaces are unavailable on this platform; opening a game window instead.",
WarningLineBrush);
}
arguments.Add(launch.EbootPath);
return arguments;
}
private void OnEmulatorOutput(string line, bool isError)
{
_pendingLines.Enqueue((line, isError));
if (!line.Contains("[VIDEOOUT][INFO] Hosted splash ready.", StringComparison.Ordinal) &&
!line.Contains("[VIDEOOUT][INFO] Hosted first frame presented.", StringComparison.Ordinal))
{
return;
}
Dispatcher.UIThread.Post(() =>
{
if (_isRunning && !_isStopping)
{
_awaitingFirstFrame = false;
ClearLibraryBlur();
MainContent.Margin = new Thickness(0);
GameView.Background = Brushes.Black;
GameView.IsHitTestVisible = true;
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
LibraryPage.IsVisible = false;
OptionsPage.IsVisible = false;
LibraryToolbar.IsVisible = false;
ContentToolbar.IsVisible = false;
ConsolePanel.IsVisible = false;
LaunchBar.IsVisible = false;
SessionLoadingPopup.IsOpen = false;
UpdateSessionBarVisibility();
}
});
}
private GameSurfaceHost EnsureGameSurfaceHost()
{
if (_gameSurfaceHost is not null)
{
return _gameSurfaceHost;
}
var host = new GameSurfaceHost();
// Configure this before attaching it to Avalonia so its first native
// HWND is hidden while the child process starts.
host.SetPresentationVisible(false);
host.SurfaceAvailable += (_, surface) =>
{
if (ReferenceEquals(_gameSurfaceHost, host))
{
StartPendingSession(surface);
}
};
host.SurfaceDestroyed += (_, surface) => OnGameSurfaceDestroyed(host, surface);
_gameSurfaceHost = host;
GameSurfaceContainer.Children.Add(host);
return host;
}
private void DisposeGameSurfaceHost()
{
var host = _gameSurfaceHost;
if (host is null)
{
return;
}
_gameSurfaceHost = null;
host.SetPresentationVisible(false);
GameSurfaceContainer.Children.Remove(host);
}
private void OnGameSurfaceDestroyed(GameSurfaceHost host, VulkanHostSurface surface)
{
if (ReferenceEquals(_gameSurfaceHost, host) && _isRunning)
{
StopEmulator();
}
}
private void ShowGameView()
{
_isStopping = false;
_awaitingFirstFrame = true;
var host = EnsureGameSurfaceHost();
GameView.IsVisible = true;
GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false;
host.SetPresentationVisible(false);
AnimateLibraryBlur(LaunchBlurRadius);
SessionHintText.Text = "Fullscreen";
SessionF11Badge.IsVisible = true;
UpdateSessionBarVisibility();
ShowSessionLoading("Loading game", "Preparing the emulation session...");
}
private void HideGameView()
{
if (_gameFullscreen && WindowState == WindowState.FullScreen)
{
OnWindowFullScreen(this, new RoutedEventArgs());
}
_gameSurfaceHost?.SetCursorAutoHide(false);
_gameSurfaceHost?.SetPresentationVisible(false);
_awaitingFirstFrame = false;
GameView.IsVisible = false;
GameView.IsHitTestVisible = true;
SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false;
AnimateLibraryBlur(0, clearWhenComplete: true);
MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
LibraryPage.IsVisible = _activePageIndex == 0;
LibraryToolbar.IsVisible = _activePageIndex == 0;
OptionsPage.IsVisible = _activePageIndex == 1;
if (GameList.SelectedItem is GameEntry game && game.Background is not null)
{
BackdropImage.Opacity = 1;
}
}
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
{
_libraryBlur ??= new BlurEffect();
MainContent.Effect = _libraryBlur;
_libraryBlurStartRadius = _libraryBlur.Radius;
_libraryBlurTargetRadius = Math.Max(0, targetRadius);
_libraryBlurStartedAt = Stopwatch.GetTimestamp();
_clearLibraryBlurWhenComplete = clearWhenComplete && _libraryBlurTargetRadius == 0;
if (Math.Abs(_libraryBlurStartRadius - _libraryBlurTargetRadius) < 0.01)
{
CompleteLibraryBlur();
return;
}
_libraryBlurTimer.Start();
}
private void AdvanceLibraryBlur()
{
if (_libraryBlur is null)
{
_libraryBlurTimer.Stop();
return;
}
var elapsed = (Stopwatch.GetTimestamp() - _libraryBlurStartedAt) /
(double)Stopwatch.Frequency;
var progress = Math.Clamp(elapsed / BlurTransitionSeconds, 0, 1);
// Cubic ease-out gives the loading transition a quick response while
// keeping the final change of sharpness unobtrusive.
var easedProgress = 1 - Math.Pow(1 - progress, 3);
_libraryBlur.Radius = _libraryBlurStartRadius +
((_libraryBlurTargetRadius - _libraryBlurStartRadius) * easedProgress);
if (progress >= 1)
{
CompleteLibraryBlur();
}
}
private void CompleteLibraryBlur()
{
_libraryBlurTimer.Stop();
if (_libraryBlur is not null)
{
_libraryBlur.Radius = _libraryBlurTargetRadius;
}
if (_clearLibraryBlurWhenComplete)
{
MainContent.Effect = null;
_libraryBlur = null;
_clearLibraryBlurWhenComplete = false;
}
}
private void ClearLibraryBlur()
{
_libraryBlurTimer.Stop();
_libraryBlur = null;
_clearLibraryBlurWhenComplete = false;
MainContent.Effect = null;
}
private void ShowSessionLoading(string title, string detail)
{
SessionLoadingTitle.Text = title;
SessionLoadingDetail.Text = detail;
SessionLoadingPopup.IsOpen = true;
}
private void ReturnToLibraryWhileStopping()
{
if (_gameFullscreen && WindowState == WindowState.FullScreen)
{
OnWindowFullScreen(this, new RoutedEventArgs());
}
// Keep the native child alive until the session exits, but hide it
// immediately. Destroying it while Vulkan still owns the surface can
// crash the GUI; leaving it transparent lets the library recover
// while the native closing popup reports teardown progress.
_gameSurfaceHost?.SetPresentationVisible(false);
_awaitingFirstFrame = false;
GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false;
SessionBarPopup.IsOpen = false;
AnimateLibraryBlur(LaunchBlurRadius);
MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
LibraryPage.IsVisible = _activePageIndex == 0;
LibraryToolbar.IsVisible = _activePageIndex == 0;
OptionsPage.IsVisible = _activePageIndex == 1;
BackdropImage.Opacity = GameList.SelectedItem is GameEntry { Background: not null } ? 1 : 0;
UpdateRunButtons();
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
}
private void OpenFileLog(string? titleId)
{
var filePath = ResolveLogFilePath(titleId);
if (string.IsNullOrWhiteSpace(filePath))
{
return;
}
try
{
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
_fileLog = new StreamWriter(filePath, append: false) { AutoFlush = true };
AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush);
}
catch (Exception exception)
{
AppendConsoleLine($"[GUI][WARN] Could not open log file: {exception.Message}", WarningLineBrush);
DropFileLog();
}
}
private string? ResolveLogFilePath(string? titleId)
{
if (string.IsNullOrWhiteSpace(_settings.LogFilePath))
{
return BuildLogFilePath(titleId);
}
if (_settings.OverrideLogFile)
{
return _settings.LogFilePath;
}
var path = _settings.LogFilePath;
var id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId;
foreach (var invalid in Path.GetInvalidFileNameChars())
{
id = id.Replace(invalid.ToString(), string.Empty, StringComparison.Ordinal);
}
var directory = Path.GetDirectoryName(path);
var filename = Path.GetFileNameWithoutExtension(path);
var extension = Path.GetExtension(path);
var timestampedName = $"{filename}-{id}-{DateTime.Now:yyyyMMdd-HHmmss}{extension}";
return string.IsNullOrEmpty(directory) ? timestampedName : Path.Combine(directory, timestampedName);
}
private void UpdateRunButtons()
{
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
StopButton.IsEnabled = _isRunning;
StopButton.IsEnabled = _isRunning && !_isStopping;
SessionStopButton.IsEnabled = _isRunning && !_isStopping;
OpenFileButton.IsEnabled = !_isRunning;
}
private void UpdateSessionBarVisibility()
{
SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
!_gameFullscreen && WindowState != WindowState.FullScreen;
}
// ---- Console ----
private void FlushPendingConsoleLines()
+4 -14
View File
@@ -17,6 +17,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- Dependency-free; provides the BuildInfo provenance shown in the
title bar. -->
<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" />
</ItemGroup>
@@ -41,18 +45,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</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>
+109 -2
View File
@@ -69,11 +69,118 @@ public sealed class PosixHostInput : IHostInput
{
// GLFW only delivers key events to the focused window, so a
// delivering keyboard implies focus.
return _source?.HasKeyboardFocus ?? false;
return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused();
}
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,
/// activated by requesting feature report 0x05), with hot-plug retry.
/// </summary>
internal static class WindowsDualSenseReader
public static class WindowsDualSenseReader
{
private const ushort SonyVendorId = 0x054C;
private const ushort DualSenseProductId = 0x0CE6;
@@ -35,7 +35,7 @@ internal static class WindowsDualSenseReader
private static byte _playerLeds = 0x04; // center LED = player 1
/// <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
// 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)
{
@@ -67,7 +67,19 @@ internal sealed partial class WindowsHostInput : IHostInput
}
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) =>
@@ -81,4 +93,9 @@ internal sealed partial class WindowsHostInput : IHostInput
[LibraryImport("user32.dll")]
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
/// retry; the first connected slot (of four) is used.
/// </summary>
internal static partial class WindowsXInputReader
public static partial class WindowsXInputReader
{
private const uint ErrorSuccess = 0;
private const int SlotCount = 4;
@@ -43,7 +43,7 @@ internal static partial class WindowsXInputReader
private static byte _triggerRight;
/// <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
// 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)
{
+72 -1
View File
@@ -10,17 +10,88 @@ namespace SharpEmu.HLE;
public static class HostSessionControl
{
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)
{
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)
{
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
{
Volatile.Read(ref _shutdownHandler)?.Invoke(reason);
handler(reason);
}
catch (Exception exception)
{
@@ -77,7 +77,10 @@ public static class KernelSemaphoreCompatExports
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);
}
@@ -118,7 +121,10 @@ public static class KernelSemaphoreCompatExports
_ = 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);
}
@@ -158,7 +164,10 @@ public static class KernelSemaphoreCompatExports
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;
}
@@ -167,7 +176,10 @@ public static class KernelSemaphoreCompatExports
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;
}
@@ -179,7 +191,10 @@ public static class KernelSemaphoreCompatExports
WakePredicate,
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);
}
@@ -201,9 +216,12 @@ public static class KernelSemaphoreCompatExports
: long.MaxValue;
lock (semaphore.Gate)
{
TraceSemaphore(
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
if (_traceSema)
{
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)
{
var remaining = deadlineMs - Environment.TickCount64;
@@ -219,8 +237,11 @@ public static class KernelSemaphoreCompatExports
semaphore.Count -= needCount;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
TraceSemaphore(
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
if (_traceSema)
{
TraceSemaphore(
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
}
if (timeoutAddress != 0)
{
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
@@ -253,12 +274,18 @@ public static class KernelSemaphoreCompatExports
{
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);
}
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);
}
}
@@ -290,7 +317,10 @@ public static class KernelSemaphoreCompatExports
semaphore.Count += signalCount;
// Wake host-thread waiters parked in the fallback path.
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
@@ -326,7 +356,10 @@ public static class KernelSemaphoreCompatExports
semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount;
semaphore.WaitingThreads = 0;
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));
@@ -346,7 +379,10 @@ public static class KernelSemaphoreCompatExports
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);
}
@@ -385,7 +421,10 @@ public static class KernelSemaphoreCompatExports
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);
}
@@ -582,6 +621,11 @@ public static class KernelSemaphoreCompatExports
private static void TraceSemaphore(string message)
{
if (!_traceSema)
{
return;
}
Console.Error.WriteLine($"[LOADER][TRACE] sema.{message}");
}
+4 -1
View File
@@ -154,7 +154,10 @@ public static class PerfOverlay
_lastGen2 = gen2;
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;
var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
+19 -5
View File
@@ -156,15 +156,29 @@ public static class VideoOutExports
private static void RequestHostShutdown(string reason)
{
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
VulkanVideoPresenter.RequestClose();
var embedded = VulkanVideoHost.IsEmbedded;
AudioOutExports.ShutdownAllPorts();
Interlocked.Exchange(ref _vblankStopRequested, 1);
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);
Environment.Exit(0);
});
VulkanVideoPresenter.RequestClose();
}
// 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
@@ -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
{
private const uint DefaultWindowWidth = 1280;
private const uint DefaultWindowHeight = 720;
// Standalone CLI launches use a desktop-sized surface. The embedded GUI
// 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
// 256 bytes. Using that fixed power of two (instead of racing the render
// 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
// stays tighter than the drain budget because queued draws pin their
// 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;
// A captured 4K flip can consume tens of MiB of device-local memory.
// Retain only a short presentation queue while always preserving the
@@ -140,30 +151,40 @@ internal static unsafe class VulkanVideoPresenter
out var pendingGuestWorkMb) && pendingGuestWorkMb > 0
? pendingGuestWorkMb
: 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
// 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
// window "Not Responding" while starving the swapchain present. Cap the
// wall-clock time spent draining per Render() call; leftover work stays
// 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 =
(long.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_RENDER_WORK_BUDGET_MS"),
out var renderBudgetMs) && renderBudgetMs >= 0
? 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
// GPU fence before skipping the frame and returning to the event pump.
// 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 =
(ulong.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_FRAME_WAIT_BUDGET_MS"),
out var frameWaitMs) && frameWaitMs > 0
? frameWaitMs
: 8UL) * 1_000_000UL;
ulong.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_FRAME_WAIT_BUDGET_MS"),
out var frameWaitMs) && frameWaitMs > 0
? frameWaitMs * 1_000_000UL
: OperatingSystem.IsMacOS() ? 8_000_000UL : ulong.MaxValue;
// Cap the guest-submission fence wait so a GPU submission whose fence never
// signals (a mistranslated compute shader that hangs the Metal queue) cannot
// 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)>
_tracedGuestImageSubmissions = [];
private static Thread? _thread;
private static VulkanHostSurface? _hostSurface;
private static VulkanHostSurface? _hostSurfacePendingDetach;
internal static event Action<VulkanHostSurface>? FirstHostFramePresented;
private static Presentation? _latestPresentation;
private static byte[]? _copyFragmentSpirv;
private static uint _windowWidth;
@@ -463,10 +487,124 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
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();
}
}
/// <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()
{
lock (_gate)
@@ -1192,7 +1330,7 @@ internal static unsafe class VulkanVideoPresenter
GuestImageAddress: address);
_latestPresentation = presentation;
_pendingGuestImagePresentations.Enqueue(presentation);
while (_pendingGuestImagePresentations.Count > MaxPendingGuestWork)
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
{
_pendingGuestImagePresentations.Dequeue();
}
@@ -1588,7 +1726,7 @@ internal static unsafe class VulkanVideoPresenter
private static void StartPresenterLocked()
{
if (HostMainThread.IsAvailable)
if (_hostSurface is null && HostMainThread.IsAvailable)
{
// AppKit (and therefore GLFW) traps when touched off the process
// main thread on macOS, so hand the whole window loop to the
@@ -1756,18 +1894,23 @@ internal static unsafe class VulkanVideoPresenter
{
uint width;
uint height;
VulkanHostSurface? hostSurface;
lock (_gate)
{
width = _windowWidth == 0 ? _latestPresentation?.Width ?? 1280 : _windowWidth;
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
hostSurface = _hostSurface;
}
PreferX11OnLinuxWayland();
InitializeMacVulkanLoader();
if (hostSurface is null)
{
PreferX11OnLinuxWayland();
InitializeMacVulkanLoader();
}
try
{
using var presenter = new Presenter(width, height);
using var presenter = new Presenter(width, height, hostSurface);
presenter.Run();
}
catch (Exception exception)
@@ -1780,6 +1923,12 @@ internal static unsafe class VulkanVideoPresenter
{
_closed = true;
_thread = null;
if (_hostSurfacePendingDetach is not null &&
ReferenceEquals(_hostSurface, _hostSurfacePendingDetach))
{
_hostSurface = null;
_hostSurfacePendingDetach = null;
}
System.Threading.Monitor.PulseAll(_gate);
}
}
@@ -1885,7 +2034,7 @@ internal static unsafe class VulkanVideoPresenter
while (!_enqueueAsImmediateQueueFollowup &&
!_closed &&
_thread is not null &&
(_pendingGuestWorkCount >= MaxPendingGuestWork ||
(_pendingGuestWorkCount >= _maxPendingGuestWorkItems ||
// Always admit one item when no payload is outstanding, even
// when that single item exceeds the configured budget. This
// avoids an impossible wait while still bounding the normal
@@ -1965,6 +2114,9 @@ internal static unsafe class VulkanVideoPresenter
RecordGuestImageWritersLocked(work, sequence);
_pendingGuestWorkCount++;
_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;
}
@@ -2287,7 +2439,10 @@ internal static unsafe class VulkanVideoPresenter
private const string FullscreenBarycentricFragmentSpirv =
"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 Vk _vk = null!;
private KhrSurface _surfaceApi = null!;
@@ -2380,8 +2535,11 @@ internal static unsafe class VulkanVideoPresenter
private long _presentNotTakenLoggedSequence = long.MinValue;
private bool _vulkanReady;
private bool _firstFramePresented;
private bool _firstHostFramePresented;
private bool _firstGuestDrawPresented;
private bool _splashPresented;
private Presentation? _lastHostSplashPresentation;
private Presentation? _pendingHostSplashReplay;
private bool _swapchainRecreateDeferred;
private bool _tracedPresentedSwapchain;
private bool _swapchainReadbackPending;
@@ -2668,11 +2826,22 @@ internal static unsafe class VulkanVideoPresenter
VulkanGuestQueueIdentity Queue,
long WorkSequence);
public Presenter(uint width, uint height)
public Presenter(uint width, uint height, VulkanHostSurface? hostSurface)
{
_hostSurface = hostSurface;
_hostBufferPool = new VulkanHostBufferPool(
MaximumCachedHostBufferBytes,
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;
options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
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()
{
DisposeVulkan();
try
{
_window.Dispose();
_window?.Dispose();
}
catch (InvalidOperationException exception)
when (exception.Message.Contains("render loop", StringComparison.OrdinalIgnoreCase))
@@ -2713,24 +2900,27 @@ internal static unsafe class VulkanVideoPresenter
private void Initialize()
{
LogGlfwPlatformInUse();
if (!OperatingSystem.IsWindows())
if (_window is not null)
{
try
LogGlfwPlatformInUse();
if (!OperatingSystem.IsWindows())
{
Pad.HostWindowInput.Attach(_window.CreateInput());
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
try
{
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))
{
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
_window.SetWindowIcon(ref icon);
if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
{
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
_window.SetWindowIcon(ref icon);
}
}
WaitForRenderDocAttachIfRequested();
@@ -2986,15 +3176,45 @@ internal static unsafe class VulkanVideoPresenter
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* portabilityExtension = null;
var instanceCreateFlags = InstanceCreateFlags.None;
var enabledExtensionCount = (int)extensionCount;
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++)
{
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 &&
@@ -3065,6 +3285,13 @@ internal static unsafe class VulkanVideoPresenter
{
SilkMarshal.Free((nint)portabilityExtension);
}
if (allocatedHostExtensions is not null)
{
foreach (var extension in allocatedHostExtensions)
{
SilkMarshal.Free(extension);
}
}
}
}
finally
@@ -3174,11 +3401,99 @@ internal static unsafe class VulkanVideoPresenter
}
private void CreateSurface()
{
if (_hostSurface is not null)
{
CreateHostSurface(_hostSurface);
return;
}
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);
}
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()
{
uint deviceCount = 0;
@@ -3245,7 +3560,10 @@ internal static unsafe class VulkanVideoPresenter
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})");
VideoOutExports.SetSelectedGpuName(selectedName);
_window.Title = VideoOutExports.GetWindowTitle();
if (_window is not null)
{
_window.Title = VideoOutExports.GetWindowTitle();
}
}
private void LoadComputeDeviceLimits()
@@ -7674,15 +7992,19 @@ internal static unsafe class VulkanVideoPresenter
WaitForActiveGuestQueueSubmissionsForCpuVisibility();
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
}
var mapped = new Span<byte>(
(void*)(allocation.Mapped + checked((nint)guestOffset)),
guestBuffer.Length);
if (_guestMemory?.TryRead(guestBuffer.BaseAddress, mapped) != true)
// Populate the cached shadow copy first and write it out to the
// mapped allocation in one pass. The mapped memory is
// HOST_VISIBLE|HOST_COHERENT (write-combined on most drivers),
// 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() &&
@@ -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 _)
{
try
@@ -11241,10 +11587,30 @@ internal static unsafe class VulkanVideoPresenter
if (Volatile.Read(ref _presenterCloseRequested))
{
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;
}
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)
{
return;
@@ -11287,7 +11653,7 @@ internal static unsafe class VulkanVideoPresenter
var renderWorkDeadline = _renderWorkBudgetTicks > 0
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
: long.MaxValue;
while (completedWork < MaxGuestWorkPerRender)
while (completedWork < _maxGuestWorkPerRender)
{
// Never block the macOS main thread waiting for in-flight GPU
// 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
// completions free up capacity (collected non-blockingly here).
CollectCompletedGuestSubmissions(waitForOldest: false);
if (_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
if (OperatingSystem.IsMacOS() &&
_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
{
break;
}
@@ -11412,6 +11779,13 @@ internal static unsafe class VulkanVideoPresenter
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
// an actual queued presentation is waiting on unfinished guest work.
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
@@ -11425,6 +11799,20 @@ internal static unsafe class VulkanVideoPresenter
}
return;
}
}
if (_hostSurface is not null)
{
if (presentation.IsSplash && presentation.Pixels is not null)
{
_lastHostSplashPresentation = presentation;
}
else
{
_lastHostSplashPresentation = null;
_pendingHostSplashReplay = null;
}
}
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
@@ -11449,6 +11837,13 @@ internal static unsafe class VulkanVideoPresenter
{
pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height
? sourcePixels
: _hostSurface is not null
? ScaleBgraCoverBilinear(
sourcePixels,
presentation.Width,
presentation.Height,
_extent.Width,
_extent.Height)
: ScaleBgra(
sourcePixels,
presentation.Width,
@@ -11715,6 +12110,11 @@ internal static unsafe class VulkanVideoPresenter
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
VideoOutExports.ReportPresentedFrame();
PerfOverlay.RecordPresent();
if (_hostSurface is not null && !_firstHostFramePresented)
{
_firstHostFramePresented = true;
NotifyFirstHostFramePresented(_hostSurface);
}
if (_swapchainReadbackPending || !_pendingAliasImageDumps.IsEmpty)
{
// Diagnostics read back GPU memory and need this frame done.
@@ -13551,12 +13951,35 @@ internal static unsafe class VulkanVideoPresenter
2,
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
{
Element0 = new Offset3D(0, 0, 0),
Element0 = new Offset3D(checked((int)sourceX), checked((int)sourceY), 0),
Element1 = new Offset3D(
checked((int)source.Width),
checked((int)source.Height),
checked((int)(sourceX + sourceWidth)),
checked((int)(sourceY + sourceHeight)),
1),
};
var destinationOffsets = new ImageBlit.DstOffsetsBuffer
@@ -13587,9 +14010,9 @@ internal static unsafe class VulkanVideoPresenter
// must blend neighbours or it silently drops every Nth source
// row/column, which shreds 1-2px features in the guest frame.
var isIntegerUpscale =
source.Width != 0 && source.Height != 0 &&
_extent.Width >= source.Width && _extent.Height >= source.Height &&
_extent.Width % source.Width == 0 && _extent.Height % source.Height == 0;
sourceWidth != 0 && sourceHeight != 0 &&
_extent.Width >= sourceWidth && _extent.Height >= sourceHeight &&
_extent.Width % sourceWidth == 0 && _extent.Height % sourceHeight == 0;
_vk.CmdBlitImage(
_commandBuffer,
source.Image,
@@ -13786,7 +14209,7 @@ internal static unsafe class VulkanVideoPresenter
capabilities.MaxImageExtent.Height));
}
var size = _window.FramebufferSize;
var size = GetFramebufferSize();
return new Extent2D(
ClampSurfaceExtent(
(uint)Math.Max(size.X, 1),
@@ -13800,6 +14223,21 @@ internal static unsafe class VulkanVideoPresenter
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(
uint value,
uint fallback,
@@ -13876,6 +14314,88 @@ internal static unsafe class VulkanVideoPresenter
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()
{
if (!_vulkanReady)
@@ -13994,7 +14514,7 @@ internal static unsafe class VulkanVideoPresenter
_surface,
out var capabilities),
"vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
var framebufferSize = _window.FramebufferSize;
var framebufferSize = GetFramebufferSize();
var hasFixedExtent = capabilities.CurrentExtent.Width != uint.MaxValue;
var surfaceWidth = hasFixedExtent
? capabilities.CurrentExtent.Width