[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", "REUSE.toml",
"nuget.config", "nuget.config",
"global.json", "global.json",
"**/packages.lock.json",
"scripts/ps5_names.txt", "scripts/ps5_names.txt",
"src/SharpEmu.GUI/Languages/**", "src/SharpEmu.GUI/Languages/**",
"_logs/**", "_logs/**",
+72 -5
View File
@@ -69,10 +69,9 @@ internal static partial class Program
} }
args = NormalizeInternalArguments(args, out var isMitigatedChild); args = NormalizeInternalArguments(args, out var isMitigatedChild);
if (args.Length == 0 && !isMitigatedChild)
if (args.Length == 0)
{ {
// No arguments: open the desktop frontend. Any argument selects
// the classic CLI behavior below.
return GuiLauncher.Run(); return GuiLauncher.Run();
} }
@@ -228,7 +227,17 @@ internal static partial class Program
return childExitCode; return childExitCode;
} }
if (!TryParseArguments(args, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath)) if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
{
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
return 1;
}
HostSessionControl.SetEmbeddedHostSurface(
hostSurface?.WindowHandle ?? 0,
hostSurface?.DisplayHandle ?? 0);
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
{ {
PrintUsage(); PrintUsage();
return 1; return 1;
@@ -255,6 +264,14 @@ internal static partial class Program
Console.Error.WriteLine("[DEBUG] Creating runtime..."); Console.Error.WriteLine("[DEBUG] Creating runtime...");
try
{
if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
{
Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
return 3;
}
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions); using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result; OrbisGen2Result result;
@@ -316,6 +333,54 @@ internal static partial class Program
return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4; return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4;
} }
finally
{
HostSessionControl.SetEmbeddedHostSurface(0);
if (hostSurface is not null)
{
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;
}
}
emulatorArgs = remaining.ToArray();
return true;
}
private static void EnsureCliConsole() private static void EnsureCliConsole()
{ {
@@ -411,7 +476,9 @@ internal static partial class Program
return handle != 0 && handle != -1; return handle != 0 && handle != -1;
} }
private static string[] NormalizeInternalArguments(string[] args, out bool isMitigatedChild) private static string[] NormalizeInternalArguments(
string[] args,
out bool isMitigatedChild)
{ {
isMitigatedChild = false; isMitigatedChild = false;
var trustedMitigatedChild = string.Equals( var trustedMitigatedChild = string.Equals(
+1
View File
@@ -54,6 +54,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''"> <PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
<ApplicationIcon>..\..\assets\images\SharpEmu.ico</ApplicationIcon> <ApplicationIcon>..\..\assets\images\SharpEmu.ico</ApplicationIcon>
<Win32Icon>..\..\assets\images\SharpEmu.ico</Win32Icon> <Win32Icon>..\..\assets\images\SharpEmu.ico</Win32Icon>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
+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); return _virtualMemory.TryWrite(address, buffer);
} }
/// <summary>
/// True when the disposed native backend left its session state alive
/// because guest workers were still executing guest code. The guest
/// address space must then stay mapped as well.
/// </summary>
internal bool NativeSessionLeaked { get; private set; }
public void Dispose() public void Dispose()
{ {
if (_nativeCpuBackend is IDisposable disposableBackend) if (_nativeCpuBackend is IDisposable disposableBackend)
@@ -700,6 +707,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
disposableBackend.Dispose(); disposableBackend.Dispose();
} }
NativeSessionLeaked = _nativeCpuBackend is DirectExecutionBackend { GuestSessionLeaked: true };
_nativeCpuBackend = null; _nativeCpuBackend = null;
} }
} }
@@ -718,7 +718,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private string? _guestThreadYieldReason; private string? _guestThreadYieldReason;
private bool _forcedGuestExit; private volatile bool _forcedGuestExit;
private ulong _lastAvTraceRip; private ulong _lastAvTraceRip;
@@ -917,7 +917,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private bool ActiveForcedGuestExit private bool ActiveForcedGuestExit
{ {
get => HasActiveExecutionThread ? _activeForcedGuestExit : _forcedGuestExit; // Host shutdown is requested from a UI or VideoOut thread. Native guest
// workers have their own thread-local execution state, so they must also
// observe the backend-wide shutdown flag.
get => _forcedGuestExit || (HasActiveExecutionThread && _activeForcedGuestExit);
set set
{ {
if (HasActiveExecutionThread) if (HasActiveExecutionThread)
@@ -3409,6 +3412,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{ {
returnValue = 0; returnValue = 0;
error = null; error = null;
if (_forcedGuestExit)
{
error = "guest execution is shutting down";
return false;
}
if (entryPoint < 65536) if (entryPoint < 65536)
{ {
error = $"invalid guest callback entry=0x{entryPoint:X16}"; error = $"invalid guest callback entry=0x{entryPoint:X16}";
@@ -3655,6 +3663,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
out string? error) out string? error)
{ {
error = null; error = null;
if (_forcedGuestExit)
{
error = "guest execution is shutting down";
return false;
}
if (continuation.Rip < 65536 || continuation.Rsp == 0) if (continuation.Rip < 65536 || continuation.Rsp == 0)
{ {
error = $"invalid guest continuation rip=0x{continuation.Rip:X16} rsp=0x{continuation.Rsp:X16}"; error = $"invalid guest continuation rip=0x{continuation.Rip:X16} rsp=0x{continuation.Rsp:X16}";
@@ -4685,6 +4698,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private void RunGuestThread(GuestThreadState thread, string reason) private void RunGuestThread(GuestThreadState thread, string reason)
{ {
if (_forcedGuestExit)
{
// Host shutdown: never enter guest code again. Teardown is about to
// free trampolines and the guest address space, and it only waits
// for executors that are already inside a slice.
lock (_guestThreadGate)
{
thread.State = GuestThreadRunState.Faulted;
thread.BlockReason = "host shutdown";
thread.HostThread = null;
thread.ExecutorActive = false;
}
return;
}
lock (_guestThreadGate) lock (_guestThreadGate)
{ {
if (!thread.ExecutorActive) if (!thread.ExecutorActive)
@@ -5526,8 +5554,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{ {
num6 = CallNativeEntry(ptr); num6 = CallNativeEntry(ptr);
Console.Error.WriteLine($"[LOADER][INFO] Guest returned: {num6}"); Console.Error.WriteLine($"[LOADER][INFO] Guest returned: {num6}");
// 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"); PumpUntilGuestThreadsIdle(context, "entry_return");
} }
}
catch (AccessViolationException ex) catch (AccessViolationException ex)
{ {
Console.Error.WriteLine("[LOADER][ERROR] Access Violation during execution: " + ex.Message); Console.Error.WriteLine("[LOADER][ERROR] Access Violation during execution: " + ex.Message);
@@ -6179,8 +6214,68 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool Win32CloseHandle(nint hObject); private static extern bool Win32CloseHandle(nint hObject);
/// <summary>
/// Set when <see cref="Dispose"/> intentionally left the native session
/// state (import trampolines, TLS, exception handlers) alive because guest
/// worker threads were still executing guest code. The owning runtime must
/// then keep the guest address space mapped as well; freeing either under a
/// running worker faults the whole process, which hosts the GUI launcher.
/// </summary>
internal bool GuestSessionLeaked { get; private set; }
private bool WaitForGuestThreadQuiescence(TimeSpan timeout)
{
var deadline = Stopwatch.GetTimestamp() + (long)(timeout.TotalSeconds * Stopwatch.Frequency);
while (true)
{
var busyCount = 0;
string? busyName = null;
var now = Stopwatch.GetTimestamp();
using (LockGate("WaitForGuestThreadQuiescence"))
{
foreach (var thread in _guestThreads.Values)
{
if (thread.ExecutorActive)
{
busyCount++;
busyName ??= thread.Name;
}
}
}
if (busyCount == 0)
{
return true;
}
if (now >= deadline)
{
Console.Error.WriteLine(
$"[LOADER][WARN] {busyCount} guest worker(s) (first: '{busyName}') did not leave guest code " +
"during shutdown; the native session state stays alive to avoid faulting them.");
return false;
}
Thread.Sleep(5);
}
}
public unsafe void Dispose() public unsafe void Dispose()
{ {
// Guest workers unwind cooperatively at their next import or block
// boundary once the forced-exit flag is set. Everything freed below is
// still reachable from a worker inside guest code, so drain the
// scheduler first and leak the session rather than fault a straggler.
_forcedGuestExit = true;
StopReadyThreadDispatcher();
StopStallWatchdog();
if (!WaitForGuestThreadQuiescence(TimeSpan.FromSeconds(5)))
{
GuestSessionLeaked = true;
return;
}
ClearGuestThreads();
if (ReferenceEquals(_posixSignalBackend, this)) if (ReferenceEquals(_posixSignalBackend, this))
{ {
// The signal handlers stay installed (they chain to the previous // The signal handlers stay installed (they chain to the previous
@@ -189,6 +189,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}"); Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}");
Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}"); Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}");
// Stop is a host operation, not an emulation failure. The detailed
// trace and session-summary builders can traverse a partially torn
// down native backend, delaying the GUI exit callback indefinitely.
if (HostSessionControl.IsShutdownRequested)
{
Console.Error.WriteLine("[RUNTIME] Skipping post-exit diagnostics for host shutdown.");
return result;
}
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace; LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog; LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary); LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
@@ -1152,6 +1162,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
disposableDispatcher.Dispose(); disposableDispatcher.Dispose();
} }
if (_cpuDispatcher is CpuDispatcher { NativeSessionLeaked: true })
{
// A guest worker is still inside guest code; unmapping the guest
// address space under it would fault the whole process, which
// hosts the GUI launcher in embedded sessions.
Console.Error.WriteLine(
"[RUNTIME] Guest workers were still active at teardown; keeping the guest address space mapped.");
return;
}
if (_virtualMemory is IDisposable disposableMemory) if (_virtualMemory is IDisposable disposableMemory)
{ {
disposableMemory.Dispose(); disposableMemory.Dispose();
+17
View File
@@ -29,6 +29,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" /> <SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" /> <SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" /> <SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" /> <SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" /> <SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
</Application.Resources> </Application.Resources>
@@ -55,6 +56,22 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Padding" Value="10,3" /> <Setter Property="Padding" Value="10,3" />
</Style> </Style>
<!-- Session status/hotkey badges: the title-id pill geometry with a
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Padding" Value="8,2" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.badge.running">
<Setter Property="Background" Value="#1E46C46B" />
<Setter Property="BorderBrush" Value="#5546C46B" />
</Style>
<Style Selector="Border.badge.key">
<Setter Property="Background" Value="#1E58A6FF" />
<Setter Property="BorderBrush" Value="#5558A6FF" />
</Style>
<Style Selector="TextBlock.sectionTitle"> <Style Selector="TextBlock.sectionTitle">
<Setter Property="FontSize" Value="11" /> <Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
+308 -293
View File
@@ -1,7 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
@@ -10,32 +9,36 @@ using Microsoft.Win32.SafeHandles;
namespace SharpEmu.GUI; namespace SharpEmu.GUI;
/// <summary> /// <summary>
/// Launches the SharpEmu CLI as a child process with the same CET/CFG mitigation /// Owns an isolated emulator process. Guest virtual memory is fixed-address and
/// opt-outs the CLI would apply to its own relaunched child, while capturing /// cannot be reliably reused while guest-created host threads are still alive,
/// stdout/stderr through pipes. The CLI's internal relaunch is suppressed via /// so the GUI must never execute a game in its own process.
/// SHARPEMU_DISABLE_MITIGATION_RELAUNCH so output is not lost to a detached
/// console. A kill-on-close job object ties the emulator's lifetime to the GUI.
/// </summary> /// </summary>
internal sealed class EmulatorProcess : IDisposable internal sealed class EmulatorProcess : IDisposable
{ {
private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000; public const int HostStopExitCode = -2;
private const uint CREATE_NO_WINDOW = 0x08000000;
private const int STARTF_USESTDHANDLES = 0x00000100; private const uint ExtendedStartupInfoPresent = 0x00080000;
private const uint HANDLE_FLAG_INHERIT = 0x00000001; private const uint CreateNoWindow = 0x08000000;
private const uint INFINITE = 0xFFFFFFFF; private const int StartfUseStdHandles = 0x00000100;
private const int PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY = 0x00020007; private const uint HandleFlagInherit = 0x00000001;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; private const uint Infinite = 0xFFFFFFFF;
private const int JobObjectExtendedLimitInformation = 9; private const int ProcThreadAttributeMitigationPolicy = 0x00020007;
private const ulong PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40; private const uint JobObjectLimitKillOnJobClose = 0x00002000;
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF = 0x00000002UL << 28; private const int JobObjectExtendedLimitInformationClass = 9;
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF = 0x00000002UL << 32; private const string MitigatedChildFlag = "--sharpemu-mitigated-child";
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40; private const string MitigatedChildEnvironment = "SHARPEMU_MITIGATED_CHILD";
private const ulong ControlFlowGuardAlwaysOff = 0x00000002UL << 40;
private const ulong CetUserShadowStacksAlwaysOff = 0x00000002UL << 28;
private const ulong UserCetSetContextIpValidationAlwaysOff = 0x00000002UL << 32;
private static readonly object EnvironmentGate = new();
private readonly object _sync = new(); private readonly object _sync = new();
private nint _processHandle; private nint _processHandle;
private nint _jobHandle; private nint _jobHandle;
private Process? _fallbackProcess; private Process? _fallbackProcess;
private bool _running; private bool _running;
private bool _stopRequested;
private bool _disposed; private bool _disposed;
public event Action<string, bool>? OutputReceived; public event Action<string, bool>? OutputReceived;
@@ -55,29 +58,34 @@ internal sealed class EmulatorProcess : IDisposable
public void Start(string exePath, IReadOnlyList<string> arguments, string? workingDirectory) public void Start(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(exePath);
ArgumentNullException.ThrowIfNull(arguments);
lock (_sync) lock (_sync)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ThrowIfDisposed();
if (_running) if (_running)
{ {
throw new InvalidOperationException("The emulator process is already running."); throw new InvalidOperationException("The emulator process is already running.");
} }
_stopRequested = false;
}
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
{ {
StartWindows(exePath, arguments, workingDirectory); StartWindows(exePath, arguments, workingDirectory);
} return;
else
{
StartFallback(exePath, arguments, workingDirectory);
} }
_running = true; StartFallback(exePath, arguments, workingDirectory);
}
} }
public void Stop() public void Stop()
{ {
nint processHandle;
nint jobHandle;
Process? fallbackProcess;
lock (_sync) lock (_sync)
{ {
if (!_running) if (!_running)
@@ -85,27 +93,34 @@ internal sealed class EmulatorProcess : IDisposable
return; return;
} }
// Prefer terminating the job: it kills the whole tree, including _stopRequested = true;
// any children the emulator spawned, even when the main process processHandle = _processHandle;
// is wedged in a GPU driver call. jobHandle = _jobHandle;
if (_jobHandle != 0) fallbackProcess = _fallbackProcess;
{
_ = TerminateJobObject(_jobHandle, 1);
} }
if (_processHandle != 0) if (jobHandle != 0)
{ {
_ = TerminateProcess(_processHandle, 1); _ = TerminateJobObject(jobHandle, unchecked((uint)HostStopExitCode));
return;
}
if (processHandle != 0)
{
_ = TerminateProcess(processHandle, unchecked((uint)HostStopExitCode));
return;
} }
try try
{ {
_fallbackProcess?.Kill(entireProcessTree: true); if (fallbackProcess is { HasExited: false })
{
fallbackProcess.Kill(entireProcessTree: true);
}
} }
catch (InvalidOperationException) catch (InvalidOperationException)
{ {
// Already exited. // The process exited while Stop was handling the request.
}
} }
} }
@@ -124,121 +139,206 @@ internal sealed class EmulatorProcess : IDisposable
Stop(); Stop();
} }
private void StartWindows(string exePath, IReadOnlyList<string> arguments, string? workingDirectory) private void StartFallback(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
{ {
// The CLI would otherwise relaunch itself into a mitigated child whose var startInfo = new ProcessStartInfo(exePath)
// console output cannot flow through our pipes.
Environment.SetEnvironmentVariable("SHARPEMU_DISABLE_MITIGATION_RELAUNCH", "1");
var securityAttributes = new SECURITY_ATTRIBUTES
{ {
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(), WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory)
bInheritHandle = 1, ? Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory
: workingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
}; };
foreach (var argument in arguments)
if (!CreatePipe(out var stdoutRead, out var stdoutWrite, ref securityAttributes, 0) ||
!CreatePipe(out var stderrRead, out var stderrWrite, ref securityAttributes, 0))
{ {
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create output pipes."); startInfo.ArgumentList.Add(argument);
} }
_ = SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0); var process = Process.Start(startInfo)
_ = SetHandleInformation(stderrRead, HANDLE_FLAG_INHERIT, 0); ?? throw new InvalidOperationException("Could not start the emulator process.");
process.EnableRaisingEvents = true;
process.OutputDataReceived += (_, eventArgs) => ForwardOutput(eventArgs.Data, isError: false);
process.ErrorDataReceived += (_, eventArgs) => ForwardOutput(eventArgs.Data, isError: true);
process.Exited += (_, _) => OnExited(process.ExitCode);
var startupInfoEx = new STARTUPINFOEX(); lock (_sync)
startupInfoEx.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>(); {
startupInfoEx.StartupInfo.dwFlags = STARTF_USESTDHANDLES; _fallbackProcess = process;
startupInfoEx.StartupInfo.hStdOutput = stdoutWrite; _running = true;
startupInfoEx.StartupInfo.hStdError = stderrWrite; }
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
private unsafe void StartWindows(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
{
nint stdoutRead = 0;
nint stdoutWrite = 0;
nint stderrRead = 0;
nint stderrWrite = 0;
nint attributeList = 0; nint attributeList = 0;
nint mitigationPolicies = 0; nint mitigationPolicies = 0;
nint processHandle = 0;
nint threadHandle = 0;
try try
{ {
var security = new SecurityAttributes
{
Size = Marshal.SizeOf<SecurityAttributes>(),
InheritHandle = 1,
};
if (!CreatePipe(out stdoutRead, out stdoutWrite, ref security, 0) ||
!CreatePipe(out stderrRead, out stderrWrite, ref security, 0))
{
throw new InvalidOperationException($"Could not create emulator output pipes (Win32 error {Marshal.GetLastWin32Error()}).");
}
if (!SetHandleInformation(stdoutRead, HandleFlagInherit, 0) ||
!SetHandleInformation(stderrRead, HandleFlagInherit, 0))
{
throw new InvalidOperationException($"Could not configure emulator output pipes (Win32 error {Marshal.GetLastWin32Error()}).");
}
nuint attributeListSize = 0; nuint attributeListSize = 0;
_ = InitializeProcThreadAttributeList(0, 1, 0, ref attributeListSize); _ = InitializeProcThreadAttributeList(0, 1, 0, ref attributeListSize);
attributeList = Marshal.AllocHGlobal((nint)attributeListSize); attributeList = Marshal.AllocHGlobal((nint)attributeListSize);
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize)) if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize))
{ {
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize the process attribute list."); throw new InvalidOperationException($"Could not initialize process mitigation attributes (Win32 error {Marshal.GetLastWin32Error()}).");
} }
startupInfoEx.lpAttributeList = attributeList;
var policy1 = PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF;
var policy2 =
PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF |
PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF;
mitigationPolicies = Marshal.AllocHGlobal(sizeof(ulong) * 2); mitigationPolicies = Marshal.AllocHGlobal(sizeof(ulong) * 2);
Marshal.WriteInt64(mitigationPolicies, unchecked((long)policy1)); Marshal.WriteInt64(mitigationPolicies, unchecked((long)ControlFlowGuardAlwaysOff));
Marshal.WriteInt64(nint.Add(mitigationPolicies, sizeof(long)), unchecked((long)policy2)); Marshal.WriteInt64(
nint.Add(mitigationPolicies, sizeof(long)),
unchecked((long)(CetUserShadowStacksAlwaysOff | UserCetSetContextIpValidationAlwaysOff)));
if (!UpdateProcThreadAttribute( if (!UpdateProcThreadAttribute(
attributeList, attributeList,
0, 0,
PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, (nint)ProcThreadAttributeMitigationPolicy,
mitigationPolicies, mitigationPolicies,
(nuint)(sizeof(ulong) * 2), (nuint)(sizeof(ulong) * 2),
0, 0,
0)) 0))
{ {
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to apply the mitigation policy."); throw new InvalidOperationException($"Could not apply process mitigations (Win32 error {Marshal.GetLastWin32Error()}).");
} }
var currentDirectory = workingDirectory ?? Environment.CurrentDirectory; var startup = new StartupInfoEx();
var created = CreateProcessW( startup.StartupInfo.Size = Marshal.SizeOf<StartupInfoEx>();
startup.StartupInfo.Flags = StartfUseStdHandles;
startup.StartupInfo.StdOutput = stdoutWrite;
startup.StartupInfo.StdError = stderrWrite;
startup.AttributeList = attributeList;
var childArguments = new List<string>(arguments.Count + 1) { MitigatedChildFlag };
childArguments.AddRange(arguments);
var commandLine = new StringBuilder(BuildCommandLine(exePath, childArguments));
ProcessInformation processInfo;
lock (EnvironmentGate)
{
var previousValue = Environment.GetEnvironmentVariable(MitigatedChildEnvironment);
try
{
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
if (!CreateProcessW(
exePath, exePath,
new StringBuilder(BuildCommandLine(exePath, arguments)), commandLine,
0, 0,
0, 0,
true, true,
EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW, ExtendedStartupInfoPresent | CreateNoWindow,
0, 0,
currentDirectory, string.IsNullOrWhiteSpace(workingDirectory)
ref startupInfoEx, ? Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory
out var processInfo); : workingDirectory,
ref startup,
if (!created) out processInfo))
{ {
var error = Marshal.GetLastWin32Error(); throw new InvalidOperationException($"Could not start the emulator process (Win32 error {Marshal.GetLastWin32Error()}).");
throw new Win32Exception(error, $"Failed to start '{exePath}' with CET/CFG mitigation disabled (Win32 error {error}: {new Win32Exception(error).Message})."); }
}
finally
{
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, previousValue);
}
} }
CloseHandle(processInfo.hThread); processHandle = processInfo.Process;
_processHandle = processInfo.hProcess; threadHandle = processInfo.Thread;
CloseHandle(stdoutWrite);
stdoutWrite = 0;
CloseHandle(stderrWrite);
stderrWrite = 0;
_jobHandle = CreateJobObjectW(0, null); var jobHandle = CreateJobObjectW(0, null);
if (_jobHandle != 0 && if (jobHandle != 0 &&
(!TryEnableKillOnJobClose(_jobHandle) || !AssignProcessToJobObject(_jobHandle, processInfo.hProcess))) (!TryEnableKillOnJobClose(jobHandle) || !AssignProcessToJobObject(jobHandle, processHandle)))
{ {
CloseHandle(_jobHandle); CloseHandle(jobHandle);
_jobHandle = 0; jobHandle = 0;
} }
StartReaderThread(stdoutRead, isError: false); lock (_sync)
StartReaderThread(stderrRead, isError: true); {
StartExitWatcherThread(); _processHandle = processHandle;
_jobHandle = jobHandle;
_running = true;
}
processHandle = 0;
StartPipeReader(stdoutRead, isError: false);
stdoutRead = 0;
StartPipeReader(stderrRead, isError: true);
stderrRead = 0;
StartWindowsExitWatcher(_processHandle);
} }
catch catch
{ {
CloseHandle(stdoutRead); if (processHandle != 0)
CloseHandle(stderrRead); {
_ = TerminateProcess(processHandle, 1);
}
throw; throw;
} }
finally finally
{ {
// The child owns duplicated pipe write ends; closing ours lets the if (threadHandle != 0)
// readers observe EOF when the child exits. {
CloseHandle(threadHandle);
}
if (processHandle != 0)
{
CloseHandle(processHandle);
}
if (stdoutRead != 0)
{
CloseHandle(stdoutRead);
}
if (stdoutWrite != 0)
{
CloseHandle(stdoutWrite); CloseHandle(stdoutWrite);
}
if (stderrRead != 0)
{
CloseHandle(stderrRead);
}
if (stderrWrite != 0)
{
CloseHandle(stderrWrite); CloseHandle(stderrWrite);
}
if (attributeList != 0) if (attributeList != 0)
{ {
DeleteProcThreadAttributeList(attributeList); DeleteProcThreadAttributeList(attributeList);
Marshal.FreeHGlobal(attributeList); Marshal.FreeHGlobal(attributeList);
} }
if (mitigationPolicies != 0) if (mitigationPolicies != 0)
{ {
Marshal.FreeHGlobal(mitigationPolicies); Marshal.FreeHGlobal(mitigationPolicies);
@@ -246,103 +346,56 @@ internal sealed class EmulatorProcess : IDisposable
} }
} }
private void StartFallback(string exePath, IReadOnlyList<string> arguments, string? workingDirectory) private void StartPipeReader(nint handle, bool isError)
{ {
var startInfo = new ProcessStartInfo var readerThread = new Thread(() =>
{
FileName = exePath,
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)
{
OutputReceived?.Invoke(e.Data, false);
}
};
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
{ {
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) while (reader.ReadLine() is { } line)
{ {
OutputReceived?.Invoke(line, isError); ForwardOutput(line, isError);
} }
} }, 256 * 1024)
catch (IOException)
{
// Pipe broken on process teardown.
}
})
{ {
IsBackground = true, IsBackground = true,
Name = isError ? "SharpEmu stderr reader" : "SharpEmu stdout reader", Name = isError ? "SharpEmu stderr reader" : "SharpEmu stdout reader",
}; };
thread.Start(); readerThread.Start();
} }
private void StartExitWatcherThread() private void StartWindowsExitWatcher(nint processHandle)
{ {
var processHandle = _processHandle; var watcher = new Thread(() =>
var thread = new Thread(() =>
{ {
_ = WaitForSingleObject(processHandle, INFINITE); _ = WaitForSingleObject(processHandle, Infinite);
var exitCode = GetExitCodeProcess(processHandle, out var rawExitCode) var exitCode = 1;
? unchecked((int)rawExitCode) if (GetExitCodeProcess(processHandle, out var nativeExitCode))
: -1; {
exitCode = unchecked((int)nativeExitCode);
}
OnExited(exitCode); OnExited(exitCode);
}) }, 128 * 1024)
{ {
IsBackground = true, IsBackground = true,
Name = "SharpEmu exit watcher", Name = "SharpEmu exit watcher",
}; };
thread.Start(); watcher.Start();
} }
private void OnExited(int exitCode) private void ForwardOutput(string? line, bool isError)
{ {
if (!string.IsNullOrEmpty(line))
{
OutputReceived?.Invoke(line, isError);
}
}
private void OnExited(int nativeExitCode)
{
int exitCode;
lock (_sync) lock (_sync)
{ {
if (!_running) if (!_running)
@@ -350,13 +403,13 @@ internal sealed class EmulatorProcess : IDisposable
return; return;
} }
exitCode = _stopRequested ? HostStopExitCode : nativeExitCode;
_running = false; _running = false;
if (_processHandle != 0) if (_processHandle != 0)
{ {
CloseHandle(_processHandle); CloseHandle(_processHandle);
_processHandle = 0; _processHandle = 0;
} }
if (_jobHandle != 0) if (_jobHandle != 0)
{ {
CloseHandle(_jobHandle); CloseHandle(_jobHandle);
@@ -372,24 +425,19 @@ internal sealed class EmulatorProcess : IDisposable
private static bool TryEnableKillOnJobClose(nint jobHandle) private static bool TryEnableKillOnJobClose(nint jobHandle)
{ {
var extendedLimitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION var info = new JobObjectExtendedLimitInformation
{ {
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation = new JobObjectBasicLimitInformation
{ {
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LimitFlags = JobObjectLimitKillOnJobClose,
}, },
}; };
var size = Marshal.SizeOf<JobObjectExtendedLimitInformation>();
var size = Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
var memory = Marshal.AllocHGlobal(size); var memory = Marshal.AllocHGlobal(size);
try try
{ {
Marshal.StructureToPtr(extendedLimitInfo, memory, false); Marshal.StructureToPtr(info, memory, false);
return SetInformationJobObject( return SetInformationJobObject(jobHandle, JobObjectExtendedLimitInformationClass, memory, unchecked((uint)size));
jobHandle,
JobObjectExtendedLimitInformation,
memory,
unchecked((uint)size));
} }
finally finally
{ {
@@ -397,128 +445,124 @@ internal sealed class EmulatorProcess : IDisposable
} }
} }
private static string BuildCommandLine(string processPath, IReadOnlyList<string> args) private static string BuildCommandLine(string processPath, IReadOnlyList<string> arguments)
{ {
var builder = new StringBuilder(); var builder = new StringBuilder(QuoteArgument(processPath));
builder.Append(QuoteArgument(processPath)); foreach (var argument in arguments)
for (var i = 0; i < args.Count; i++)
{ {
builder.Append(' '); builder.Append(' ');
builder.Append(QuoteArgument(args[i])); builder.Append(QuoteArgument(argument));
} }
return builder.ToString(); return builder.ToString();
} }
private static string QuoteArgument(string argument) private static string QuoteArgument(string value)
{ {
if (argument.Length == 0) if (value.Length == 0)
{ {
return "\"\""; return "\"\"";
} }
var needsQuotes = false; if (!value.Any(static c => char.IsWhiteSpace(c) || c == '"'))
foreach (var c in argument)
{ {
if (char.IsWhiteSpace(c) || c == '"') return value;
{
needsQuotes = true;
break;
}
} }
if (!needsQuotes) var builder = new StringBuilder(value.Length + 2);
{
return argument;
}
var builder = new StringBuilder(argument.Length + 2);
builder.Append('"'); builder.Append('"');
var slashCount = 0;
var backslashCount = 0; foreach (var character in value)
foreach (var c in argument)
{ {
if (c == '\\') if (character == '\\')
{ {
backslashCount++; slashCount++;
continue; continue;
} }
if (c == '"') if (character == '"')
{ {
builder.Append('\\', (backslashCount * 2) + 1); builder.Append('\\', (slashCount * 2) + 1);
builder.Append('"'); builder.Append(character);
backslashCount = 0; slashCount = 0;
continue; continue;
} }
if (backslashCount > 0) if (slashCount > 0)
{ {
builder.Append('\\', backslashCount); builder.Append('\\', slashCount);
backslashCount = 0; slashCount = 0;
} }
builder.Append(c); builder.Append(character);
} }
if (backslashCount > 0) if (slashCount > 0)
{ {
builder.Append('\\', backslashCount * 2); builder.Append('\\', slashCount * 2);
} }
builder.Append('"'); builder.Append('"');
return builder.ToString(); return builder.ToString();
} }
[StructLayout(LayoutKind.Sequential)] private void ThrowIfDisposed()
private struct SECURITY_ATTRIBUTES
{ {
public int nLength; if (_disposed)
public nint lpSecurityDescriptor; {
public int bInheritHandle; throw new ObjectDisposedException(nameof(EmulatorProcess));
}
}
[StructLayout(LayoutKind.Sequential)]
private struct SecurityAttributes
{
public int Size;
public nint SecurityDescriptor;
public int InheritHandle;
} }
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct STARTUPINFO private struct StartupInfo
{ {
public int cb; public int Size;
public nint lpReserved; public nint Reserved;
public nint lpDesktop; public nint Desktop;
public nint lpTitle; public nint Title;
public int dwX; public int X;
public int dwY; public int Y;
public int dwXSize; public int XSize;
public int dwYSize; public int YSize;
public int dwXCountChars; public int XCountChars;
public int dwYCountChars; public int YCountChars;
public int dwFillAttribute; public int FillAttribute;
public int dwFlags; public int Flags;
public short wShowWindow; public short ShowWindow;
public short cbReserved2; public short Reserved2Count;
public nint lpReserved2; public nint Reserved2;
public nint hStdInput; public nint StdInput;
public nint hStdOutput; public nint StdOutput;
public nint hStdError; public nint StdError;
} }
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFOEX private struct StartupInfoEx
{ {
public STARTUPINFO StartupInfo; public StartupInfo StartupInfo;
public nint lpAttributeList; public nint AttributeList;
} }
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION private struct ProcessInformation
{ {
public nint hProcess; public nint Process;
public nint hThread; public nint Thread;
public int dwProcessId; public int ProcessId;
public int dwThreadId; public int ThreadId;
} }
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION private struct JobObjectBasicLimitInformation
{ {
public long PerProcessUserTimeLimit; public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit; public long PerJobUserTimeLimit;
@@ -532,7 +576,7 @@ internal sealed class EmulatorProcess : IDisposable
} }
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS private struct IoCounters
{ {
public ulong ReadOperationCount; public ulong ReadOperationCount;
public ulong WriteOperationCount; public ulong WriteOperationCount;
@@ -543,10 +587,10 @@ internal sealed class EmulatorProcess : IDisposable
} }
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION private struct JobObjectExtendedLimitInformation
{ {
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; public JobObjectBasicLimitInformation BasicLimitInformation;
public IO_COUNTERS IoInfo; public IoCounters IoInfo;
public nuint ProcessMemoryLimit; public nuint ProcessMemoryLimit;
public nuint JobMemoryLimit; public nuint JobMemoryLimit;
public nuint PeakProcessMemoryUsed; public nuint PeakProcessMemoryUsed;
@@ -555,66 +599,37 @@ internal sealed class EmulatorProcess : IDisposable
[DllImport("kernel32.dll", SetLastError = true)] [DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreatePipe( private static extern bool CreatePipe(out nint readPipe, out nint writePipe, ref SecurityAttributes attributes, uint size);
out nint hReadPipe,
out nint hWritePipe,
ref SECURITY_ATTRIBUTES lpPipeAttributes,
uint nSize);
[DllImport("kernel32.dll", SetLastError = true)] [DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetHandleInformation(nint hObject, uint dwMask, uint dwFlags); private static extern bool SetHandleInformation(nint handle, uint mask, uint flags);
[DllImport("kernel32.dll", SetLastError = true)] [DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool InitializeProcThreadAttributeList( private static extern bool InitializeProcThreadAttributeList(nint list, int count, int flags, ref nuint size);
nint lpAttributeList,
int dwAttributeCount,
int dwFlags,
ref nuint lpSize);
[DllImport("kernel32.dll", SetLastError = true)] [DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UpdateProcThreadAttribute( private static extern bool UpdateProcThreadAttribute(nint list, uint flags, nint attribute, nint value, nuint size, nint previousValue, nint returnSize);
nint lpAttributeList,
uint dwFlags,
nint attribute,
nint lpValue,
nuint cbSize,
nint lpPreviousValue,
nint lpReturnSize);
[DllImport("kernel32.dll")] [DllImport("kernel32.dll")]
private static extern void DeleteProcThreadAttributeList(nint lpAttributeList); private static extern void DeleteProcThreadAttributeList(nint list);
[DllImport("kernel32.dll", EntryPoint = "CreateJobObjectW", SetLastError = true, CharSet = CharSet.Unicode)] [DllImport("kernel32.dll", EntryPoint = "CreateJobObjectW", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern nint CreateJobObjectW(nint lpJobAttributes, string? lpName); private static extern nint CreateJobObjectW(nint attributes, string? name);
[DllImport("kernel32.dll", SetLastError = true)] [DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject( private static extern bool SetInformationJobObject(nint job, int infoClass, nint info, uint size);
nint hJob,
int jobObjectInfoClass,
nint lpJobObjectInfo,
uint cbJobObjectInfoLength);
[DllImport("kernel32.dll", SetLastError = true)] [DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(nint hJob, nint hProcess); private static extern bool AssignProcessToJobObject(nint job, nint process);
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)] [DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW( private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
string applicationName,
StringBuilder commandLine,
nint processAttributes,
nint threadAttributes,
[MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
uint creationFlags,
nint environment,
string currentDirectory,
ref STARTUPINFOEX startupInfo,
out PROCESS_INFORMATION processInformation);
[DllImport("kernel32.dll", SetLastError = true)] [DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(nint handle, uint milliseconds); private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
+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" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI"
xmlns:primitives="clr-namespace:Avalonia.Controls.Primitives;assembly=Avalonia.Controls"
x:Class="SharpEmu.GUI.MainWindow" x:Class="SharpEmu.GUI.MainWindow"
Title="SharpEmu" Title="SharpEmu"
Width="1280" Height="820" Width="1280" Height="820"
@@ -18,7 +20,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
KeyDown="OnKeyDown"> KeyDown="OnKeyDown">
<Grid RowDefinitions="Auto,*,Auto"> <Grid x:Name="RootLayout" RowDefinitions="Auto,*,Auto">
<!-- Selected-game backdrop: key art behind the main content, dimmed by <!-- Selected-game backdrop: key art behind the main content, dimmed by
a scrim so tiles and text stay readable. Fades on selection. --> a scrim so tiles and text stay readable. Fades on selection. -->
@@ -56,13 +58,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
<!-- Main content --> <!-- Main content -->
<Grid Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto"> <Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
<!-- The game owns the full client area while running. Session controls
use a native popup so they can stay above this native child surface. -->
<Border x:Name="GameView" Grid.Row="0" Grid.RowSpan="4" IsVisible="False" Background="#000000" ClipToBounds="True">
<Grid x:Name="GameSurfaceContainer" />
</Border>
<!-- Library / Options page switcher, with the library toolbar sharing <!-- Library / Options page switcher, with the library toolbar sharing
the same row on the right. Plain buttons (not TabItem) so there is the same row on the right. Plain buttons (not TabItem) so there is
no underline; LB/RB hint chips flank the pair and the gamepad's no underline; LB/RB hint chips flank the pair and the gamepad's
shoulder buttons actually switch pages from anywhere. --> shoulder buttons actually switch pages from anywhere. -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20"> <Grid x:Name="ContentToolbar" Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center"> <StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
<Border Classes="padHint" VerticalAlignment="Center"> <Border Classes="padHint" VerticalAlignment="Center">
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" /> <TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
@@ -568,7 +576,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border> </Border>
<!-- Launch bar; capped so it does not sprawl on a maximized window. --> <!-- Launch bar; capped so it does not sprawl on a maximized window. -->
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280"> <Border Grid.Row="3" x:Name="LaunchBar" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
@@ -633,6 +641,66 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border> </Border>
</Grid> </Grid>
<!-- Avalonia's regular overlay layer cannot appear over a native child
HWND/X11/Metal surface. Keep the running-session controls in a native
popup so the game reaches the bottom status bar without losing Stop. -->
<primitives:Popup x:Name="SessionBarPopup"
IsOpen="False"
PlacementTarget="{Binding #GameView}"
Placement="Bottom"
VerticalOffset="-66"
Topmost="True"
ShouldUseOverlayLayer="False"
TakesFocusFromNativeControl="False"
IsLightDismissEnabled="False">
<Border Classes="card" Width="598" Height="58" CornerRadius="16" Padding="14,8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock x:Name="SessionGameTitle" Text="GAME RUNNING" FontSize="13" FontWeight="SemiBold"
MaxWidth="240" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Classes="badge running" VerticalAlignment="Center">
<TextBlock Text="RUNNING" FontSize="9" FontWeight="Bold" LetterSpacing="1"
Foreground="{StaticResource SuccessBrush}" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="7">
<Border x:Name="SessionF11Badge" Classes="badge key" VerticalAlignment="Center">
<TextBlock Text="F11" FontSize="9" FontWeight="Bold"
Foreground="{StaticResource InfoBrush}" />
</Border>
<TextBlock x:Name="SessionHintText" Text="Fullscreen" FontSize="11"
Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button x:Name="SessionConsoleButton" Classes="ghost" Content="≡ Console" />
<Button x:Name="SessionStopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
</StackPanel>
</Grid>
</Border>
</primitives:Popup>
<!-- This is a native popup rather than an Avalonia overlay because the
emulated Vulkan surface is a native child window. -->
<primitives:Popup x:Name="SessionLoadingPopup"
IsOpen="False"
PlacementTarget="{Binding #GameView}"
Placement="Center"
Topmost="True"
ShouldUseOverlayLayer="False"
TakesFocusFromNativeControl="False"
IsLightDismissEnabled="False">
<Border Classes="card" Width="380" CornerRadius="18" Padding="22,18">
<StackPanel Spacing="12">
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<ProgressBar IsIndeterminate="True" Height="5" />
</StackPanel>
</Border>
</primitives:Popup>
<!-- Status bar --> <!-- Status bar -->
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}" <Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
ColumnDefinitions="*,Auto"> ColumnDefinitions="*,Auto">
+520 -105
View File
@@ -12,8 +12,11 @@ using Avalonia.Platform;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using SharpEmu.Core.Cpu;
using SharpEmu.Core.Runtime;
using SharpEmu.HLE.Host; using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Windows; using SharpEmu.HLE.Host.Windows;
using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging; using SharpEmu.Logging;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
@@ -28,6 +31,8 @@ public partial class MainWindow : Window
{ {
private const int MaxConsoleLines = 4000; private const int MaxConsoleLines = 4000;
private const int MaxConsoleLinesPerFlush = 500; private const int MaxConsoleLinesPerFlush = 500;
private const double LaunchBlurRadius = 12;
private const double BlurTransitionSeconds = 0.24;
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE")); private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488")); private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
@@ -48,14 +53,26 @@ public partial class MainWindow : Window
private readonly List<LogLine> _allConsoleLines = new(); private readonly List<LogLine> _allConsoleLines = new();
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new(); private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
private readonly DispatcherTimer _consoleFlushTimer; private readonly DispatcherTimer _consoleFlushTimer;
private readonly DispatcherTimer _libraryBlurTimer;
private BlurEffect? _libraryBlur;
private double _libraryBlurStartRadius;
private double _libraryBlurTargetRadius;
private long _libraryBlurStartedAt;
private bool _clearLibraryBlurWhenComplete;
private GuiSettings _settings = new(); private GuiSettings _settings = new();
private EmulatorProcess? _emulator; private EmulatorProcess? _emulator;
private GameSurfaceHost? _gameSurfaceHost;
private ConsoleWindow? _consoleWindow; private ConsoleWindow? _consoleWindow;
private GuiConsoleMirror? _consoleMirror;
private StreamWriter? _fileLog; private StreamWriter? _fileLog;
private readonly SndPreviewPlayer _sndPreview = new(); private readonly SndPreviewPlayer _sndPreview = new();
private string? _emulatorExePath; private string? _emulatorExePath;
private PendingLaunch? _pendingLaunch;
private bool _gameFullscreen;
private bool _isRunning; private bool _isRunning;
private bool _isStopping;
private bool _awaitingFirstFrame;
private int _autoScrollTicks; private int _autoScrollTicks;
private int _activePageIndex; private int _activePageIndex;
private Updater.UpdateInfo? _availableUpdate; private Updater.UpdateInfo? _availableUpdate;
@@ -83,12 +100,21 @@ public partial class MainWindow : Window
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient(); private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
private string? _latestCommitSha; private string? _latestCommitSha;
private sealed record PendingLaunch(
string EbootPath,
string DisplayName,
string? TitleId,
SharpEmuRuntimeOptions RuntimeOptions);
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
GameList.ItemsSource = _visibleGames; GameList.ItemsSource = _visibleGames;
ConsoleList.ItemsSource = _consoleLines; ConsoleList.ItemsSource = _consoleLines;
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
_pendingLines.Enqueue((line, isError)));
Closed += (_, _) => _emulator?.Stop();
_consoleFlushTimer = new DispatcherTimer _consoleFlushTimer = new DispatcherTimer
{ {
@@ -101,6 +127,15 @@ public partial class MainWindow : Window
}; };
_consoleFlushTimer.Start(); _consoleFlushTimer.Start();
_libraryBlurTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(16),
};
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
Activated += (_, _) => UpdateSessionBarVisibility();
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
TitleBar.PointerPressed += OnTitleBarPointerPressed; TitleBar.PointerPressed += OnTitleBarPointerPressed;
GameList.SelectionChanged += (_, _) => UpdateSelectedGame(); GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
GameList.DoubleTapped += (_, _) => LaunchSelected(); GameList.DoubleTapped += (_, _) => LaunchSelected();
@@ -111,10 +146,10 @@ public partial class MainWindow : Window
RescanButton.Click += async (_, _) => await RescanLibraryAsync(); RescanButton.Click += async (_, _) => await RescanLibraryAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync(); OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected(); LaunchButton.Click += (_, _) => LaunchSelected();
StopButton.Click += (_, _) => _emulator?.Stop();
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); }; ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
StopButton.Click += (_, _) => StopEmulator(); StopButton.Click += (_, _) => StopEmulator();
ClearLogButton.Click += (_, _) => _consoleLines.Clear(); SessionStopButton.Click += (_, _) => StopEmulator();
SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync(); CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow(); DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
LibraryTabButton.Click += (_, _) => SetActivePage(0); LibraryTabButton.Click += (_, _) => SetActivePage(0);
@@ -162,6 +197,7 @@ public partial class MainWindow : Window
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged(); LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel); GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
CtxLaunch.Click += (_, _) => LaunchSelected(); CtxLaunch.Click += (_, _) => LaunchSelected();
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder(); CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
CtxCopyPath.Click += async (_, _) => CtxCopyPath.Click += async (_, _) =>
@@ -683,14 +719,46 @@ public partial class MainWindow : Window
} }
} }
private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
{
// While a session is on screen, Enter and Space are game input
// (Cross button). Keyboard focus stays on the launcher window, so a
// previously clicked, still-focused button (console toggle, session
// bar) would also activate and reshape the game view. Swallow the
// keys before button activation; the emulator process reads raw key
// state and is unaffected. Fullscreen hides those buttons, which is
// why this only manifested in windowed sessions.
if (_isRunning && GameView.IsVisible &&
args.Key is Key.Enter or Key.Space)
{
args.Handled = true;
}
}
private void OnWindowFullScreen(object sender, RoutedEventArgs args) private void OnWindowFullScreen(object sender, RoutedEventArgs args)
{ {
if (WindowState == WindowState.FullScreen) if (WindowState == WindowState.FullScreen)
{ {
WindowState = WindowState.Normal; // Leaving F11 should restore a monitor-sized window with the
// launcher chrome, not fall back to the design-time window size.
WindowState = WindowState.Maximized;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome; ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
TitleBar.IsVisible = true; TitleBar.IsVisible = true;
StatusBar.IsVisible = true; StatusBar.IsVisible = true;
if (_gameFullscreen)
{
_gameFullscreen = false;
Grid.SetRow(MainContent, 1);
Grid.SetRowSpan(MainContent, 1);
MainContent.Margin = _isRunning
? new Thickness(0)
: new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = !_isRunning;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
QueueGameSurfaceResize();
UpdateSessionBarVisibility();
}
} }
else else
{ {
@@ -698,18 +766,45 @@ public partial class MainWindow : Window
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome; ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
TitleBar.IsVisible = false; TitleBar.IsVisible = false;
StatusBar.IsVisible = false; StatusBar.IsVisible = false;
if (_isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible)
{
// The native child receives its new physical Bounds as soon
// as this grid spans the monitor. The presenter recreates its
// swapchain from that size, rather than stretching 720p.
_gameFullscreen = true;
// Re-arming restarts the idle countdown, so the cursor also
// hides a moment after F11 even without further mouse motion.
_gameSurfaceHost?.SetCursorAutoHide(true);
Grid.SetRow(MainContent, 0);
Grid.SetRowSpan(MainContent, 3);
MainContent.Margin = new Thickness(0);
ContentToolbar.IsVisible = false;
ConsolePanel.IsVisible = false;
LaunchBar.IsVisible = false;
QueueGameSurfaceResize();
UpdateSessionBarVisibility();
} }
} }
}
private void QueueGameSurfaceResize()
{
Dispatcher.UIThread.Post(
() => _gameSurfaceHost?.RefreshSurfaceSize(),
DispatcherPriority.Render);
}
private void OnWindowClosing() private void OnWindowClosing()
{ {
_settings.Save(); _settings.Save();
_consoleFlushTimer.Stop(); _consoleFlushTimer.Stop();
_libraryBlurTimer.Stop();
_gamepadTimer.Stop(); _gamepadTimer.Stop();
_sndPreview.Stop(); _sndPreview.Stop();
_discord?.Dispose(); _discord?.Dispose();
_consoleWindow?.Close(); _consoleWindow?.Close();
_emulator?.Dispose(); _emulator?.Dispose();
_consoleMirror?.Dispose();
DropFileLog(); DropFileLog();
} }
@@ -896,8 +991,8 @@ public partial class MainWindow : Window
candidates.Add(_settings.EmulatorPath); candidates.Add(_settings.EmulatorPath);
} }
// The GUI and the CLI are the same executable: with arguments it runs // The GUI and CLI share one executable. The selected path is the
// the emulator, so the preferred child process is this process itself. // isolated child executable and also defines the portable data root.
if (Environment.ProcessPath is { } selfPath && if (Environment.ProcessPath is { } selfPath &&
Path.GetFileNameWithoutExtension(selfPath).Equals("SharpEmu", StringComparison.OrdinalIgnoreCase)) Path.GetFileNameWithoutExtension(selfPath).Equals("SharpEmu", StringComparison.OrdinalIgnoreCase))
{ {
@@ -1576,89 +1671,18 @@ public partial class MainWindow : Window
return; return;
} }
if (_emulatorExePath is null)
{
LocateEmulator();
if (_emulatorExePath is null)
{
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
return;
}
}
_sndPreview.Stop(); _sndPreview.Stop();
var arguments = new List<string>
{
"--cpu-engine=native",
$"--log-level={_settings.LogLevel.ToLowerInvariant()}",
};
if (_settings.StrictDynlibResolution)
{
arguments.Add("--strict");
}
if (_settings.ImportTraceLimit > 0)
{
arguments.Add($"--trace-imports={_settings.ImportTraceLimit}");
}
_consoleLines.Clear(); _consoleLines.Clear();
_allConsoleLines.Clear();
// Let the CLI mirror stdout/stderr itself; it sees loader/native
// diagnostics before the GUI pipe reader can filter or batch them.
DropFileLog(); DropFileLog();
if (_settings.LogToFile) if (_settings.LogToFile)
{ {
string filePath; OpenFileLog(titleId);
if (!string.IsNullOrWhiteSpace(_settings.LogFilePath))
{
if (_settings.OverrideLogFile)
{
filePath = _settings.LogFilePath;
}
else
{
string path = _settings.LogFilePath;
string id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId;
foreach (var invalidChar in Path.GetInvalidFileNameChars())
{
id = id.Replace(invalidChar.ToString(), "");
}
string identifier = $"{id}-{DateTime.Now:yyyyMMdd-HHmmss}";
string? dir = Path.GetDirectoryName(path);
string? fileName = Path.GetFileNameWithoutExtension(path);
string? extension = Path.GetExtension(path);
string newFileName = $"{fileName}-{identifier}{extension}";
filePath = string.IsNullOrEmpty(dir)
? newFileName
: Path.Combine(dir, newFileName);
}
}
else
{
filePath = BuildLogFilePath(titleId) ?? string.Empty;
} }
if (!string.IsNullOrEmpty(filePath)) // The isolated game child inherits these diagnostics. Keep them on the
{ // launcher process so every platform receives the same launch options.
arguments.Add("--log-file");
arguments.Add(filePath);
AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush);
}
}
arguments.Add(ebootPath);
AppendConsoleLine(
Localization.Instance.Format("Launch.Command", string.Join(' ', arguments)),
DimLineBrush);
// Apply the enabled switches to this process; both emulator launch paths
// (CreateProcessW and Process.Start) inherit it. Clear switches turned
// off since the previous launch.
foreach (var staleName in _appliedEnvironmentVariables) foreach (var staleName in _appliedEnvironmentVariables)
{ {
if (!_settings.EnvironmentToggles.Contains(staleName)) if (!_settings.EnvironmentToggles.Contains(staleName))
@@ -1674,34 +1698,41 @@ public partial class MainWindow : Window
_appliedEnvironmentVariables.Add(name); _appliedEnvironmentVariables.Add(name);
} }
var emulator = new EmulatorProcess(); if (SharpEmuLog.TryParseLevel(_settings.LogLevel, out var logLevel))
emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError));
emulator.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code));
try
{ {
emulator.Start(_emulatorExePath, arguments, Path.GetDirectoryName(ebootPath)); SharpEmuLog.MinimumLevel = logLevel;
}
catch (Exception ex)
{
emulator.Dispose();
AppendConsoleLine(Localization.Instance.Format("Launch.StartFailed", ex.Message), ErrorLineBrush);
DropFileLog();
return;
} }
_emulator = emulator; var runtimeOptions = new SharpEmuRuntimeOptions
{
CpuEngine = CpuExecutionEngine.NativeOnly,
StrictDynlibResolution = _settings.StrictDynlibResolution,
ImportTraceLimit = Math.Max(0, _settings.ImportTraceLimit),
};
_isRunning = true; _isRunning = true;
_runningGameName = displayName; _runningGameName = displayName;
_runningGameTitleId = _allGames SessionGameTitle.Text = displayName;
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))? _runningGameTitleId = titleId ?? _allGames
.TitleId; .FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); _runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush; StatusDot.Fill = SuccessLineBrush;
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName); StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName); StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
UpdateRunButtons(); UpdateRunButtons();
UpdateDiscordPresence(); UpdateDiscordPresence();
ShowGameView();
_pendingLaunch = new PendingLaunch(
Path.GetFullPath(ebootPath),
displayName,
_runningGameTitleId,
runtimeOptions);
if (_gameSurfaceHost?.Surface is { } surface)
{
StartPendingSession(surface);
}
} }
/// <summary> /// <summary>
@@ -1713,17 +1744,35 @@ public partial class MainWindow : Window
/// </summary> /// </summary>
private void StopEmulator() private void StopEmulator()
{ {
if (!_isRunning) if (!_isRunning || _isStopping)
{ {
return; return;
} }
_emulator?.Stop(); if (_emulator is null)
{
// The native host can be created a moment after Launch. Do not
// let that delayed callback start a session the user already
// cancelled.
_pendingLaunch = null;
OnEmulatorExited(0);
return;
}
_isStopping = true;
StopButton.IsEnabled = false;
SessionStopButton.IsEnabled = false;
SessionHintText.Text = Localization.Instance.Get("Launch.Stopping");
SessionF11Badge.IsVisible = false;
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
_emulator.Stop();
_runningGameName = null; _runningGameName = null;
_runningGameTitleId = null; _runningGameTitleId = null;
StatusText.Text = Localization.Instance.Get("Launch.Stopping"); StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping"); StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence(); UpdateDiscordPresence();
UpdateSessionBarVisibility();
ReturnToLibraryWhileStopping();
} }
/// <summary> /// <summary>
@@ -1734,7 +1783,7 @@ public partial class MainWindow : Window
{ {
try try
{ {
var exeDirectory = Path.GetDirectoryName(_emulatorExePath); var exeDirectory = Path.GetDirectoryName(_emulatorExePath) ?? AppContext.BaseDirectory;
if (string.IsNullOrEmpty(exeDirectory)) if (string.IsNullOrEmpty(exeDirectory))
{ {
return null; return null;
@@ -1761,8 +1810,12 @@ public partial class MainWindow : Window
{ {
FlushPendingConsoleLines(); FlushPendingConsoleLines();
_isRunning = false; _isRunning = false;
_isStopping = false;
_emulator?.Dispose(); _emulator?.Dispose();
_emulator = null; _emulator = null;
_pendingLaunch = null;
DisposeGameSurfaceHost();
HideGameView();
var meaningKey = exitCode switch var meaningKey = exitCode switch
{ {
@@ -1774,13 +1827,20 @@ public partial class MainWindow : Window
-1073741819 => "Exit.EmulationError", -1073741819 => "Exit.EmulationError",
_ => "Exit.Unknown", _ => "Exit.Unknown",
}; };
var stoppedByUser = exitCode == EmulatorProcess.HostStopExitCode;
var meaning = Localization.Instance.Get(meaningKey); var meaning = Localization.Instance.Get(meaningKey);
var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush; var brush = exitCode == 0 || stoppedByUser ? SuccessLineBrush : ErrorLineBrush;
AppendConsoleLine(Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning), brush); AppendConsoleLine(
stoppedByUser
? "Game closed by the user."
: Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning),
brush);
CloseFileLogSoon(); CloseFileLogSoon();
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush; StatusDot.Fill = exitCode == 0 || stoppedByUser ? (IBrush)SuccessLineBrush : ErrorLineBrush;
StatusText.Text = Localization.Instance.Format("Launch.Exited", exitCode, meaning); StatusText.Text = stoppedByUser
? "Game closed by the user."
: Localization.Instance.Format("Launch.Exited", exitCode, meaning);
StatusBarRight.Text = Localization.Instance.Get("Status.Idle"); StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
_runningGameName = null; _runningGameName = null;
_runningGameTitleId = null; _runningGameTitleId = null;
@@ -1788,13 +1848,368 @@ public partial class MainWindow : Window
UpdateDiscordPresence(); UpdateDiscordPresence();
} }
private void StartPendingSession(VulkanHostSurface surface)
{
if (_pendingLaunch is not { } launch || _emulator is not null)
{
return;
}
if (string.IsNullOrWhiteSpace(_emulatorExePath))
{
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
OnEmulatorExited(3);
return;
}
var process = new EmulatorProcess();
process.OutputReceived += OnEmulatorOutput;
process.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code));
try
{
var arguments = BuildEmulatorArguments(launch, surface);
_emulator = process;
_pendingLaunch = null;
process.Start(
_emulatorExePath,
arguments,
Path.GetDirectoryName(_emulatorExePath));
AppendConsoleLine(
Localization.Instance.Format("Launch.Command", launch.EbootPath),
DimLineBrush);
}
catch (Exception exception)
{
_emulator = null;
process.Dispose();
AppendConsoleLine(
Localization.Instance.Format("Launch.StartFailed", exception.Message),
ErrorLineBrush);
OnEmulatorExited(3);
}
}
private List<string> BuildEmulatorArguments(PendingLaunch launch, VulkanHostSurface surface)
{
var arguments = new List<string>
{
"--cpu-engine=native",
$"--log-level={_settings.LogLevel}",
};
if (launch.RuntimeOptions.StrictDynlibResolution)
{
arguments.Add("--strict");
}
if (launch.RuntimeOptions.ImportTraceLimit > 0)
{
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
}
if (surface.TryGetChildProcessDescriptor(out var descriptor))
{
arguments.Add($"--host-surface={descriptor}");
}
else
{
AppendConsoleLine(
"[GUI][WARN] Embedded child surfaces are unavailable on this platform; opening a game window instead.",
WarningLineBrush);
}
arguments.Add(launch.EbootPath);
return arguments;
}
private void OnEmulatorOutput(string line, bool isError)
{
_pendingLines.Enqueue((line, isError));
if (!line.Contains("[VIDEOOUT][INFO] Hosted splash ready.", StringComparison.Ordinal) &&
!line.Contains("[VIDEOOUT][INFO] Hosted first frame presented.", StringComparison.Ordinal))
{
return;
}
Dispatcher.UIThread.Post(() =>
{
if (_isRunning && !_isStopping)
{
_awaitingFirstFrame = false;
ClearLibraryBlur();
MainContent.Margin = new Thickness(0);
GameView.Background = Brushes.Black;
GameView.IsHitTestVisible = true;
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
LibraryPage.IsVisible = false;
OptionsPage.IsVisible = false;
LibraryToolbar.IsVisible = false;
ContentToolbar.IsVisible = false;
ConsolePanel.IsVisible = false;
LaunchBar.IsVisible = false;
SessionLoadingPopup.IsOpen = false;
UpdateSessionBarVisibility();
}
});
}
private GameSurfaceHost EnsureGameSurfaceHost()
{
if (_gameSurfaceHost is not null)
{
return _gameSurfaceHost;
}
var host = new GameSurfaceHost();
// Configure this before attaching it to Avalonia so its first native
// HWND is hidden while the child process starts.
host.SetPresentationVisible(false);
host.SurfaceAvailable += (_, surface) =>
{
if (ReferenceEquals(_gameSurfaceHost, host))
{
StartPendingSession(surface);
}
};
host.SurfaceDestroyed += (_, surface) => OnGameSurfaceDestroyed(host, surface);
_gameSurfaceHost = host;
GameSurfaceContainer.Children.Add(host);
return host;
}
private void DisposeGameSurfaceHost()
{
var host = _gameSurfaceHost;
if (host is null)
{
return;
}
_gameSurfaceHost = null;
host.SetPresentationVisible(false);
GameSurfaceContainer.Children.Remove(host);
}
private void OnGameSurfaceDestroyed(GameSurfaceHost host, VulkanHostSurface surface)
{
if (ReferenceEquals(_gameSurfaceHost, host) && _isRunning)
{
StopEmulator();
}
}
private void ShowGameView()
{
_isStopping = false;
_awaitingFirstFrame = true;
var host = EnsureGameSurfaceHost();
GameView.IsVisible = true;
GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false;
host.SetPresentationVisible(false);
AnimateLibraryBlur(LaunchBlurRadius);
SessionHintText.Text = "Fullscreen";
SessionF11Badge.IsVisible = true;
UpdateSessionBarVisibility();
ShowSessionLoading("Loading game", "Preparing the emulation session...");
}
private void HideGameView()
{
if (_gameFullscreen && WindowState == WindowState.FullScreen)
{
OnWindowFullScreen(this, new RoutedEventArgs());
}
_gameSurfaceHost?.SetCursorAutoHide(false);
_gameSurfaceHost?.SetPresentationVisible(false);
_awaitingFirstFrame = false;
GameView.IsVisible = false;
GameView.IsHitTestVisible = true;
SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false;
AnimateLibraryBlur(0, clearWhenComplete: true);
MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
LibraryPage.IsVisible = _activePageIndex == 0;
LibraryToolbar.IsVisible = _activePageIndex == 0;
OptionsPage.IsVisible = _activePageIndex == 1;
if (GameList.SelectedItem is GameEntry game && game.Background is not null)
{
BackdropImage.Opacity = 1;
}
}
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
{
_libraryBlur ??= new BlurEffect();
MainContent.Effect = _libraryBlur;
_libraryBlurStartRadius = _libraryBlur.Radius;
_libraryBlurTargetRadius = Math.Max(0, targetRadius);
_libraryBlurStartedAt = Stopwatch.GetTimestamp();
_clearLibraryBlurWhenComplete = clearWhenComplete && _libraryBlurTargetRadius == 0;
if (Math.Abs(_libraryBlurStartRadius - _libraryBlurTargetRadius) < 0.01)
{
CompleteLibraryBlur();
return;
}
_libraryBlurTimer.Start();
}
private void AdvanceLibraryBlur()
{
if (_libraryBlur is null)
{
_libraryBlurTimer.Stop();
return;
}
var elapsed = (Stopwatch.GetTimestamp() - _libraryBlurStartedAt) /
(double)Stopwatch.Frequency;
var progress = Math.Clamp(elapsed / BlurTransitionSeconds, 0, 1);
// Cubic ease-out gives the loading transition a quick response while
// keeping the final change of sharpness unobtrusive.
var easedProgress = 1 - Math.Pow(1 - progress, 3);
_libraryBlur.Radius = _libraryBlurStartRadius +
((_libraryBlurTargetRadius - _libraryBlurStartRadius) * easedProgress);
if (progress >= 1)
{
CompleteLibraryBlur();
}
}
private void CompleteLibraryBlur()
{
_libraryBlurTimer.Stop();
if (_libraryBlur is not null)
{
_libraryBlur.Radius = _libraryBlurTargetRadius;
}
if (_clearLibraryBlurWhenComplete)
{
MainContent.Effect = null;
_libraryBlur = null;
_clearLibraryBlurWhenComplete = false;
}
}
private void ClearLibraryBlur()
{
_libraryBlurTimer.Stop();
_libraryBlur = null;
_clearLibraryBlurWhenComplete = false;
MainContent.Effect = null;
}
private void ShowSessionLoading(string title, string detail)
{
SessionLoadingTitle.Text = title;
SessionLoadingDetail.Text = detail;
SessionLoadingPopup.IsOpen = true;
}
private void ReturnToLibraryWhileStopping()
{
if (_gameFullscreen && WindowState == WindowState.FullScreen)
{
OnWindowFullScreen(this, new RoutedEventArgs());
}
// Keep the native child alive until the session exits, but hide it
// immediately. Destroying it while Vulkan still owns the surface can
// crash the GUI; leaving it transparent lets the library recover
// while the native closing popup reports teardown progress.
_gameSurfaceHost?.SetPresentationVisible(false);
_awaitingFirstFrame = false;
GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false;
SessionBarPopup.IsOpen = false;
AnimateLibraryBlur(LaunchBlurRadius);
MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
LibraryPage.IsVisible = _activePageIndex == 0;
LibraryToolbar.IsVisible = _activePageIndex == 0;
OptionsPage.IsVisible = _activePageIndex == 1;
BackdropImage.Opacity = GameList.SelectedItem is GameEntry { Background: not null } ? 1 : 0;
UpdateRunButtons();
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
}
private void OpenFileLog(string? titleId)
{
var filePath = ResolveLogFilePath(titleId);
if (string.IsNullOrWhiteSpace(filePath))
{
return;
}
try
{
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
_fileLog = new StreamWriter(filePath, append: false) { AutoFlush = true };
AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush);
}
catch (Exception exception)
{
AppendConsoleLine($"[GUI][WARN] Could not open log file: {exception.Message}", WarningLineBrush);
DropFileLog();
}
}
private string? ResolveLogFilePath(string? titleId)
{
if (string.IsNullOrWhiteSpace(_settings.LogFilePath))
{
return BuildLogFilePath(titleId);
}
if (_settings.OverrideLogFile)
{
return _settings.LogFilePath;
}
var path = _settings.LogFilePath;
var id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId;
foreach (var invalid in Path.GetInvalidFileNameChars())
{
id = id.Replace(invalid.ToString(), string.Empty, StringComparison.Ordinal);
}
var directory = Path.GetDirectoryName(path);
var filename = Path.GetFileNameWithoutExtension(path);
var extension = Path.GetExtension(path);
var timestampedName = $"{filename}-{id}-{DateTime.Now:yyyyMMdd-HHmmss}{extension}";
return string.IsNullOrEmpty(directory) ? timestampedName : Path.Combine(directory, timestampedName);
}
private void UpdateRunButtons() private void UpdateRunButtons()
{ {
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry; LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
StopButton.IsEnabled = _isRunning; StopButton.IsEnabled = _isRunning && !_isStopping;
SessionStopButton.IsEnabled = _isRunning && !_isStopping;
OpenFileButton.IsEnabled = !_isRunning; OpenFileButton.IsEnabled = !_isRunning;
} }
private void UpdateSessionBarVisibility()
{
SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
!_gameFullscreen && WindowState != WindowState.FullScreen;
}
// ---- Console ---- // ---- Console ----
private void FlushPendingConsoleLines() private void FlushPendingConsoleLines()
+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 <!-- Dependency-free; provides the BuildInfo provenance shown in the
title bar. --> title bar. -->
<ItemGroup> <ItemGroup>
<!-- The GUI owns the native presentation control while each game runs in
an isolated emulator process. -->
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" /> <ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
</ItemGroup> </ItemGroup>
@@ -41,18 +45,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName> <LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
<!-- The controller readers (DualSense raw HID + Xbox XInput) are shared
with the emulator's host input backend. They are dependency-free, so
they are compiled in directly rather than pulling a reference to all
of SharpEmu.HLE into the launcher. -->
<ItemGroup>
<Compile Include="..\SharpEmu.HLE\Host\HostGamepadState.cs" Link="Input/HostGamepadState.cs" />
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsHidNative.cs" Link="Input/WindowsHidNative.cs" />
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsDualSenseReader.cs" Link="Input/WindowsDualSenseReader.cs" />
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsXInputReader.cs" Link="Input/WindowsXInputReader.cs" />
</ItemGroup>
</Project> </Project>
+109 -2
View File
@@ -69,11 +69,118 @@ public sealed class PosixHostInput : IHostInput
{ {
// GLFW only delivers key events to the focused window, so a // GLFW only delivers key events to the focused window, so a
// delivering keyboard implies focus. // delivering keyboard implies focus.
return _source?.HasKeyboardFocus ?? false; return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused();
} }
public bool IsKeyDown(int virtualKey) public bool IsKeyDown(int virtualKey)
{ {
return _source?.IsKeyDown(virtualKey) ?? false; var source = _source;
if (source is not null)
{
return source.IsKeyDown(virtualKey);
} }
return IsEmbeddedX11WindowFocused() && IsEmbeddedX11KeyDown(virtualKey);
}
private static bool IsEmbeddedX11WindowFocused()
{
if (!OperatingSystem.IsLinux())
{
return false;
}
var display = HostSessionControl.EmbeddedHostDisplay;
var window = HostSessionControl.EmbeddedHostWindow;
if (display == 0 || window == 0 || XGetInputFocus(display, out var focusedWindow, out _) == 0 || focusedWindow == 0)
{
return false;
}
return GetTopLevelWindow(display, focusedWindow) == GetTopLevelWindow(display, window);
}
private static bool IsEmbeddedX11KeyDown(int virtualKey)
{
var display = HostSessionControl.EmbeddedHostDisplay;
var keysym = ToX11Keysym(virtualKey);
if (display == 0 || keysym == 0)
{
return false;
}
var keycode = XKeysymToKeycode(display, keysym);
if (keycode == 0)
{
return false;
}
var keymap = new byte[32];
XQueryKeymap(display, keymap);
return (keymap[keycode >> 3] & (1 << (keycode & 7))) != 0;
}
private static nint GetTopLevelWindow(nint display, nint window)
{
var current = window;
for (var depth = 0; depth < 16; depth++)
{
if (XQueryTree(display, current, out var root, out var parent, out var children, out _) == 0)
{
return 0;
}
if (children != 0)
{
XFree(children);
}
if (parent == 0 || parent == root)
{
return current;
}
current = parent;
}
return 0;
}
private static nuint ToX11Keysym(int virtualKey)
{
return virtualKey switch
{
0x08 => 0xFF08, // Backspace
0x09 => 0xFF09, // Tab
0x0D => 0xFF0D, // Return
0x1B => 0xFF1B, // Escape
0x25 => 0xFF51, // Left
0x26 => 0xFF52, // Up
0x27 => 0xFF53, // Right
0x28 => 0xFF54, // Down
>= 0x41 and <= 0x5A => (nuint)virtualKey,
_ => 0,
};
}
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XGetInputFocus(nint display, out nint focus, out int revertTo);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XQueryKeymap(nint display, [System.Runtime.InteropServices.Out] byte[] keysReturn);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern byte XKeysymToKeycode(nint display, nuint keysym);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XQueryTree(
nint display,
nint window,
out nint root,
out nint parent,
out nint children,
out uint childCount);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XFree(nint data);
} }
@@ -10,7 +10,7 @@ namespace SharpEmu.HLE.Host.Windows;
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31, /// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
/// activated by requesting feature report 0x05), with hot-plug retry. /// activated by requesting feature report 0x05), with hot-plug retry.
/// </summary> /// </summary>
internal static class WindowsDualSenseReader public static class WindowsDualSenseReader
{ {
private const ushort SonyVendorId = 0x054C; private const ushort SonyVendorId = 0x054C;
private const ushort DualSenseProductId = 0x0CE6; private const ushort DualSenseProductId = 0x0CE6;
@@ -35,7 +35,7 @@ internal static class WindowsDualSenseReader
private static byte _playerLeds = 0x04; // center LED = player 1 private static byte _playerLeds = 0x04; // center LED = player 1
/// <summary>Starts the background reader once; safe to call repeatedly.</summary> /// <summary>Starts the background reader once; safe to call repeatedly.</summary>
internal static void EnsureStarted() public static void EnsureStarted()
{ {
// The GUI source-links this reader and calls it directly, without the // The GUI source-links this reader and calls it directly, without the
// host-platform resolution that otherwise guarantees Windows. // host-platform resolution that otherwise guarantees Windows.
@@ -61,7 +61,7 @@ internal static class WindowsDualSenseReader
} }
} }
internal static bool TryGetState(out HostGamepadState state) public static bool TryGetState(out HostGamepadState state)
{ {
lock (Gate) lock (Gate)
{ {
@@ -67,7 +67,19 @@ internal sealed partial class WindowsHostInput : IHostInput
} }
GetWindowThreadProcessId(foregroundWindow, out var processId); GetWindowThreadProcessId(foregroundWindow, out var processId);
return processId == (uint)Environment.ProcessId; if (processId == (uint)Environment.ProcessId)
{
return true;
}
// The GUI runs the emulator in an isolated child process. Its native
// Vulkan surface is a child of the GUI window, so the foreground
// window belongs to the launcher process rather than this one.
var embeddedHostWindow = HostSessionControl.EmbeddedHostWindow;
var hostTopLevelWindow = embeddedHostWindow == 0
? 0
: GetAncestor(embeddedHostWindow, GetAncestorRoot);
return hostTopLevelWindow != 0 && foregroundWindow == hostTopLevelWindow;
} }
public bool IsKeyDown(int virtualKey) => public bool IsKeyDown(int virtualKey) =>
@@ -81,4 +93,9 @@ internal sealed partial class WindowsHostInput : IHostInput
[LibraryImport("user32.dll")] [LibraryImport("user32.dll")]
private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId); private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId);
[LibraryImport("user32.dll")]
private static partial nint GetAncestor(nint hWnd, uint gaFlags);
private const uint GetAncestorRoot = 2;
} }
@@ -11,7 +11,7 @@ namespace SharpEmu.HLE.Host.Windows;
/// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug /// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug
/// retry; the first connected slot (of four) is used. /// retry; the first connected slot (of four) is used.
/// </summary> /// </summary>
internal static partial class WindowsXInputReader public static partial class WindowsXInputReader
{ {
private const uint ErrorSuccess = 0; private const uint ErrorSuccess = 0;
private const int SlotCount = 4; private const int SlotCount = 4;
@@ -43,7 +43,7 @@ internal static partial class WindowsXInputReader
private static byte _triggerRight; private static byte _triggerRight;
/// <summary>Starts the background reader once; safe to call repeatedly.</summary> /// <summary>Starts the background reader once; safe to call repeatedly.</summary>
internal static void EnsureStarted() public static void EnsureStarted()
{ {
// The GUI source-links this reader and calls it directly, without the // The GUI source-links this reader and calls it directly, without the
// host-platform resolution that otherwise guarantees Windows. // host-platform resolution that otherwise guarantees Windows.
@@ -69,7 +69,7 @@ internal static partial class WindowsXInputReader
} }
} }
internal static bool TryGetState(out HostGamepadState state) public static bool TryGetState(out HostGamepadState state)
{ {
lock (Gate) lock (Gate)
{ {
+72 -1
View File
@@ -10,17 +10,88 @@ namespace SharpEmu.HLE;
public static class HostSessionControl public static class HostSessionControl
{ {
private static Action<string>? _shutdownHandler; private static Action<string>? _shutdownHandler;
private static string? _pendingShutdownReason;
private static int _shutdownRequested;
private static long _embeddedHostWindow;
private static long _embeddedHostDisplay;
/// <summary>
/// Indicates that the active host session is being stopped. Runtime code
/// uses this to skip expensive post-exit diagnostics before returning the
/// GUI to its library.
/// </summary>
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
/// <summary>
/// Native GUI surface used by an isolated emulator child. Input backends
/// use it to treat the launcher window as the active game window.
/// </summary>
public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
/// <summary>X11 Display* paired with <see cref="EmbeddedHostWindow"/> when available.</summary>
public static nint EmbeddedHostDisplay => unchecked((nint)Interlocked.Read(ref _embeddedHostDisplay));
public static void SetEmbeddedHostSurface(nint window, nint display = 0)
{
Interlocked.Exchange(ref _embeddedHostDisplay, unchecked((long)display));
Interlocked.Exchange(ref _embeddedHostWindow, unchecked((long)window));
}
/// <summary>
/// Starts a fresh session after the previous guest has fully left its
/// execution backend.
/// </summary>
public static void ResetShutdownRequest()
{
Interlocked.Exchange(ref _pendingShutdownReason, null);
Volatile.Write(ref _shutdownRequested, 0);
}
public static void SetShutdownHandler(Action<string>? handler) public static void SetShutdownHandler(Action<string>? handler)
{ {
Volatile.Write(ref _shutdownHandler, handler); Volatile.Write(ref _shutdownHandler, handler);
if (handler is null)
{
Interlocked.Exchange(ref _pendingShutdownReason, null);
return;
}
var pendingReason = Interlocked.Exchange(ref _pendingShutdownReason, null);
if (pendingReason is not null)
{
Invoke(handler, pendingReason);
}
} }
public static void RequestShutdown(string reason) public static void RequestShutdown(string reason)
{
Volatile.Write(ref _shutdownRequested, 1);
var handler = Volatile.Read(ref _shutdownHandler);
if (handler is not null)
{
Invoke(handler, reason);
return;
}
// Stop can be pressed while the GUI session is starting. Retain the
// request until the native backend installs its cooperative handler.
Volatile.Write(ref _pendingShutdownReason, reason);
handler = Volatile.Read(ref _shutdownHandler);
if (handler is not null)
{
var pendingReason = Interlocked.Exchange(ref _pendingShutdownReason, null);
if (pendingReason is not null)
{
Invoke(handler, pendingReason);
}
}
}
private static void Invoke(Action<string> handler, string reason)
{ {
try try
{ {
Volatile.Read(ref _shutdownHandler)?.Invoke(reason); handler(reason);
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -77,7 +77,10 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
} }
if (_traceSema)
{
TraceSemaphore($"create handle=0x{handle:X8} name='{name}' attr=0x{attr:X} init={initialCount} max={maxCount}"); TraceSemaphore($"create handle=0x{handle:X8} name='{name}' attr=0x{attr:X} init={initialCount} max={maxCount}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
} }
@@ -118,7 +121,10 @@ public static class KernelSemaphoreCompatExports
_ = TryWriteUInt32(ctx, timeoutAddress, timeoutUsec); _ = TryWriteUInt32(ctx, timeoutAddress, timeoutUsec);
} }
if (_traceSema)
{
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}"); TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
} }
@@ -157,8 +163,11 @@ public static class KernelSemaphoreCompatExports
} }
if (acquired) if (acquired)
{
if (_traceSema)
{ {
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}"); TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
@@ -167,7 +176,10 @@ public static class KernelSemaphoreCompatExports
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1); semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
} }
if (_traceSema)
{
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}"); TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
} }
@@ -178,8 +190,11 @@ public static class KernelSemaphoreCompatExports
ResumeWait, ResumeWait,
WakePredicate, WakePredicate,
deadline)) deadline))
{
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)}"); TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
} }
@@ -200,10 +215,13 @@ public static class KernelSemaphoreCompatExports
? Environment.TickCount64 + Math.Max(1L, timeoutUsec / 1000L) ? Environment.TickCount64 + Math.Max(1L, timeoutUsec / 1000L)
: long.MaxValue; : long.MaxValue;
lock (semaphore.Gate) lock (semaphore.Gate)
{
if (_traceSema)
{ {
TraceSemaphore( TraceSemaphore(
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " + $"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}"); $"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
}
while (semaphore.Count < needCount) while (semaphore.Count < needCount)
{ {
var remaining = deadlineMs - Environment.TickCount64; var remaining = deadlineMs - Environment.TickCount64;
@@ -219,8 +237,11 @@ public static class KernelSemaphoreCompatExports
semaphore.Count -= needCount; semaphore.Count -= needCount;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1); semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
if (_traceSema)
{
TraceSemaphore( TraceSemaphore(
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}"); $"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
}
if (timeoutAddress != 0) if (timeoutAddress != 0)
{ {
_ = TryWriteUInt32(ctx, timeoutAddress, 0); _ = TryWriteUInt32(ctx, timeoutAddress, 0);
@@ -252,13 +273,19 @@ public static class KernelSemaphoreCompatExports
lock (semaphore.Gate) lock (semaphore.Gate)
{ {
if (semaphore.Count < needCount) if (semaphore.Count < needCount)
{
if (_traceSema)
{ {
TraceSemaphore($"poll-busy handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}"); TraceSemaphore($"poll-busy handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
} }
semaphore.Count -= needCount; semaphore.Count -= needCount;
if (_traceSema)
{
TraceSemaphore($"poll handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}"); TraceSemaphore($"poll handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
} }
} }
@@ -290,8 +317,11 @@ public static class KernelSemaphoreCompatExports
semaphore.Count += signalCount; semaphore.Count += signalCount;
// Wake host-thread waiters parked in the fallback path. // Wake host-thread waiters parked in the fallback path.
Monitor.PulseAll(semaphore.Gate); Monitor.PulseAll(semaphore.Gate);
if (_traceSema)
{
TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}"); TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
} }
}
// Wake cooperatively-blocked guest threads; their wake predicate // Wake cooperatively-blocked guest threads; their wake predicate
// acquires the tokens atomically, so this respects the new count. // acquires the tokens atomically, so this respects the new count.
@@ -326,8 +356,11 @@ public static class KernelSemaphoreCompatExports
semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount; semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount;
semaphore.WaitingThreads = 0; semaphore.WaitingThreads = 0;
Monitor.PulseAll(semaphore.Gate); Monitor.PulseAll(semaphore.Gate);
if (_traceSema)
{
TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}"); TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}");
} }
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle)); _ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
@@ -346,7 +379,10 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
} }
if (_traceSema)
{
TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'"); TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
} }
@@ -385,7 +421,10 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
} }
if (_traceSema)
{
TraceSemaphore($"posix-init address=0x{semaphoreAddress:X16} handle=0x{handle:X8} count={initialCount}"); TraceSemaphore($"posix-init address=0x{semaphoreAddress:X16} handle=0x{handle:X8} count={initialCount}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
} }
@@ -582,6 +621,11 @@ public static class KernelSemaphoreCompatExports
private static void TraceSemaphore(string message) private static void TraceSemaphore(string message)
{ {
if (!_traceSema)
{
return;
}
Console.Error.WriteLine($"[LOADER][TRACE] sema.{message}"); Console.Error.WriteLine($"[LOADER][TRACE] sema.{message}");
} }
+4 -1
View File
@@ -154,7 +154,10 @@ public static class PerfOverlay
_lastGen2 = gen2; _lastGen2 = gen2;
var cpuTime = GetProcessCpuTime(); var cpuTime = GetProcessCpuTime();
_cpuPercent = (cpuTime - _lastCpuTime).TotalSeconds / seconds * 100.0; // Normalize across logical processors (Task Manager convention):
// raw process time / wall time reads 100% per fully busy core.
_cpuPercent = (cpuTime - _lastCpuTime).TotalSeconds / seconds * 100.0 /
Environment.ProcessorCount;
_lastCpuTime = cpuTime; _lastCpuTime = cpuTime;
var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0; var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
+15 -1
View File
@@ -156,16 +156,30 @@ public static class VideoOutExports
private static void RequestHostShutdown(string reason) private static void RequestHostShutdown(string reason)
{ {
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}"); Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
VulkanVideoPresenter.RequestClose(); var embedded = VulkanVideoHost.IsEmbedded;
AudioOutExports.ShutdownAllPorts(); AudioOutExports.ShutdownAllPorts();
Interlocked.Exchange(ref _vblankStopRequested, 1); Interlocked.Exchange(ref _vblankStopRequested, 1);
HostSessionControl.RequestShutdown(reason); HostSessionControl.RequestShutdown(reason);
// 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)
{
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 _ => ThreadPool.QueueUserWorkItem(static _ =>
{ {
Thread.Sleep(2000); Thread.Sleep(2000);
Environment.Exit(0); Environment.Exit(0);
}); });
} }
}
private sealed class VideoOutPortState private sealed class VideoOutPortState
{ {
@@ -0,0 +1,270 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
/// <summary>
/// A native child surface owned by a host UI. The presenter consumes this
/// directly, avoiding a second top-level GLFW window when the GUI is active.
/// </summary>
public enum VulkanHostSurfaceKind
{
Win32,
Xlib,
Metal,
}
/// <summary>
/// Platform-native handles required to create a Vulkan presentation surface.
/// The GUI owns their lifetime and updates the physical pixel size on resize.
/// </summary>
public sealed class VulkanHostSurface : IDisposable
{
private int _pixelWidth;
private int _pixelHeight;
private int _resizeGeneration;
private readonly bool _ownsDisplay;
private readonly bool _pollNativeSize;
private long _nextNativeSizePoll;
public VulkanHostSurface(
VulkanHostSurfaceKind kind,
nint windowHandle,
nint displayHandle = 0,
nint metalLayerHandle = 0,
bool ownsDisplay = false,
bool pollNativeSize = false)
{
Kind = kind;
WindowHandle = windowHandle;
DisplayHandle = displayHandle;
MetalLayerHandle = metalLayerHandle;
_ownsDisplay = ownsDisplay;
_pollNativeSize = pollNativeSize;
}
public VulkanHostSurfaceKind Kind { get; }
public nint WindowHandle { get; }
/// <summary>X11 Display* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Xlib"/>.</summary>
public nint DisplayHandle { get; }
/// <summary>CAMetalLayer* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Metal"/>.</summary>
public nint MetalLayerHandle { get; }
public int PixelWidth => Volatile.Read(ref _pixelWidth);
public int PixelHeight => Volatile.Read(ref _pixelHeight);
internal int ResizeGeneration => Volatile.Read(ref _resizeGeneration);
public void UpdatePixelSize(int width, int height)
{
width = Math.Max(width, 1);
height = Math.Max(height, 1);
if (Volatile.Read(ref _pixelWidth) == width && Volatile.Read(ref _pixelHeight) == height)
{
return;
}
Volatile.Write(ref _pixelWidth, width);
Volatile.Write(ref _pixelHeight, height);
Interlocked.Increment(ref _resizeGeneration);
}
/// <summary>
/// The child emulator cannot receive Avalonia resize notifications. Poll
/// the native host at a bounded rate so embedded child swapchains still
/// follow normal resize and F11 transitions.
/// </summary>
internal void RefreshChildProcessPixelSize()
{
if (!_pollNativeSize)
{
return;
}
var now = System.Diagnostics.Stopwatch.GetTimestamp();
var due = Volatile.Read(ref _nextNativeSizePoll);
if (now < due || Interlocked.CompareExchange(
ref _nextNativeSizePoll,
now + (System.Diagnostics.Stopwatch.Frequency / 8),
due) != due)
{
return;
}
if (Kind == VulkanHostSurfaceKind.Win32 && GetClientRect(WindowHandle, out var rect))
{
UpdatePixelSize(rect.Right - rect.Left, rect.Bottom - rect.Top);
return;
}
if (Kind == VulkanHostSurfaceKind.Xlib && DisplayHandle != 0 &&
XGetGeometry(
DisplayHandle,
WindowHandle,
out _,
out _,
out _,
out var width,
out var height,
out _,
out _) != 0)
{
UpdatePixelSize(unchecked((int)width), unchecked((int)height));
}
}
/// <summary>
/// Serializes a native child handle for a separately hosted emulator
/// process. Metal object pointers are process-local, so macOS falls back
/// to a standalone child window until an IPC Metal host is implemented.
/// </summary>
public bool TryGetChildProcessDescriptor(out string descriptor)
{
descriptor = string.Empty;
if (Kind == VulkanHostSurfaceKind.Metal || WindowHandle == 0)
{
return false;
}
var kind = Kind == VulkanHostSurfaceKind.Win32 ? "win32" : "xlib";
descriptor = string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"{kind}:{unchecked((ulong)WindowHandle):X}:{Math.Max(PixelWidth, 1)}:{Math.Max(PixelHeight, 1)}:{unchecked((ulong)DisplayHandle):X}");
return true;
}
/// <summary>
/// Reconstructs a surface in the isolated emulator process. X11 clients
/// must open their own Display connection; Display* values cannot cross a
/// process boundary.
/// </summary>
public static bool TryCreateChildProcessSurface(
string descriptor,
out VulkanHostSurface? surface,
out string? error)
{
surface = null;
error = null;
var parts = descriptor.Split(':');
if (parts.Length != 5 ||
!ulong.TryParse(parts[1], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var window) ||
!int.TryParse(parts[2], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var width) ||
!int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var height) ||
!ulong.TryParse(parts[4], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var nativeDisplay))
{
error = "invalid host-surface descriptor";
return false;
}
if (window == 0 || width <= 0 || height <= 0)
{
error = "host-surface descriptor has an invalid size or handle";
return false;
}
if (string.Equals(parts[0], "win32", StringComparison.OrdinalIgnoreCase))
{
surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Win32,
unchecked((nint)window),
unchecked((nint)nativeDisplay),
pollNativeSize: true);
}
else if (string.Equals(parts[0], "xlib", StringComparison.OrdinalIgnoreCase))
{
var display = XOpenDisplay(0);
if (display == 0)
{
error = "could not open an X11 display for the host surface";
return false;
}
surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Xlib,
unchecked((nint)window),
display,
ownsDisplay: true,
pollNativeSize: true);
}
else
{
error = $"unsupported host-surface kind '{parts[0]}'";
return false;
}
surface.UpdatePixelSize(width, height);
return true;
}
public void Dispose()
{
if (_ownsDisplay && DisplayHandle != 0 && OperatingSystem.IsLinux())
{
_ = XCloseDisplay(DisplayHandle);
}
}
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
private static extern nint XOpenDisplay(nint displayName);
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
private static extern int XCloseDisplay(nint display);
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetClientRect", SetLastError = true)]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool GetClientRect(nint window, out Rect rect);
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XGetGeometry")]
private static extern int XGetGeometry(
nint display,
nint drawable,
out nint root,
out int x,
out int y,
out uint width,
out uint height,
out uint borderWidth,
out uint depth);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
}
/// <summary>
/// Small public bridge between a desktop UI and the internal Vulkan
/// presenter. Launchers can host a surface without depending on renderer
/// submission internals.
/// </summary>
public static class VulkanVideoHost
{
/// <summary>
/// Raised after the first successful Vulkan present to an embedded host
/// surface. UI hosts use this to retire their launch affordance only once
/// a real frame can be seen.
/// </summary>
public static event Action<VulkanHostSurface>? FirstFramePresented
{
add => VulkanVideoPresenter.FirstHostFramePresented += value;
remove => VulkanVideoPresenter.FirstHostFramePresented -= value;
}
public static bool TryAttachSurface(VulkanHostSurface surface) =>
VulkanVideoPresenter.TryAttachHostSurface(surface);
public static void DetachSurface(VulkanHostSurface surface) =>
VulkanVideoPresenter.DetachHostSurface(surface);
public static void RequestClose() => VulkanVideoPresenter.RequestClose();
public static bool IsEmbedded => VulkanVideoPresenter.UsesHostSurface;
}
@@ -100,8 +100,10 @@ internal readonly record struct VulkanGuestQueueIdentity(
internal static unsafe class VulkanVideoPresenter internal static unsafe class VulkanVideoPresenter
{ {
private const uint DefaultWindowWidth = 1280; // Standalone CLI launches use a desktop-sized surface. The embedded GUI
private const uint DefaultWindowHeight = 720; // always takes its dimensions from the native child control instead.
private const uint DefaultWindowWidth = 1920;
private const uint DefaultWindowHeight = 1080;
// Vulkan's portable upper bound for minStorageBufferOffsetAlignment is // Vulkan's portable upper bound for minStorageBufferOffsetAlignment is
// 256 bytes. Using that fixed power of two (instead of racing the render // 256 bytes. Using that fixed power of two (instead of racing the render
// thread's physical-device query) gives shader translation and descriptor // thread's physical-device query) gives shader translation and descriptor
@@ -123,7 +125,16 @@ internal static unsafe class VulkanVideoPresenter
// of draws per frame to a fraction of the display rate. The pending cap // of draws per frame to a fraction of the display rate. The pending cap
// stays tighter than the drain budget because queued draws pin their // stays tighter than the drain budget because queued draws pin their
// pooled guest-data arrays until the render thread uploads them. // pooled guest-data arrays until the render thread uploads them.
private const int MaxPendingGuestWork = 64; // The Cocoa event loop must stay responsive while guest work is pending,
// but Windows and Linux render on a dedicated host thread. Keeping the
// macOS item limit everywhere throttles draw-heavy games well below their
// display rate before the byte budget is remotely close to full.
private static readonly int _maxPendingGuestWorkItems =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_PENDING_GUEST_WORK_ITEMS"),
out var pendingGuestWorkItems) && pendingGuestWorkItems > 0
? pendingGuestWorkItems
: OperatingSystem.IsMacOS() ? 64 : 512;
private const ulong MaximumCachedHostBufferBytes = 128UL * 1024 * 1024; private const ulong MaximumCachedHostBufferBytes = 128UL * 1024 * 1024;
// A captured 4K flip can consume tens of MiB of device-local memory. // A captured 4K flip can consume tens of MiB of device-local memory.
// Retain only a short presentation queue while always preserving the // Retain only a short presentation queue while always preserving the
@@ -140,30 +151,40 @@ internal static unsafe class VulkanVideoPresenter
out var pendingGuestWorkMb) && pendingGuestWorkMb > 0 out var pendingGuestWorkMb) && pendingGuestWorkMb > 0
? pendingGuestWorkMb ? pendingGuestWorkMb
: 256UL) * 1024UL * 1024UL; : 256UL) * 1024UL * 1024UL;
private const int MaxGuestWorkPerRender = 256; private static readonly int _maxGuestWorkPerRender =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_MAX_GUEST_WORK_PER_RENDER"),
out var guestWorkPerRender) && guestWorkPerRender > 0
? guestWorkPerRender
: OperatingSystem.IsMacOS() ? 256 : 1024;
// On macOS the whole window loop — including Render() and its guest-work // On macOS the whole window loop — including Render() and its guest-work
// drain — runs on the process main thread, so draining a large backlog of // drain — runs on the process main thread, so draining a large backlog of
// slow guest work (heavy compute) blocks the Cocoa event pump and marks the // slow guest work (heavy compute) blocks the Cocoa event pump and marks the
// window "Not Responding" while starving the swapchain present. Cap the // window "Not Responding" while starving the swapchain present. Cap the
// wall-clock time spent draining per Render() call; leftover work stays // wall-clock time spent draining per Render() call; leftover work stays
// queued for the next frame. SHARPEMU_RENDER_WORK_BUDGET_MS overrides // queued for the next frame. SHARPEMU_RENDER_WORK_BUDGET_MS overrides
// (0 disables the cap); default 12ms keeps the window interactive at ~60Hz. // (0 disables the cap); default 12ms keeps the macOS window interactive at
// ~60Hz. Windows and Linux use a dedicated render thread, so they drain
// without a time budget by default.
private static readonly long _renderWorkBudgetTicks = private static readonly long _renderWorkBudgetTicks =
(long.TryParse( (long.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_RENDER_WORK_BUDGET_MS"), Environment.GetEnvironmentVariable("SHARPEMU_RENDER_WORK_BUDGET_MS"),
out var renderBudgetMs) && renderBudgetMs >= 0 out var renderBudgetMs) && renderBudgetMs >= 0
? renderBudgetMs ? renderBudgetMs
: 12L) * System.Diagnostics.Stopwatch.Frequency / 1000L; : OperatingSystem.IsMacOS() ? 12L : 0L) *
System.Diagnostics.Stopwatch.Frequency / 1000L;
// Max time the main-thread Render() will block waiting for a frame slot's // Max time the main-thread Render() will block waiting for a frame slot's
// GPU fence before skipping the frame and returning to the event pump. // GPU fence before skipping the frame and returning to the event pump.
// Prevents the window freezing behind a slow-compute GPU backlog. // Prevents the window freezing behind a slow-compute GPU backlog.
// SHARPEMU_FRAME_WAIT_BUDGET_MS overrides; default 8ms. // SHARPEMU_FRAME_WAIT_BUDGET_MS overrides; default 8ms on macOS. The
// dedicated Windows/Linux render thread may wait for its frame slot so it
// does not drop guest-work drain opportunities under normal GPU load.
private static readonly ulong _frameSlotWaitBudgetNs = private static readonly ulong _frameSlotWaitBudgetNs =
(ulong.TryParse( ulong.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_FRAME_WAIT_BUDGET_MS"), Environment.GetEnvironmentVariable("SHARPEMU_FRAME_WAIT_BUDGET_MS"),
out var frameWaitMs) && frameWaitMs > 0 out var frameWaitMs) && frameWaitMs > 0
? frameWaitMs ? frameWaitMs * 1_000_000UL
: 8UL) * 1_000_000UL; : OperatingSystem.IsMacOS() ? 8_000_000UL : ulong.MaxValue;
// Cap the guest-submission fence wait so a GPU submission whose fence never // Cap the guest-submission fence wait so a GPU submission whose fence never
// signals (a mistranslated compute shader that hangs the Metal queue) cannot // signals (a mistranslated compute shader that hangs the Metal queue) cannot
// freeze the render thread forever and starve the swapchain present. // freeze the render thread forever and starve the swapchain present.
@@ -263,6 +284,9 @@ internal static unsafe class VulkanVideoPresenter
private static readonly HashSet<(ulong Address, uint Width, uint Height)> private static readonly HashSet<(ulong Address, uint Width, uint Height)>
_tracedGuestImageSubmissions = []; _tracedGuestImageSubmissions = [];
private static Thread? _thread; private static Thread? _thread;
private static VulkanHostSurface? _hostSurface;
private static VulkanHostSurface? _hostSurfacePendingDetach;
internal static event Action<VulkanHostSurface>? FirstHostFramePresented;
private static Presentation? _latestPresentation; private static Presentation? _latestPresentation;
private static byte[]? _copyFragmentSpirv; private static byte[]? _copyFragmentSpirv;
private static uint _windowWidth; private static uint _windowWidth;
@@ -463,10 +487,124 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDraw: null, TranslatedDraw: null,
RequiredGuestWorkSequence: 0, RequiredGuestWorkSequence: 0,
IsSplash: false); IsSplash: false);
if (_hostSurface is not null && _latestPresentation is { IsSplash: true })
{
// The GUI keeps its native child hidden while the launcher is
// loading. Reveal it for the real VideoOut splash rather than
// substituting a launcher-side image.
Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted splash ready.");
}
StartPresenterLocked(); StartPresenterLocked();
} }
} }
/// <summary>
/// Selects a same-process native surface for the next VideoOut session.
/// This is intentionally rejected once the presenter has started: Vulkan
/// surface ownership cannot change under an active swapchain.
/// </summary>
public static bool TryAttachHostSurface(VulkanHostSurface surface)
{
ArgumentNullException.ThrowIfNull(surface);
lock (_gate)
{
if (_thread is not null)
{
return false;
}
ResetHostSessionStateLocked();
_hostSurface = surface;
_closed = false;
_presenterCloseRequested = false;
return true;
}
}
/// <summary>
/// Releases the host surface after the guest session has stopped.
/// </summary>
public static void DetachHostSurface(VulkanHostSurface surface)
{
ArgumentNullException.ThrowIfNull(surface);
lock (_gate)
{
if (!ReferenceEquals(_hostSurface, surface))
{
return;
}
if (_thread is null)
{
_hostSurface = null;
}
else
{
_hostSurfacePendingDetach = surface;
}
}
}
internal static bool UsesHostSurface
{
get
{
lock (_gate)
{
return _hostSurface is not null;
}
}
}
private static void NotifyFirstHostFramePresented(VulkanHostSurface surface)
{
// This marker crosses the GUI child-process pipe. The in-process
// event remains for hosts that embed the renderer directly.
Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted first frame presented.");
var callback = FirstHostFramePresented;
if (callback is null)
{
return;
}
try
{
callback(surface);
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Embedded first-frame notification failed: {exception.Message}");
}
}
private static void ResetHostSessionStateLocked()
{
_latestPresentation = null;
_splashHidden = false;
_pendingGuestWorkByQueue.Clear();
_pendingGuestQueueSchedule.Clear();
_pendingGuestQueueCursor = 0;
_pendingGuestWorkCount = 0;
_pendingGuestWorkBytes = 0;
_pendingGuestImagePresentations.Clear();
_guestImageWorkSequences.Clear();
_availableGuestImages.Clear();
_lastOrderedGuestFlipVersions.Clear();
_orderedGuestFlipVersionSequence = 0;
_pendingGuestImageUploads.Clear();
_pendingGuestImageInitialData.Clear();
_guestImageExtents.Clear();
_enqueuedGuestWorkSequence = 0;
_completedGuestWorkSequence = 0;
_completedGuestWorkOutOfOrder.Clear();
_lastEnqueuedGuestWorkByQueue.Clear();
_executingGuestWorkSequence = 0;
_hostSurfacePendingDetach = null;
}
public static void HideSplashScreen() public static void HideSplashScreen()
{ {
lock (_gate) lock (_gate)
@@ -1192,7 +1330,7 @@ internal static unsafe class VulkanVideoPresenter
GuestImageAddress: address); GuestImageAddress: address);
_latestPresentation = presentation; _latestPresentation = presentation;
_pendingGuestImagePresentations.Enqueue(presentation); _pendingGuestImagePresentations.Enqueue(presentation);
while (_pendingGuestImagePresentations.Count > MaxPendingGuestWork) while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
{ {
_pendingGuestImagePresentations.Dequeue(); _pendingGuestImagePresentations.Dequeue();
} }
@@ -1588,7 +1726,7 @@ internal static unsafe class VulkanVideoPresenter
private static void StartPresenterLocked() private static void StartPresenterLocked()
{ {
if (HostMainThread.IsAvailable) if (_hostSurface is null && HostMainThread.IsAvailable)
{ {
// AppKit (and therefore GLFW) traps when touched off the process // AppKit (and therefore GLFW) traps when touched off the process
// main thread on macOS, so hand the whole window loop to the // main thread on macOS, so hand the whole window loop to the
@@ -1756,18 +1894,23 @@ internal static unsafe class VulkanVideoPresenter
{ {
uint width; uint width;
uint height; uint height;
VulkanHostSurface? hostSurface;
lock (_gate) lock (_gate)
{ {
width = _windowWidth == 0 ? _latestPresentation?.Width ?? 1280 : _windowWidth; width = _windowWidth == 0 ? _latestPresentation?.Width ?? 1280 : _windowWidth;
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight; height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
hostSurface = _hostSurface;
} }
if (hostSurface is null)
{
PreferX11OnLinuxWayland(); PreferX11OnLinuxWayland();
InitializeMacVulkanLoader(); InitializeMacVulkanLoader();
}
try try
{ {
using var presenter = new Presenter(width, height); using var presenter = new Presenter(width, height, hostSurface);
presenter.Run(); presenter.Run();
} }
catch (Exception exception) catch (Exception exception)
@@ -1780,6 +1923,12 @@ internal static unsafe class VulkanVideoPresenter
{ {
_closed = true; _closed = true;
_thread = null; _thread = null;
if (_hostSurfacePendingDetach is not null &&
ReferenceEquals(_hostSurface, _hostSurfacePendingDetach))
{
_hostSurface = null;
_hostSurfacePendingDetach = null;
}
System.Threading.Monitor.PulseAll(_gate); System.Threading.Monitor.PulseAll(_gate);
} }
} }
@@ -1885,7 +2034,7 @@ internal static unsafe class VulkanVideoPresenter
while (!_enqueueAsImmediateQueueFollowup && while (!_enqueueAsImmediateQueueFollowup &&
!_closed && !_closed &&
_thread is not null && _thread is not null &&
(_pendingGuestWorkCount >= MaxPendingGuestWork || (_pendingGuestWorkCount >= _maxPendingGuestWorkItems ||
// Always admit one item when no payload is outstanding, even // Always admit one item when no payload is outstanding, even
// when that single item exceeds the configured budget. This // when that single item exceeds the configured budget. This
// avoids an impossible wait while still bounding the normal // avoids an impossible wait while still bounding the normal
@@ -1965,6 +2114,9 @@ internal static unsafe class VulkanVideoPresenter
RecordGuestImageWritersLocked(work, sequence); RecordGuestImageWritersLocked(work, sequence);
_pendingGuestWorkCount++; _pendingGuestWorkCount++;
_pendingGuestWorkBytes = SaturatingAdd(_pendingGuestWorkBytes, payloadBytes); _pendingGuestWorkBytes = SaturatingAdd(_pendingGuestWorkBytes, payloadBytes);
// Wake the embedded render loop parked in WaitForRenderWork; without a
// pulse it only notices new work when its timed wait expires.
System.Threading.Monitor.PulseAll(_gate);
return sequence; return sequence;
} }
@@ -2287,7 +2439,10 @@ internal static unsafe class VulkanVideoPresenter
private const string FullscreenBarycentricFragmentSpirv = private const string FullscreenBarycentricFragmentSpirv =
"AwIjBwAAAQALAAgAEgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ABwAEAAAABAAAAG1haW4AAAAACQAAAAwAAAAQAAMABAAAAAcAAAADAAMAAgAAAMIBAAAFAAQABAAAAG1haW4AAAAABQAFAAkAAABvdXRDb2xvcgAAAAAFAAUADAAAAGJhcnljZW50cmljAEcABAAJAAAAHgAAAAAAAABHAAQADAAAAB4AAAAAAAAAEwACAAIAAAAhAAMAAwAAAAIAAAAWAAMABgAAACAAAAAXAAQABwAAAAYAAAAEAAAAIAAEAAgAAAADAAAABwAAADsABAAIAAAACQAAAAMAAAAXAAQACgAAAAYAAAACAAAAIAAEAAsAAAABAAAACgAAADsABAALAAAADAAAAAEAAAArAAQABgAAAA4AAAAAAAAANgAFAAIAAAAEAAAAAAAAAAMAAAD4AAIABQAAAD0ABAAKAAAADQAAAAwAAABRAAUABgAAAA8AAAANAAAAAAAAAFEABQAGAAAAEAAAAA0AAAABAAAAUAAHAAcAAAARAAAADwAAABAAAAAOAAAADgAAAD4AAwAJAAAAEQAAAP0AAQA4AAEA"; "AwIjBwAAAQALAAgAEgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ABwAEAAAABAAAAG1haW4AAAAACQAAAAwAAAAQAAMABAAAAAcAAAADAAMAAgAAAMIBAAAFAAQABAAAAG1haW4AAAAABQAFAAkAAABvdXRDb2xvcgAAAAAFAAUADAAAAGJhcnljZW50cmljAEcABAAJAAAAHgAAAAAAAABHAAQADAAAAB4AAAAAAAAAEwACAAIAAAAhAAMAAwAAAAIAAAAWAAMABgAAACAAAAAXAAQABwAAAAYAAAAEAAAAIAAEAAgAAAADAAAABwAAADsABAAIAAAACQAAAAMAAAAXAAQACgAAAAYAAAACAAAAIAAEAAsAAAABAAAACgAAADsABAALAAAADAAAAAEAAAArAAQABgAAAA4AAAAAAAAANgAFAAIAAAAEAAAAAAAAAAMAAAD4AAIABQAAAD0ABAAKAAAADQAAAAwAAABRAAUABgAAAA8AAAANAAAAAAAAAFEABQAGAAAAEAAAAA0AAAABAAAAUAAHAAcAAAARAAAADwAAABAAAAAOAAAADgAAAD4AAwAJAAAAEQAAAP0AAQA4AAEA";
private readonly IWindow _window; private readonly IWindow? _window;
private readonly VulkanHostSurface? _hostSurface;
private int _lastHostResizeGeneration;
private bool _embeddedLoopClosed;
private const int MaxInFlightGuestSubmissions = 8; private const int MaxInFlightGuestSubmissions = 8;
private Vk _vk = null!; private Vk _vk = null!;
private KhrSurface _surfaceApi = null!; private KhrSurface _surfaceApi = null!;
@@ -2380,8 +2535,11 @@ internal static unsafe class VulkanVideoPresenter
private long _presentNotTakenLoggedSequence = long.MinValue; private long _presentNotTakenLoggedSequence = long.MinValue;
private bool _vulkanReady; private bool _vulkanReady;
private bool _firstFramePresented; private bool _firstFramePresented;
private bool _firstHostFramePresented;
private bool _firstGuestDrawPresented; private bool _firstGuestDrawPresented;
private bool _splashPresented; private bool _splashPresented;
private Presentation? _lastHostSplashPresentation;
private Presentation? _pendingHostSplashReplay;
private bool _swapchainRecreateDeferred; private bool _swapchainRecreateDeferred;
private bool _tracedPresentedSwapchain; private bool _tracedPresentedSwapchain;
private bool _swapchainReadbackPending; private bool _swapchainReadbackPending;
@@ -2668,11 +2826,22 @@ internal static unsafe class VulkanVideoPresenter
VulkanGuestQueueIdentity Queue, VulkanGuestQueueIdentity Queue,
long WorkSequence); long WorkSequence);
public Presenter(uint width, uint height) public Presenter(uint width, uint height, VulkanHostSurface? hostSurface)
{ {
_hostSurface = hostSurface;
_hostBufferPool = new VulkanHostBufferPool( _hostBufferPool = new VulkanHostBufferPool(
MaximumCachedHostBufferBytes, MaximumCachedHostBufferBytes,
DestroyHostBufferAllocation); DestroyHostBufferAllocation);
if (_hostSurface is not null)
{
_hostSurface.UpdatePixelSize(
_hostSurface.PixelWidth > 0 ? _hostSurface.PixelWidth : (int)width,
_hostSurface.PixelHeight > 0 ? _hostSurface.PixelHeight : (int)height);
_lastHostResizeGeneration = _hostSurface.ResizeGeneration;
return;
}
var options = WindowOptions.DefaultVulkan; var options = WindowOptions.DefaultVulkan;
options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight); options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
options.Title = VideoOutExports.GetWindowTitle(); options.Title = VideoOutExports.GetWindowTitle();
@@ -2694,14 +2863,32 @@ internal static unsafe class VulkanVideoPresenter
}; };
} }
public void Run() => _window.Run(); public void Run()
{
if (_window is not null)
{
_window.Run();
return;
}
Initialize();
while (!_embeddedLoopClosed)
{
Render(0);
// Guest draws and flips execute on this thread. An unconditional
// Thread.Sleep here is quantized to the Windows timer period
// (~15.6ms) and throttles every hosted game far below the GLFW
// path. Wait only while there is genuinely no render work.
WaitForRenderWork();
}
}
public void Dispose() public void Dispose()
{ {
DisposeVulkan(); DisposeVulkan();
try try
{ {
_window.Dispose(); _window?.Dispose();
} }
catch (InvalidOperationException exception) catch (InvalidOperationException exception)
when (exception.Message.Contains("render loop", StringComparison.OrdinalIgnoreCase)) when (exception.Message.Contains("render loop", StringComparison.OrdinalIgnoreCase))
@@ -2712,6 +2899,8 @@ internal static unsafe class VulkanVideoPresenter
} }
private void Initialize() private void Initialize()
{
if (_window is not null)
{ {
LogGlfwPlatformInUse(); LogGlfwPlatformInUse();
if (!OperatingSystem.IsWindows()) if (!OperatingSystem.IsWindows())
@@ -2732,6 +2921,7 @@ internal static unsafe class VulkanVideoPresenter
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels); var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
_window.SetWindowIcon(ref icon); _window.SetWindowIcon(ref icon);
} }
}
WaitForRenderDocAttachIfRequested(); WaitForRenderDocAttachIfRequested();
_vk = Vk.GetApi(); _vk = Vk.GetApi();
@@ -2986,16 +3176,46 @@ internal static unsafe class VulkanVideoPresenter
ApiVersion = Vk.Version12, ApiVersion = Vk.Version12,
}; };
var extensions = _window.VkSurface!.GetRequiredExtensions(out var extensionCount); var hostExtensionNames = _hostSurface is null
? null
: GetHostSurfaceExtensions(_hostSurface.Kind);
byte** extensions;
uint extensionCount;
if (hostExtensionNames is not null)
{
extensions = null;
extensionCount = (uint)hostExtensionNames.Length;
}
else if (_window?.VkSurface is { } glfwSurface)
{
extensions = glfwSurface.GetRequiredExtensions(out extensionCount);
}
else
{
throw new InvalidOperationException("GLFW did not provide Vulkan surface extensions.");
}
byte* debugUtilsExtension = null; byte* debugUtilsExtension = null;
byte* portabilityExtension = null; byte* portabilityExtension = null;
var instanceCreateFlags = InstanceCreateFlags.None; var instanceCreateFlags = InstanceCreateFlags.None;
var enabledExtensionCount = (int)extensionCount; var enabledExtensionCount = (int)extensionCount;
var enabledExtensions = stackalloc byte*[(int)extensionCount + 2]; var enabledExtensions = stackalloc byte*[(int)extensionCount + 2];
var allocatedHostExtensions = hostExtensionNames is null
? null
: new nint[hostExtensionNames.Length];
for (var index = 0; index < (int)extensionCount; index++) for (var index = 0; index < (int)extensionCount; index++)
{
if (hostExtensionNames is null)
{ {
enabledExtensions[index] = extensions[index]; enabledExtensions[index] = extensions[index];
} }
else
{
var extension = (byte*)SilkMarshal.StringToPtr(hostExtensionNames[index]);
allocatedHostExtensions![index] = (nint)extension;
enabledExtensions[index] = extension;
}
}
if (_vulkanDebugUtilsEnabled && if (_vulkanDebugUtilsEnabled &&
IsInstanceExtensionAvailable(DebugUtilsExtensionName)) IsInstanceExtensionAvailable(DebugUtilsExtensionName))
@@ -3065,6 +3285,13 @@ internal static unsafe class VulkanVideoPresenter
{ {
SilkMarshal.Free((nint)portabilityExtension); SilkMarshal.Free((nint)portabilityExtension);
} }
if (allocatedHostExtensions is not null)
{
foreach (var extension in allocatedHostExtensions)
{
SilkMarshal.Free(extension);
}
}
} }
} }
finally finally
@@ -3174,11 +3401,99 @@ internal static unsafe class VulkanVideoPresenter
} }
private void CreateSurface() private void CreateSurface()
{ {
if (_hostSurface is not null)
{
CreateHostSurface(_hostSurface);
return;
}
var instanceHandle = new VkHandle(_instance.Handle); var instanceHandle = new VkHandle(_instance.Handle);
var surfaceHandle = _window.VkSurface!.Create<AllocationCallbacks>(instanceHandle, null); var surfaceHandle = _window!.VkSurface!.Create<AllocationCallbacks>(instanceHandle, null);
_surface = new SurfaceKHR(surfaceHandle.Handle); _surface = new SurfaceKHR(surfaceHandle.Handle);
} }
private static string[] GetHostSurfaceExtensions(VulkanHostSurfaceKind kind) => kind switch
{
VulkanHostSurfaceKind.Win32 => ["VK_KHR_surface", "VK_KHR_win32_surface"],
VulkanHostSurfaceKind.Xlib => ["VK_KHR_surface", "VK_KHR_xlib_surface"],
VulkanHostSurfaceKind.Metal => ["VK_KHR_surface", "VK_EXT_metal_surface"],
_ => throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {kind}."),
};
private void CreateHostSurface(VulkanHostSurface hostSurface)
{
switch (hostSurface.Kind)
{
case VulkanHostSurfaceKind.Win32:
CreateWin32HostSurface(hostSurface);
return;
case VulkanHostSurfaceKind.Xlib:
CreateXlibHostSurface(hostSurface);
return;
case VulkanHostSurfaceKind.Metal:
CreateMetalHostSurface(hostSurface);
return;
default:
throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {hostSurface.Kind}.");
}
}
private void CreateWin32HostSurface(VulkanHostSurface hostSurface)
{
if (!_vk.TryGetInstanceExtension(_instance, out KhrWin32Surface win32Surface))
{
throw new InvalidOperationException("VK_KHR_win32_surface is unavailable.");
}
var createInfo = new Win32SurfaceCreateInfoKHR
{
SType = StructureType.Win32SurfaceCreateInfoKhr,
Hinstance = hostSurface.DisplayHandle != 0
? hostSurface.DisplayHandle
: GetModuleHandleW(null),
Hwnd = hostSurface.WindowHandle,
};
Check(win32Surface.CreateWin32Surface(_instance, &createInfo, null, out _surface), "vkCreateWin32SurfaceKHR");
}
private void CreateXlibHostSurface(VulkanHostSurface hostSurface)
{
if (!_vk.TryGetInstanceExtension(_instance, out KhrXlibSurface xlibSurface))
{
throw new InvalidOperationException("VK_KHR_xlib_surface is unavailable.");
}
var createInfo = new XlibSurfaceCreateInfoKHR
{
SType = StructureType.XlibSurfaceCreateInfoKhr,
Dpy = (nint*)hostSurface.DisplayHandle,
Window = hostSurface.WindowHandle,
};
Check(xlibSurface.CreateXlibSurface(_instance, &createInfo, null, out _surface), "vkCreateXlibSurfaceKHR");
}
private void CreateMetalHostSurface(VulkanHostSurface hostSurface)
{
var proc = _vk.GetInstanceProcAddr(_instance, "vkCreateMetalSurfaceEXT");
if (proc == 0)
{
throw new InvalidOperationException("VK_EXT_metal_surface is unavailable.");
}
var createInfo = new MetalSurfaceCreateInfoEXT
{
SType = StructureType.MetalSurfaceCreateInfoExt,
PLayer = (nint*)hostSurface.MetalLayerHandle,
};
var createSurface = (delegate* unmanaged<Instance, MetalSurfaceCreateInfoEXT*, AllocationCallbacks*, SurfaceKHR*, Result>)proc.Handle;
SurfaceKHR surface;
Check(createSurface(_instance, &createInfo, null, &surface), "vkCreateMetalSurfaceEXT");
_surface = surface;
}
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
private static extern nint GetModuleHandleW(string? moduleName);
private void SelectPhysicalDevice() private void SelectPhysicalDevice()
{ {
uint deviceCount = 0; uint deviceCount = 0;
@@ -3245,8 +3560,11 @@ internal static unsafe class VulkanVideoPresenter
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})"); $"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})");
VideoOutExports.SetSelectedGpuName(selectedName); VideoOutExports.SetSelectedGpuName(selectedName);
if (_window is not null)
{
_window.Title = VideoOutExports.GetWindowTitle(); _window.Title = VideoOutExports.GetWindowTitle();
} }
}
private void LoadComputeDeviceLimits() private void LoadComputeDeviceLimits()
{ {
@@ -7674,15 +7992,19 @@ internal static unsafe class VulkanVideoPresenter
WaitForActiveGuestQueueSubmissionsForCpuVisibility(); WaitForActiveGuestQueueSubmissionsForCpuVisibility();
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name); WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
} }
var mapped = new Span<byte>( // Populate the cached shadow copy first and write it out to the
(void*)(allocation.Mapped + checked((nint)guestOffset)), // mapped allocation in one pass. The mapped memory is
guestBuffer.Length); // HOST_VISIBLE|HOST_COHERENT (write-combined on most drivers),
if (_guestMemory?.TryRead(guestBuffer.BaseAddress, mapped) != true) // so CPU reads from it are uncached and orders of magnitude
// slower than heap reads — never use it as a copy source.
if (_guestMemory?.TryRead(guestBuffer.BaseAddress, shadow) != true)
{ {
source.CopyTo(mapped); source.CopyTo(shadow);
} }
mapped.CopyTo(shadow); shadow.CopyTo(new Span<byte>(
(void*)(allocation.Mapped + checked((nint)guestOffset)),
guestBuffer.Length));
} }
if (ShouldTraceVulkanResources() && if (ShouldTraceVulkanResources() &&
@@ -11219,6 +11541,30 @@ internal static unsafe class VulkanVideoPresenter
} }
} }
private bool _overlayHotkeyWasDown;
private void PollPerfOverlayHotkey()
{
// POSIX hosts route F1 through the GLFW window's keyboard events
// (HostWindowInput.Attach). Windows never attaches that path — its
// input comes from user32 polling — so sample the hotkey here for
// both the standalone window and the embedded host surface.
if (!OperatingSystem.IsWindows())
{
return;
}
const int VkF1 = 0x70;
var input = SharpEmu.HLE.Host.HostPlatform.Current.Input;
var down = input.IsKeyDown(VkF1) && input.IsHostWindowFocused();
if (down && !_overlayHotkeyWasDown)
{
PerfOverlay.Toggle();
}
_overlayHotkeyWasDown = down;
}
private void Render(double _) private void Render(double _)
{ {
try try
@@ -11241,10 +11587,30 @@ internal static unsafe class VulkanVideoPresenter
if (Volatile.Read(ref _presenterCloseRequested)) if (Volatile.Read(ref _presenterCloseRequested))
{ {
Console.Error.WriteLine("[LOADER][WARN] Vulkan VideoOut closing on host shutdown request."); Console.Error.WriteLine("[LOADER][WARN] Vulkan VideoOut closing on host shutdown request.");
if (_window is not null)
{
_window.Close(); _window.Close();
}
else
{
_embeddedLoopClosed = true;
}
return; return;
} }
PollPerfOverlayHotkey();
if (_hostSurface is not null)
{
_hostSurface.RefreshChildProcessPixelSize();
if (_lastHostResizeGeneration != _hostSurface.ResizeGeneration)
{
_lastHostResizeGeneration = _hostSurface.ResizeGeneration;
RecreateSwapchainResources("embedded host resize", Result.SuboptimalKhr);
_pendingHostSplashReplay = _lastHostSplashPresentation;
}
}
if (!_vulkanReady) if (!_vulkanReady)
{ {
return; return;
@@ -11287,7 +11653,7 @@ internal static unsafe class VulkanVideoPresenter
var renderWorkDeadline = _renderWorkBudgetTicks > 0 var renderWorkDeadline = _renderWorkBudgetTicks > 0
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks ? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
: long.MaxValue; : long.MaxValue;
while (completedWork < MaxGuestWorkPerRender) while (completedWork < _maxGuestWorkPerRender)
{ {
// Never block the macOS main thread waiting for in-flight GPU // Never block the macOS main thread waiting for in-flight GPU
// work to drain. If submission is at capacity (a slow-compute // work to drain. If submission is at capacity (a slow-compute
@@ -11295,7 +11661,8 @@ internal static unsafe class VulkanVideoPresenter
// remaining queued work is picked up on later frames as the GPU // remaining queued work is picked up on later frames as the GPU
// completions free up capacity (collected non-blockingly here). // completions free up capacity (collected non-blockingly here).
CollectCompletedGuestSubmissions(waitForOldest: false); CollectCompletedGuestSubmissions(waitForOldest: false);
if (_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions) if (OperatingSystem.IsMacOS() &&
_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
{ {
break; break;
} }
@@ -11411,6 +11778,13 @@ internal static unsafe class VulkanVideoPresenter
CollectAbandonedGuestImageVersions(); CollectAbandonedGuestImageVersions();
if (!TryTakePresentation(_presentedSequence, out var presentation)) if (!TryTakePresentation(_presentedSequence, out var presentation))
{
if (_pendingHostSplashReplay is { } splash)
{
presentation = splash;
_pendingHostSplashReplay = null;
}
else
{ {
// A render-loop tick with no newer flip is normal. Warn only when // A render-loop tick with no newer flip is normal. Warn only when
// an actual queued presentation is waiting on unfinished guest work. // an actual queued presentation is waiting on unfinished guest work.
@@ -11426,6 +11800,20 @@ internal static unsafe class VulkanVideoPresenter
return; return;
} }
}
if (_hostSurface is not null)
{
if (presentation.IsSplash && presentation.Pixels is not null)
{
_lastHostSplashPresentation = presentation;
}
else
{
_lastHostSplashPresentation = null;
_pendingHostSplashReplay = null;
}
}
if (ShouldTracePresentedGuestImageContentsForDiagnostics()) if (ShouldTracePresentedGuestImageContentsForDiagnostics())
{ {
@@ -11449,6 +11837,13 @@ internal static unsafe class VulkanVideoPresenter
{ {
pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height
? sourcePixels ? sourcePixels
: _hostSurface is not null
? ScaleBgraCoverBilinear(
sourcePixels,
presentation.Width,
presentation.Height,
_extent.Width,
_extent.Height)
: ScaleBgra( : ScaleBgra(
sourcePixels, sourcePixels,
presentation.Width, presentation.Width,
@@ -11715,6 +12110,11 @@ internal static unsafe class VulkanVideoPresenter
recreateAfterPresent |= presentResult == Result.SuboptimalKhr; recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
VideoOutExports.ReportPresentedFrame(); VideoOutExports.ReportPresentedFrame();
PerfOverlay.RecordPresent(); PerfOverlay.RecordPresent();
if (_hostSurface is not null && !_firstHostFramePresented)
{
_firstHostFramePresented = true;
NotifyFirstHostFramePresented(_hostSurface);
}
if (_swapchainReadbackPending || !_pendingAliasImageDumps.IsEmpty) if (_swapchainReadbackPending || !_pendingAliasImageDumps.IsEmpty)
{ {
// Diagnostics read back GPU memory and need this frame done. // Diagnostics read back GPU memory and need this frame done.
@@ -13551,12 +13951,35 @@ internal static unsafe class VulkanVideoPresenter
2, 2,
barriers); barriers);
var sourceX = 0u;
var sourceY = 0u;
var sourceWidth = source.Width;
var sourceHeight = source.Height;
if (_hostSurface is not null)
{
// The embedded GUI fills its game surface like CSS
// object-fit: cover. Preserve the guest aspect ratio and crop
// only the excess instead of distorting every video frame.
var sourceIsWider = (ulong)sourceWidth * _extent.Height >
(ulong)_extent.Width * sourceHeight;
if (sourceIsWider)
{
sourceWidth = Math.Max(1u, (uint)((ulong)sourceHeight * _extent.Width / _extent.Height));
sourceX = (source.Width - sourceWidth) / 2;
}
else
{
sourceHeight = Math.Max(1u, (uint)((ulong)sourceWidth * _extent.Height / _extent.Width));
sourceY = (source.Height - sourceHeight) / 2;
}
}
var sourceOffsets = new ImageBlit.SrcOffsetsBuffer var sourceOffsets = new ImageBlit.SrcOffsetsBuffer
{ {
Element0 = new Offset3D(0, 0, 0), Element0 = new Offset3D(checked((int)sourceX), checked((int)sourceY), 0),
Element1 = new Offset3D( Element1 = new Offset3D(
checked((int)source.Width), checked((int)(sourceX + sourceWidth)),
checked((int)source.Height), checked((int)(sourceY + sourceHeight)),
1), 1),
}; };
var destinationOffsets = new ImageBlit.DstOffsetsBuffer var destinationOffsets = new ImageBlit.DstOffsetsBuffer
@@ -13587,9 +14010,9 @@ internal static unsafe class VulkanVideoPresenter
// must blend neighbours or it silently drops every Nth source // must blend neighbours or it silently drops every Nth source
// row/column, which shreds 1-2px features in the guest frame. // row/column, which shreds 1-2px features in the guest frame.
var isIntegerUpscale = var isIntegerUpscale =
source.Width != 0 && source.Height != 0 && sourceWidth != 0 && sourceHeight != 0 &&
_extent.Width >= source.Width && _extent.Height >= source.Height && _extent.Width >= sourceWidth && _extent.Height >= sourceHeight &&
_extent.Width % source.Width == 0 && _extent.Height % source.Height == 0; _extent.Width % sourceWidth == 0 && _extent.Height % sourceHeight == 0;
_vk.CmdBlitImage( _vk.CmdBlitImage(
_commandBuffer, _commandBuffer,
source.Image, source.Image,
@@ -13786,7 +14209,7 @@ internal static unsafe class VulkanVideoPresenter
capabilities.MaxImageExtent.Height)); capabilities.MaxImageExtent.Height));
} }
var size = _window.FramebufferSize; var size = GetFramebufferSize();
return new Extent2D( return new Extent2D(
ClampSurfaceExtent( ClampSurfaceExtent(
(uint)Math.Max(size.X, 1), (uint)Math.Max(size.X, 1),
@@ -13800,6 +14223,21 @@ internal static unsafe class VulkanVideoPresenter
capabilities.MaxImageExtent.Height)); capabilities.MaxImageExtent.Height));
} }
private Vector2D<int> GetFramebufferSize()
{
if (_window is not null)
{
return _window.FramebufferSize;
}
if (_hostSurface is not null)
{
return new Vector2D<int>(_hostSurface.PixelWidth, _hostSurface.PixelHeight);
}
return new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
}
private static uint ClampSurfaceExtent( private static uint ClampSurfaceExtent(
uint value, uint value,
uint fallback, uint fallback,
@@ -13876,6 +14314,88 @@ internal static unsafe class VulkanVideoPresenter
return destination; return destination;
} }
private static byte[] ScaleBgraCoverBilinear(
byte[] source,
uint sourceWidth,
uint sourceHeight,
uint width,
uint height)
{
var destination = new byte[checked((int)(width * height * 4))];
var sourceIsWider = (ulong)sourceWidth * height > (ulong)width * sourceHeight;
var cropWidth = sourceIsWider
? Math.Max(1u, (uint)((ulong)sourceHeight * width / height))
: sourceWidth;
var cropHeight = sourceIsWider
? sourceHeight
: Math.Max(1u, (uint)((ulong)sourceWidth * height / width));
var offsetX = (sourceWidth - cropWidth) / 2;
var offsetY = (sourceHeight - cropHeight) / 2;
var maxSourceX = offsetX + cropWidth - 1;
var maxSourceY = offsetY + cropHeight - 1;
for (uint y = 0; y < height; y++)
{
for (uint x = 0; x < width; x++)
{
var destinationOffset = checked((int)(((ulong)y * width + x) * 4));
float blue = 0;
float green = 0;
float red = 0;
float alpha = 0;
for (var sampleY = 0; sampleY < 2; sampleY++)
{
var scaledY = offsetY + (((y + ((sampleY + 0.5f) / 2)) * cropHeight) / height) - 0.5f;
var sourceY0 = (uint)Math.Clamp((int)MathF.Floor(scaledY), (int)offsetY, (int)maxSourceY);
var sourceY1 = Math.Min(sourceY0 + 1, maxSourceY);
var fractionY = scaledY - MathF.Floor(scaledY);
for (var sampleX = 0; sampleX < 2; sampleX++)
{
var scaledX = offsetX + (((x + ((sampleX + 0.5f) / 2)) * cropWidth) / width) - 0.5f;
var sourceX0 = (uint)Math.Clamp((int)MathF.Floor(scaledX), (int)offsetX, (int)maxSourceX);
var sourceX1 = Math.Min(sourceX0 + 1, maxSourceX);
var fractionX = scaledX - MathF.Floor(scaledX);
var sourceOffset00 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX0) * 4));
var sourceOffset10 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX1) * 4));
var sourceOffset01 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX0) * 4));
var sourceOffset11 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX1) * 4));
var topBlue = source[sourceOffset00] +
((source[sourceOffset10] - source[sourceOffset00]) * fractionX);
var bottomBlue = source[sourceOffset01] +
((source[sourceOffset11] - source[sourceOffset01]) * fractionX);
blue += topBlue + ((bottomBlue - topBlue) * fractionY);
var topGreen = source[sourceOffset00 + 1] +
((source[sourceOffset10 + 1] - source[sourceOffset00 + 1]) * fractionX);
var bottomGreen = source[sourceOffset01 + 1] +
((source[sourceOffset11 + 1] - source[sourceOffset01 + 1]) * fractionX);
green += topGreen + ((bottomGreen - topGreen) * fractionY);
var topRed = source[sourceOffset00 + 2] +
((source[sourceOffset10 + 2] - source[sourceOffset00 + 2]) * fractionX);
var bottomRed = source[sourceOffset01 + 2] +
((source[sourceOffset11 + 2] - source[sourceOffset01 + 2]) * fractionX);
red += topRed + ((bottomRed - topRed) * fractionY);
var topAlpha = source[sourceOffset00 + 3] +
((source[sourceOffset10 + 3] - source[sourceOffset00 + 3]) * fractionX);
var bottomAlpha = source[sourceOffset01 + 3] +
((source[sourceOffset11 + 3] - source[sourceOffset01 + 3]) * fractionX);
alpha += topAlpha + ((bottomAlpha - topAlpha) * fractionY);
}
}
destination[destinationOffset] = (byte)MathF.Round(blue * 0.25f);
destination[destinationOffset + 1] = (byte)MathF.Round(green * 0.25f);
destination[destinationOffset + 2] = (byte)MathF.Round(red * 0.25f);
destination[destinationOffset + 3] = (byte)MathF.Round(alpha * 0.25f);
}
}
return destination;
}
private void DisposeVulkan() private void DisposeVulkan()
{ {
if (!_vulkanReady) if (!_vulkanReady)
@@ -13994,7 +14514,7 @@ internal static unsafe class VulkanVideoPresenter
_surface, _surface,
out var capabilities), out var capabilities),
"vkGetPhysicalDeviceSurfaceCapabilitiesKHR"); "vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
var framebufferSize = _window.FramebufferSize; var framebufferSize = GetFramebufferSize();
var hasFixedExtent = capabilities.CurrentExtent.Width != uint.MaxValue; var hasFixedExtent = capabilities.CurrentExtent.Width != uint.MaxValue;
var surfaceWidth = hasFixedExtent var surfaceWidth = hasFixedExtent
? capabilities.CurrentExtent.Width ? capabilities.CurrentExtent.Width