diff --git a/Directory.Packages.props b/Directory.Packages.props
index e03cb42..b795f3c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,4 +1,4 @@
-
@@ -7,10 +7,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
true
+
+
+
+
+
+
\ No newline at end of file
diff --git a/SharpEmu.slnx b/SharpEmu.slnx
index 9b2a924..3c45742 100644
--- a/SharpEmu.slnx
+++ b/SharpEmu.slnx
@@ -7,6 +7,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs
index 62e94c2..60ec8d2 100644
--- a/src/SharpEmu.CLI/Program.cs
+++ b/src/SharpEmu.CLI/Program.cs
@@ -3,6 +3,7 @@
using SharpEmu.Core.Runtime;
using SharpEmu.Core.Cpu;
+using SharpEmu.GUI;
using SharpEmu.HLE;
using SharpEmu.Logging;
using System.Runtime.InteropServices;
@@ -24,12 +25,32 @@ internal static partial class Program
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF = 0x00000002UL << 28;
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF = 0x00000002UL << 32;
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
+ private const int ATTACH_PARENT_PROCESS = -1;
+ private const int STD_OUTPUT_HANDLE = -11;
+ private const int STD_ERROR_HANDLE = -12;
+ private const uint GENERIC_READ = 0x80000000;
+ private const uint GENERIC_WRITE = 0x40000000;
+ private const uint FILE_SHARE_READ = 0x00000001;
+ private const uint FILE_SHARE_WRITE = 0x00000002;
+ private const uint OPEN_EXISTING = 3;
+ [STAThread]
private static int Main(string[] args)
{
+ args = NormalizeInternalArguments(args, out var isMitigatedChild);
+ if (args.Length == 0 && !isMitigatedChild)
+ {
+ // No arguments: open the desktop frontend. Any argument selects
+ // the classic CLI behavior below.
+ return GuiLauncher.Run();
+ }
+
+ // The executable uses the GUI subsystem, so CLI mode has to connect
+ // itself to a console before the first write.
+ EnsureCliConsole();
+
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
- args = NormalizeInternalArguments(args, out var isMitigatedChild);
if (!isMitigatedChild && TryRunMitigatedChild(args, out var childExitCode))
{
return childExitCode;
@@ -101,6 +122,58 @@ internal static partial class Program
return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4;
}
+ private static void EnsureCliConsole()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ // Standard handles already provided (pipes or file redirection, e.g.
+ // when the GUI or a script launches us): use them as-is.
+ if (IsHandleValid(GetStdHandle(STD_OUTPUT_HANDLE)) && IsHandleValid(GetStdHandle(STD_ERROR_HANDLE)))
+ {
+ return;
+ }
+
+ // Prefer the console of the parent process (interactive terminal);
+ // create one only when started with arguments but no terminal at all
+ // (e.g. a shortcut), so usage and errors remain visible.
+ if (!AttachConsole(ATTACH_PARENT_PROCESS) && GetConsoleWindow() == 0)
+ {
+ _ = AllocConsole();
+ }
+
+ RebindStdHandleToConsole(STD_OUTPUT_HANDLE);
+ RebindStdHandleToConsole(STD_ERROR_HANDLE);
+ }
+
+ private static void RebindStdHandleToConsole(int stdHandle)
+ {
+ if (IsHandleValid(GetStdHandle(stdHandle)) || GetConsoleWindow() == 0)
+ {
+ return;
+ }
+
+ var conOut = CreateFileW(
+ "CONOUT$",
+ GENERIC_READ | GENERIC_WRITE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE,
+ 0,
+ OPEN_EXISTING,
+ 0,
+ 0);
+ if (IsHandleValid(conOut))
+ {
+ _ = SetStdHandle(stdHandle, conOut);
+ }
+ }
+
+ private static bool IsHandleValid(nint handle)
+ {
+ return handle != 0 && handle != -1;
+ }
+
private static string[] NormalizeInternalArguments(string[] args, out bool isMitigatedChild)
{
isMitigatedChild = false;
@@ -670,4 +743,32 @@ internal static partial class Program
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(nint handle);
+
+ [DllImport("kernel32.dll")]
+ private static extern nint GetConsoleWindow();
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool AttachConsole(int processId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool AllocConsole();
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern nint GetStdHandle(int stdHandle);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetStdHandle(int stdHandle, nint handle);
+
+ [DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode)]
+ private static extern nint CreateFileW(
+ string fileName,
+ uint desiredAccess,
+ uint shareMode,
+ nint securityAttributes,
+ uint creationDisposition,
+ uint flagsAndAttributes,
+ nint templateFile);
}
diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj
index 18d24da..67e373c 100644
--- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj
+++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj
@@ -7,16 +7,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
- Exe
+
+ WinExe
SharpEmu
win-x64;linux-x64;osx-arm64
true
true
-
+ true
true
enable
true
@@ -43,4 +46,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
+ <_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
+ Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw'))" />
+
+
+ true
+
+
+
+
diff --git a/src/SharpEmu.CLI/packages.lock.json b/src/SharpEmu.CLI/packages.lock.json
index 4f4620a..1020cc1 100644
--- a/src/SharpEmu.CLI/packages.lock.json
+++ b/src/SharpEmu.CLI/packages.lock.json
@@ -8,6 +8,105 @@
"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",
@@ -59,6 +158,38 @@
"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",
@@ -73,6 +204,16 @@
"SharpEmu.Logging": "[1.0.0, )"
}
},
+ "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, )",
+ "Tmds.DBus.Protocol": "[0.21.3, )"
+ }
+ },
"sharpemu.hle": {
"type": "Project"
},
@@ -89,6 +230,48 @@
"sharpemu.logging": {
"type": "Project"
},
+ "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, )",
@@ -133,9 +316,61 @@
"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",
@@ -143,6 +378,52 @@
}
},
"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",
@@ -150,6 +431,52 @@
}
},
"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",
diff --git a/src/SharpEmu.GUI/App.axaml b/src/SharpEmu.GUI/App.axaml
new file mode 100644
index 0000000..1638b9d
--- /dev/null
+++ b/src/SharpEmu.GUI/App.axaml
@@ -0,0 +1,118 @@
+
+
+
+
+
+ #7C5CFC
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SharpEmu.GUI/App.axaml.cs b/src/SharpEmu.GUI/App.axaml.cs
new file mode 100644
index 0000000..111ef48
--- /dev/null
+++ b/src/SharpEmu.GUI/App.axaml.cs
@@ -0,0 +1,26 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace SharpEmu.GUI;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/src/SharpEmu.GUI/EmulatorProcess.cs b/src/SharpEmu.GUI/EmulatorProcess.cs
new file mode 100644
index 0000000..f7b120d
--- /dev/null
+++ b/src/SharpEmu.GUI/EmulatorProcess.cs
@@ -0,0 +1,645 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using Microsoft.Win32.SafeHandles;
+
+namespace SharpEmu.GUI;
+
+///
+/// Launches the SharpEmu CLI as a child process with the same CET/CFG mitigation
+/// opt-outs the CLI would apply to its own relaunched child, while capturing
+/// stdout/stderr through pipes. The CLI's internal relaunch is suppressed via
+/// SHARPEMU_DISABLE_MITIGATION_RELAUNCH so output is not lost to a detached
+/// console. A kill-on-close job object ties the emulator's lifetime to the GUI.
+///
+internal sealed class EmulatorProcess : IDisposable
+{
+ private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
+ private const uint CREATE_NO_WINDOW = 0x08000000;
+ private const int STARTF_USESTDHANDLES = 0x00000100;
+ private const uint HANDLE_FLAG_INHERIT = 0x00000001;
+ private const uint INFINITE = 0xFFFFFFFF;
+ private const int PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY = 0x00020007;
+ private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
+ private const int JobObjectExtendedLimitInformation = 9;
+ private const ulong PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
+ private const ulong PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF = 0x00000002UL << 28;
+ private const ulong PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF = 0x00000002UL << 32;
+ private const ulong PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
+
+ private readonly object _sync = new();
+ private nint _processHandle;
+ private nint _jobHandle;
+ private Process? _fallbackProcess;
+ private bool _running;
+ private bool _disposed;
+
+ public event Action? OutputReceived;
+
+ public event Action? Exited;
+
+ public bool IsRunning
+ {
+ get
+ {
+ lock (_sync)
+ {
+ return _running;
+ }
+ }
+ }
+
+ public void Start(string exePath, IReadOnlyList arguments, string? workingDirectory)
+ {
+ lock (_sync)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_running)
+ {
+ throw new InvalidOperationException("The emulator process is already running.");
+ }
+
+ if (OperatingSystem.IsWindows())
+ {
+ StartWindows(exePath, arguments, workingDirectory);
+ }
+ else
+ {
+ StartFallback(exePath, arguments, workingDirectory);
+ }
+
+ _running = true;
+ }
+ }
+
+ public void Stop()
+ {
+ lock (_sync)
+ {
+ if (!_running)
+ {
+ return;
+ }
+
+ if (_processHandle != 0)
+ {
+ _ = TerminateProcess(_processHandle, 1);
+ }
+
+ try
+ {
+ _fallbackProcess?.Kill(entireProcessTree: true);
+ }
+ catch (InvalidOperationException)
+ {
+ // Already exited.
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_sync)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ }
+
+ Stop();
+ }
+
+ private void StartWindows(string exePath, IReadOnlyList arguments, string? workingDirectory)
+ {
+ // The CLI would otherwise relaunch itself into a mitigated child whose
+ // console output cannot flow through our pipes.
+ Environment.SetEnvironmentVariable("SHARPEMU_DISABLE_MITIGATION_RELAUNCH", "1");
+
+ var securityAttributes = new SECURITY_ATTRIBUTES
+ {
+ nLength = Marshal.SizeOf(),
+ bInheritHandle = 1,
+ };
+
+ 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.");
+ }
+
+ _ = SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0);
+ _ = SetHandleInformation(stderrRead, HANDLE_FLAG_INHERIT, 0);
+
+ var startupInfoEx = new STARTUPINFOEX();
+ startupInfoEx.StartupInfo.cb = Marshal.SizeOf();
+ startupInfoEx.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
+ startupInfoEx.StartupInfo.hStdOutput = stdoutWrite;
+ startupInfoEx.StartupInfo.hStdError = stderrWrite;
+
+ nint attributeList = 0;
+ nint mitigationPolicies = 0;
+ try
+ {
+ nuint attributeListSize = 0;
+ _ = InitializeProcThreadAttributeList(0, 1, 0, ref attributeListSize);
+ attributeList = Marshal.AllocHGlobal((nint)attributeListSize);
+ if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize the process attribute list.");
+ }
+
+ 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 |
+ PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF;
+
+ mitigationPolicies = Marshal.AllocHGlobal(sizeof(ulong) * 2);
+ Marshal.WriteInt64(mitigationPolicies, unchecked((long)policy1));
+ Marshal.WriteInt64(nint.Add(mitigationPolicies, sizeof(long)), unchecked((long)policy2));
+
+ if (!UpdateProcThreadAttribute(
+ attributeList,
+ 0,
+ PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY,
+ mitigationPolicies,
+ (nuint)(sizeof(ulong) * 2),
+ 0,
+ 0))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to apply the mitigation policy.");
+ }
+
+ var currentDirectory = workingDirectory ?? Environment.CurrentDirectory;
+ var created = CreateProcessW(
+ exePath,
+ new StringBuilder(BuildCommandLine(exePath, arguments)),
+ 0,
+ 0,
+ true,
+ EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW,
+ 0,
+ currentDirectory,
+ ref startupInfoEx,
+ out var processInfo);
+
+ if (!created)
+ {
+ // Some mitigation policy bits (e.g. XFG) are not supported on
+ // older Windows builds. Mirror the CLI's behavior and fall back
+ // to launching without the mitigation attribute list.
+ startupInfoEx.lpAttributeList = 0;
+ created = CreateProcessW(
+ exePath,
+ new StringBuilder(BuildCommandLine(exePath, arguments)),
+ 0,
+ 0,
+ true,
+ CREATE_NO_WINDOW,
+ 0,
+ currentDirectory,
+ ref startupInfoEx,
+ out processInfo);
+ }
+
+ if (!created)
+ {
+ var error = Marshal.GetLastWin32Error();
+ throw new Win32Exception(error, $"Failed to start '{exePath}' (Win32 error {error}: {new Win32Exception(error).Message}).");
+ }
+
+ CloseHandle(processInfo.hThread);
+ _processHandle = processInfo.hProcess;
+
+ _jobHandle = CreateJobObjectW(0, null);
+ if (_jobHandle != 0 &&
+ (!TryEnableKillOnJobClose(_jobHandle) || !AssignProcessToJobObject(_jobHandle, processInfo.hProcess)))
+ {
+ CloseHandle(_jobHandle);
+ _jobHandle = 0;
+ }
+
+ StartReaderThread(stdoutRead, isError: false);
+ StartReaderThread(stderrRead, isError: true);
+ StartExitWatcherThread();
+ }
+ catch
+ {
+ CloseHandle(stdoutRead);
+ CloseHandle(stderrRead);
+ throw;
+ }
+ finally
+ {
+ // The child owns duplicated pipe write ends; closing ours lets the
+ // readers observe EOF when the child exits.
+ CloseHandle(stdoutWrite);
+ CloseHandle(stderrWrite);
+
+ if (attributeList != 0)
+ {
+ DeleteProcThreadAttributeList(attributeList);
+ Marshal.FreeHGlobal(attributeList);
+ }
+
+ if (mitigationPolicies != 0)
+ {
+ Marshal.FreeHGlobal(mitigationPolicies);
+ }
+ }
+ }
+
+ private void StartFallback(string exePath, IReadOnlyList arguments, string? workingDirectory)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ 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
+ {
+ while (reader.ReadLine() is { } line)
+ {
+ OutputReceived?.Invoke(line, isError);
+ }
+ }
+ catch (IOException)
+ {
+ // Pipe broken on process teardown.
+ }
+ })
+ {
+ IsBackground = true,
+ Name = isError ? "SharpEmu stderr reader" : "SharpEmu stdout reader",
+ };
+ thread.Start();
+ }
+
+ private void StartExitWatcherThread()
+ {
+ var processHandle = _processHandle;
+ var thread = new Thread(() =>
+ {
+ _ = WaitForSingleObject(processHandle, INFINITE);
+ var exitCode = GetExitCodeProcess(processHandle, out var rawExitCode)
+ ? unchecked((int)rawExitCode)
+ : -1;
+ OnExited(exitCode);
+ })
+ {
+ IsBackground = true,
+ Name = "SharpEmu exit watcher",
+ };
+ thread.Start();
+ }
+
+ private void OnExited(int exitCode)
+ {
+ lock (_sync)
+ {
+ if (!_running)
+ {
+ return;
+ }
+
+ _running = false;
+ if (_processHandle != 0)
+ {
+ CloseHandle(_processHandle);
+ _processHandle = 0;
+ }
+
+ if (_jobHandle != 0)
+ {
+ CloseHandle(_jobHandle);
+ _jobHandle = 0;
+ }
+
+ _fallbackProcess?.Dispose();
+ _fallbackProcess = null;
+ }
+
+ Exited?.Invoke(exitCode);
+ }
+
+ private static bool TryEnableKillOnJobClose(nint jobHandle)
+ {
+ var extendedLimitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
+ {
+ BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
+ {
+ LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
+ },
+ };
+
+ var size = Marshal.SizeOf();
+ var memory = Marshal.AllocHGlobal(size);
+ try
+ {
+ Marshal.StructureToPtr(extendedLimitInfo, memory, false);
+ return SetInformationJobObject(
+ jobHandle,
+ JobObjectExtendedLimitInformation,
+ memory,
+ unchecked((uint)size));
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(memory);
+ }
+ }
+
+ private static string BuildCommandLine(string processPath, IReadOnlyList args)
+ {
+ var builder = new StringBuilder();
+ builder.Append(QuoteArgument(processPath));
+ for (var i = 0; i < args.Count; i++)
+ {
+ builder.Append(' ');
+ builder.Append(QuoteArgument(args[i]));
+ }
+
+ return builder.ToString();
+ }
+
+ private static string QuoteArgument(string argument)
+ {
+ if (argument.Length == 0)
+ {
+ return "\"\"";
+ }
+
+ var needsQuotes = false;
+ foreach (var c in argument)
+ {
+ if (char.IsWhiteSpace(c) || c == '"')
+ {
+ needsQuotes = true;
+ break;
+ }
+ }
+
+ if (!needsQuotes)
+ {
+ return argument;
+ }
+
+ var builder = new StringBuilder(argument.Length + 2);
+ builder.Append('"');
+
+ var backslashCount = 0;
+ foreach (var c in argument)
+ {
+ if (c == '\\')
+ {
+ backslashCount++;
+ continue;
+ }
+
+ if (c == '"')
+ {
+ builder.Append('\\', (backslashCount * 2) + 1);
+ builder.Append('"');
+ backslashCount = 0;
+ continue;
+ }
+
+ if (backslashCount > 0)
+ {
+ builder.Append('\\', backslashCount);
+ backslashCount = 0;
+ }
+
+ builder.Append(c);
+ }
+
+ if (backslashCount > 0)
+ {
+ builder.Append('\\', backslashCount * 2);
+ }
+
+ builder.Append('"');
+ return builder.ToString();
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SECURITY_ATTRIBUTES
+ {
+ public int nLength;
+ public nint lpSecurityDescriptor;
+ public int bInheritHandle;
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct STARTUPINFO
+ {
+ public int cb;
+ public nint lpReserved;
+ public nint lpDesktop;
+ public nint lpTitle;
+ public int dwX;
+ public int dwY;
+ public int dwXSize;
+ public int dwYSize;
+ public int dwXCountChars;
+ public int dwYCountChars;
+ public int dwFillAttribute;
+ public int dwFlags;
+ public short wShowWindow;
+ public short cbReserved2;
+ public nint lpReserved2;
+ public nint hStdInput;
+ public nint hStdOutput;
+ public nint hStdError;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct STARTUPINFOEX
+ {
+ public STARTUPINFO StartupInfo;
+ public nint lpAttributeList;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct PROCESS_INFORMATION
+ {
+ public nint hProcess;
+ public nint hThread;
+ public int dwProcessId;
+ public int dwThreadId;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
+ {
+ public long PerProcessUserTimeLimit;
+ public long PerJobUserTimeLimit;
+ public uint LimitFlags;
+ public nuint MinimumWorkingSetSize;
+ public nuint MaximumWorkingSetSize;
+ public uint ActiveProcessLimit;
+ public nint Affinity;
+ public uint PriorityClass;
+ public uint SchedulingClass;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct IO_COUNTERS
+ {
+ public ulong ReadOperationCount;
+ public ulong WriteOperationCount;
+ public ulong OtherOperationCount;
+ public ulong ReadTransferCount;
+ public ulong WriteTransferCount;
+ public ulong OtherTransferCount;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
+ {
+ public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
+ public IO_COUNTERS IoInfo;
+ public nuint ProcessMemoryLimit;
+ public nuint JobMemoryLimit;
+ public nuint PeakProcessMemoryUsed;
+ public nuint PeakJobMemoryUsed;
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CreatePipe(
+ out nint hReadPipe,
+ out nint hWritePipe,
+ ref SECURITY_ATTRIBUTES lpPipeAttributes,
+ uint nSize);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetHandleInformation(nint hObject, uint dwMask, uint dwFlags);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool InitializeProcThreadAttributeList(
+ nint lpAttributeList,
+ int dwAttributeCount,
+ int dwFlags,
+ ref nuint lpSize);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool UpdateProcThreadAttribute(
+ nint lpAttributeList,
+ uint dwFlags,
+ nint attribute,
+ nint lpValue,
+ nuint cbSize,
+ nint lpPreviousValue,
+ nint lpReturnSize);
+
+ [DllImport("kernel32.dll")]
+ private static extern void DeleteProcThreadAttributeList(nint lpAttributeList);
+
+ [DllImport("kernel32.dll", EntryPoint = "CreateJobObjectW", SetLastError = true, CharSet = CharSet.Unicode)]
+ private static extern nint CreateJobObjectW(nint lpJobAttributes, string? lpName);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetInformationJobObject(
+ nint hJob,
+ int jobObjectInfoClass,
+ nint lpJobObjectInfo,
+ uint cbJobObjectInfoLength);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool AssignProcessToJobObject(nint hJob, nint hProcess);
+
+ [DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CreateProcessW(
+ string applicationName,
+ StringBuilder commandLine,
+ nint processAttributes,
+ nint threadAttributes,
+ [MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
+ uint creationFlags,
+ nint environment,
+ string currentDirectory,
+ ref STARTUPINFOEX startupInfo,
+ out PROCESS_INFORMATION processInformation);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetExitCodeProcess(nint process, out uint exitCode);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool TerminateProcess(nint process, uint exitCode);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CloseHandle(nint handle);
+}
diff --git a/src/SharpEmu.GUI/GameEntry.cs b/src/SharpEmu.GUI/GameEntry.cs
new file mode 100644
index 0000000..ea27d3b
--- /dev/null
+++ b/src/SharpEmu.GUI/GameEntry.cs
@@ -0,0 +1,22 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.GUI;
+
+public sealed record GameEntry(string Name, string? TitleId, string Path, long SizeBytes)
+{
+ public string Detail => TitleId is not null
+ ? $"{TitleId} • {FormatSize(SizeBytes)}"
+ : $"{FormatSize(SizeBytes)} • {Path}";
+
+ private static string FormatSize(long bytes)
+ {
+ return bytes switch
+ {
+ >= 1L << 30 => $"{bytes / (double)(1L << 30):0.0} GiB",
+ >= 1L << 20 => $"{bytes / (double)(1L << 20):0.0} MiB",
+ >= 1L << 10 => $"{bytes / (double)(1L << 10):0.0} KiB",
+ _ => $"{bytes} B",
+ };
+ }
+}
diff --git a/src/SharpEmu.GUI/GuiLauncher.cs b/src/SharpEmu.GUI/GuiLauncher.cs
new file mode 100644
index 0000000..34bf9ec
--- /dev/null
+++ b/src/SharpEmu.GUI/GuiLauncher.cs
@@ -0,0 +1,47 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using Avalonia;
+
+namespace SharpEmu.GUI;
+
+///
+/// Entry point for the desktop frontend, hosted by the SharpEmu executable
+/// when it is started without command-line arguments.
+///
+public static class GuiLauncher
+{
+ public static int Run()
+ {
+ try
+ {
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(Array.Empty());
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ WriteCrashLog(ex);
+ throw;
+ }
+ }
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+
+ private static void WriteCrashLog(Exception ex)
+ {
+ try
+ {
+ File.AppendAllText(
+ Path.Combine(AppContext.BaseDirectory, "gui-crash.log"),
+ $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {ex}{Environment.NewLine}{Environment.NewLine}");
+ }
+ catch (Exception)
+ {
+ // Crash logging is best-effort.
+ }
+ }
+}
diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs
new file mode 100644
index 0000000..bc31717
--- /dev/null
+++ b/src/SharpEmu.GUI/GuiSettings.cs
@@ -0,0 +1,64 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Text.Json;
+
+namespace SharpEmu.GUI;
+
+public sealed class GuiSettings
+{
+ private static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ WriteIndented = true,
+ };
+
+ public List GameFolders { get; set; } = new();
+
+ public string LogLevel { get; set; } = "Info";
+
+ public int ImportTraceLimit { get; set; }
+
+ public bool StrictDynlibResolution { get; set; }
+
+ public string? EmulatorPath { get; set; }
+
+ // The emulator is portable and keeps its data next to the executable;
+ // the GUI follows the same convention.
+ public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "gui-settings.json");
+
+ public static GuiSettings Load()
+ {
+ try
+ {
+ if (File.Exists(SettingsPath))
+ {
+ var json = File.ReadAllText(SettingsPath);
+ return JsonSerializer.Deserialize(json, SerializerOptions) ?? new GuiSettings();
+ }
+ }
+ catch (Exception)
+ {
+ // Corrupt or unreadable settings fall back to defaults.
+ }
+
+ return new GuiSettings();
+ }
+
+ public void Save()
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(SettingsPath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllText(SettingsPath, JsonSerializer.Serialize(this, SerializerOptions));
+ }
+ catch (Exception)
+ {
+ // Settings persistence is best-effort.
+ }
+ }
+}
diff --git a/src/SharpEmu.GUI/LogLine.cs b/src/SharpEmu.GUI/LogLine.cs
new file mode 100644
index 0000000..452b319
--- /dev/null
+++ b/src/SharpEmu.GUI/LogLine.cs
@@ -0,0 +1,8 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using Avalonia.Media;
+
+namespace SharpEmu.GUI;
+
+public sealed record LogLine(string Text, IBrush Brush);
diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml
new file mode 100644
index 0000000..2ebd422
--- /dev/null
+++ b/src/SharpEmu.GUI/MainWindow.axaml
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs
new file mode 100644
index 0000000..09a986c
--- /dev/null
+++ b/src/SharpEmu.GUI/MainWindow.axaml.cs
@@ -0,0 +1,609 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Collections.Concurrent;
+using System.Collections.ObjectModel;
+using System.Reflection;
+using System.Text.Json;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+
+namespace SharpEmu.GUI;
+
+public partial class MainWindow : Window
+{
+ private const int MaxConsoleLines = 4000;
+
+ private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
+ private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
+ private static readonly IBrush InfoLineBrush = new SolidColorBrush(Color.Parse("#6FA8FF"));
+ private static readonly IBrush WarningLineBrush = new SolidColorBrush(Color.Parse("#E8B341"));
+ private static readonly IBrush ErrorLineBrush = new SolidColorBrush(Color.Parse("#F2777C"));
+ private static readonly IBrush SuccessLineBrush = new SolidColorBrush(Color.Parse("#63D489"));
+
+ private readonly List _allGames = new();
+ private readonly ObservableCollection _visibleGames = new();
+ private readonly ObservableCollection _consoleLines = new();
+ private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
+ private readonly DispatcherTimer _consoleFlushTimer;
+
+ private GuiSettings _settings = new();
+ private EmulatorProcess? _emulator;
+ private string? _emulatorExePath;
+ private bool _isRunning;
+ private int _autoScrollTicks;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ GameList.ItemsSource = _visibleGames;
+ ConsoleList.ItemsSource = _consoleLines;
+
+ _consoleFlushTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromMilliseconds(80),
+ };
+ _consoleFlushTimer.Tick += (_, _) =>
+ {
+ FlushPendingConsoleLines();
+ MaybeAutoScroll();
+ };
+ _consoleFlushTimer.Start();
+
+ TitleBar.PointerPressed += OnTitleBarPointerPressed;
+ GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
+ GameList.DoubleTapped += (_, _) => LaunchSelected();
+ SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
+ AddFolderButton.Click += async (_, _) => await AddFolderAsync();
+ RescanButton.Click += async (_, _) => await RescanLibraryAsync();
+ OpenFileButton.Click += async (_, _) => await OpenFileAsync();
+ LaunchButton.Click += (_, _) => LaunchSelected();
+ StopButton.Click += (_, _) => _emulator?.Stop();
+ ClearLogButton.Click += (_, _) => _consoleLines.Clear();
+ CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
+
+ Opened += async (_, _) => await OnOpenedAsync();
+ Closing += (_, _) => OnWindowClosing();
+ }
+
+ private async Task OnOpenedAsync()
+ {
+ var version = Assembly.GetExecutingAssembly().GetName().Version;
+ if (version is not null)
+ {
+ VersionText.Text = $"v{version.ToString(3)}";
+ }
+
+ _settings = GuiSettings.Load();
+ ApplySettingsToControls();
+ LocateEmulator();
+ await RescanLibraryAsync();
+ }
+
+ private void OnWindowClosing()
+ {
+ ReadControlsIntoSettings();
+ _settings.Save();
+ _consoleFlushTimer.Stop();
+ _emulator?.Dispose();
+ }
+
+ private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ BeginMoveDrag(e);
+ }
+ }
+
+ // ---- Settings <-> controls ----
+
+ private void ApplySettingsToControls()
+ {
+ LogLevelBox.SelectedIndex = _settings.LogLevel.ToLowerInvariant() switch
+ {
+ "trace" => 0,
+ "debug" => 1,
+ "info" => 2,
+ "warning" or "warn" => 3,
+ "error" => 4,
+ "critical" or "fatal" => 5,
+ _ => 2,
+ };
+ TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
+ StrictToggle.IsChecked = _settings.StrictDynlibResolution;
+ }
+
+ private void ReadControlsIntoSettings()
+ {
+ _settings.LogLevel = SelectedLogLevel();
+ _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
+ _settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
+ }
+
+ private string SelectedLogLevel()
+ {
+ return LogLevelBox.SelectedIndex switch
+ {
+ 0 => "Trace",
+ 1 => "Debug",
+ 2 => "Info",
+ 3 => "Warning",
+ 4 => "Error",
+ 5 => "Critical",
+ _ => "Info",
+ };
+ }
+
+ // ---- Emulator discovery ----
+
+ private void LocateEmulator()
+ {
+ var exeName = OperatingSystem.IsWindows() ? "SharpEmu.exe" : "SharpEmu";
+ var baseDirectory = AppContext.BaseDirectory;
+ var candidates = new List();
+ if (!string.IsNullOrWhiteSpace(_settings.EmulatorPath))
+ {
+ candidates.Add(_settings.EmulatorPath);
+ }
+
+ // The GUI and the CLI are the same executable: with arguments it runs
+ // the emulator, so the preferred child process is this process itself.
+ if (Environment.ProcessPath is { } selfPath &&
+ Path.GetFileNameWithoutExtension(selfPath).Equals("SharpEmu", StringComparison.OrdinalIgnoreCase))
+ {
+ candidates.Add(selfPath);
+ }
+
+ candidates.Add(Path.Combine(baseDirectory, exeName));
+ candidates.Add(Path.Combine(baseDirectory, "win-x64", exeName));
+ candidates.Add(Path.Combine(baseDirectory, "..", exeName));
+
+ _emulatorExePath = candidates.FirstOrDefault(File.Exists) is { } found
+ ? Path.GetFullPath(found)
+ : null;
+
+ EmulatorPathText.Text = _emulatorExePath is not null
+ ? $"Emulator: {_emulatorExePath}"
+ : "Emulator: SharpEmu executable not found — build SharpEmu.CLI first.";
+ }
+
+ // ---- Game library ----
+
+ private async Task AddFolderAsync()
+ {
+ var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Choose a folder containing games",
+ AllowMultiple = false,
+ });
+
+ var path = folders.FirstOrDefault()?.TryGetLocalPath();
+ if (string.IsNullOrEmpty(path))
+ {
+ return;
+ }
+
+ if (!_settings.GameFolders.Contains(path, StringComparer.OrdinalIgnoreCase))
+ {
+ _settings.GameFolders.Add(path);
+ _settings.Save();
+ }
+
+ await RescanLibraryAsync();
+ }
+
+ private async Task RescanLibraryAsync()
+ {
+ var folders = _settings.GameFolders.ToArray();
+ StatusBarRight.Text = "Scanning library…";
+
+ var games = await Task.Run(() => ScanFolders(folders));
+
+ _allGames.Clear();
+ _allGames.AddRange(games);
+ RefreshVisibleGames();
+ StatusBarRight.Text = folders.Length == 0
+ ? "Add a game folder to populate the library."
+ : $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
+ }
+
+ private static List ScanFolders(IReadOnlyList folders)
+ {
+ var games = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var enumeration = new EnumerationOptions
+ {
+ IgnoreInaccessible = true,
+ RecurseSubdirectories = true,
+ MaxRecursionDepth = 8,
+ };
+
+ foreach (var folder in folders)
+ {
+ if (!Directory.Exists(folder))
+ {
+ continue;
+ }
+
+ try
+ {
+ foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration))
+ {
+ var fullPath = Path.GetFullPath(file);
+ if (!seen.Add(fullPath))
+ {
+ continue;
+ }
+
+ long size = 0;
+ try
+ {
+ size = new FileInfo(fullPath).Length;
+ }
+ catch (IOException)
+ {
+ }
+
+ var (title, titleId) = TryReadParamJson(fullPath);
+ games.Add(new GameEntry(title ?? GameNameFor(fullPath), titleId, fullPath, size));
+ }
+ }
+ catch (Exception)
+ {
+ // Skip folders that fail to enumerate.
+ }
+ }
+
+ games.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
+ return games;
+ }
+
+ ///
+ /// Reads the game title and title id from sce_sys/param.json next to the
+ /// executable, when present.
+ ///
+ private static (string? Title, string? TitleId) TryReadParamJson(string ebootPath)
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(ebootPath);
+ if (directory is null)
+ {
+ return (null, null);
+ }
+
+ var paramPath = Path.Combine(directory, "sce_sys", "param.json");
+ if (!File.Exists(paramPath))
+ {
+ return (null, null);
+ }
+
+ // ReadAllText handles a UTF-8 BOM, which JsonDocument rejects in
+ // raw bytes.
+ using var document = JsonDocument.Parse(File.ReadAllText(paramPath));
+ var root = document.RootElement;
+
+ string? titleId = null;
+ if (root.TryGetProperty("titleId", out var idElement) && idElement.ValueKind == JsonValueKind.String)
+ {
+ titleId = idElement.GetString();
+ }
+
+ string? title = null;
+ if (root.TryGetProperty("localizedParameters", out var localized) &&
+ localized.ValueKind == JsonValueKind.Object)
+ {
+ if (localized.TryGetProperty("defaultLanguage", out var language) &&
+ language.ValueKind == JsonValueKind.String &&
+ localized.TryGetProperty(language.GetString()!, out var defaultBlock) &&
+ defaultBlock.ValueKind == JsonValueKind.Object &&
+ defaultBlock.TryGetProperty("titleName", out var titleName) &&
+ titleName.ValueKind == JsonValueKind.String)
+ {
+ title = titleName.GetString();
+ }
+ else
+ {
+ foreach (var property in localized.EnumerateObject())
+ {
+ if (property.Value.ValueKind == JsonValueKind.Object &&
+ property.Value.TryGetProperty("titleName", out var anyTitleName) &&
+ anyTitleName.ValueKind == JsonValueKind.String)
+ {
+ title = anyTitleName.GetString();
+ break;
+ }
+ }
+ }
+ }
+
+ return (
+ string.IsNullOrWhiteSpace(title) ? null : title,
+ string.IsNullOrWhiteSpace(titleId) ? null : titleId);
+ }
+ catch (Exception)
+ {
+ return (null, null);
+ }
+ }
+
+ private static string GameNameFor(string ebootPath)
+ {
+ var directory = Path.GetDirectoryName(ebootPath);
+ var name = directory is not null ? Path.GetFileName(directory) : null;
+ return string.IsNullOrEmpty(name) ? Path.GetFileName(ebootPath) : name;
+ }
+
+ private void RefreshVisibleGames()
+ {
+ var query = SearchBox.Text?.Trim() ?? string.Empty;
+ var selected = GameList.SelectedItem as GameEntry;
+
+ _visibleGames.Clear();
+ foreach (var game in _allGames)
+ {
+ if (query.Length == 0 ||
+ game.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ game.Path.Contains(query, StringComparison.OrdinalIgnoreCase))
+ {
+ _visibleGames.Add(game);
+ }
+ }
+
+ GameCountText.Text = _visibleGames.Count.ToString();
+ if (selected is not null && _visibleGames.Contains(selected))
+ {
+ GameList.SelectedItem = selected;
+ }
+
+ UpdateSelectedGame();
+ }
+
+ private void UpdateSelectedGame()
+ {
+ if (GameList.SelectedItem is GameEntry game)
+ {
+ SelectedGameTitle.Text = game.Name;
+ SelectedGamePath.Text = game.Path;
+ }
+ else
+ {
+ SelectedGameTitle.Text = "No game selected";
+ SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
+ }
+
+ UpdateRunButtons();
+ }
+
+ // ---- Launching ----
+
+ private async Task OpenFileAsync()
+ {
+ var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Open an executable to launch",
+ AllowMultiple = false,
+ FileTypeFilter = new[]
+ {
+ new FilePickerFileType("PS executables") { Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } },
+ FilePickerFileTypes.All,
+ },
+ });
+
+ var path = files.FirstOrDefault()?.TryGetLocalPath();
+ if (!string.IsNullOrEmpty(path))
+ {
+ Launch(path, Path.GetFileName(path));
+ }
+ }
+
+ private void LaunchSelected()
+ {
+ if (GameList.SelectedItem is GameEntry game)
+ {
+ Launch(game.Path, game.Name);
+ }
+ }
+
+ private void Launch(string ebootPath, string displayName)
+ {
+ if (_isRunning)
+ {
+ return;
+ }
+
+ if (_emulatorExePath is null)
+ {
+ LocateEmulator();
+ if (_emulatorExePath is null)
+ {
+ AppendConsoleLine("SharpEmu executable not found. Build the SharpEmu.CLI project first (dotnet build).", ErrorLineBrush);
+ return;
+ }
+ }
+
+ ReadControlsIntoSettings();
+ _settings.Save();
+
+ var arguments = new List
+ {
+ "--cpu-engine=native",
+ $"--log-level={_settings.LogLevel.ToLowerInvariant()}",
+ };
+ if (_settings.StrictDynlibResolution)
+ {
+ arguments.Add("--strict");
+ }
+
+ if (_settings.ImportTraceLimit > 0)
+ {
+ arguments.Add($"--trace-imports={_settings.ImportTraceLimit}");
+ }
+
+ arguments.Add(ebootPath);
+
+ _consoleLines.Clear();
+ AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
+
+ var emulator = new EmulatorProcess();
+ emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError));
+ emulator.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code));
+
+ try
+ {
+ emulator.Start(_emulatorExePath, arguments, Path.GetDirectoryName(ebootPath));
+ }
+ catch (Exception ex)
+ {
+ emulator.Dispose();
+ AppendConsoleLine($"Failed to start the emulator: {ex.Message}", ErrorLineBrush);
+ return;
+ }
+
+ _emulator = emulator;
+ _isRunning = true;
+ StatusDot.Fill = SuccessLineBrush;
+ StatusText.Text = $"Running — {displayName}";
+ StatusBarRight.Text = $"Running {displayName}";
+ UpdateRunButtons();
+ }
+
+ private void OnEmulatorExited(int exitCode)
+ {
+ FlushPendingConsoleLines();
+ _isRunning = false;
+ _emulator?.Dispose();
+ _emulator = null;
+
+ var meaning = exitCode switch
+ {
+ 0 => "OK",
+ 1 => "invalid arguments",
+ 2 => "eboot not found",
+ 3 => "runtime exception",
+ 4 => "emulation error",
+ _ => "unknown",
+ };
+ var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush;
+ AppendConsoleLine($"Process exited with code {exitCode} ({meaning}).", brush);
+
+ StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
+ StatusText.Text = $"Exited with code {exitCode} ({meaning})";
+ StatusBarRight.Text = "Idle";
+ UpdateRunButtons();
+ }
+
+ private void UpdateRunButtons()
+ {
+ LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
+ StopButton.IsEnabled = _isRunning;
+ OpenFileButton.IsEnabled = !_isRunning;
+ }
+
+ // ---- Console ----
+
+ private void FlushPendingConsoleLines()
+ {
+ if (_pendingLines.IsEmpty)
+ {
+ return;
+ }
+
+ var incoming = new List();
+ while (_pendingLines.TryDequeue(out var pending))
+ {
+ incoming.Add(new LogLine(pending.Line, BrushForLine(pending.Line)));
+ }
+
+ if (incoming.Count >= MaxConsoleLines)
+ {
+ // A burst larger than the cap: keep only the newest lines.
+ _consoleLines.Clear();
+ for (var i = incoming.Count - MaxConsoleLines; i < incoming.Count; i++)
+ {
+ _consoleLines.Add(incoming[i]);
+ }
+ }
+ else
+ {
+ var overflow = _consoleLines.Count + incoming.Count - MaxConsoleLines;
+ for (var i = 0; i < overflow; i++)
+ {
+ _consoleLines.RemoveAt(0);
+ }
+
+ foreach (var line in incoming)
+ {
+ _consoleLines.Add(line);
+ }
+ }
+
+ _autoScrollTicks = 3;
+ }
+
+ private void AppendConsoleLine(string text, IBrush brush)
+ {
+ _consoleLines.Add(new LogLine(text, brush));
+ _autoScrollTicks = 3;
+ MaybeAutoScroll();
+ }
+
+ private void MaybeAutoScroll()
+ {
+ // ScrollToEnd is applied over a few flush-timer ticks because the
+ // virtualizing panel re-estimates its extent after large batches, and
+ // a single scroll can land short of the true end. A synchronous
+ // ScrollIntoView during rapid adds is avoided entirely — it can crash
+ // the panel with "Invalid Arrange rectangle".
+ if (_autoScrollTicks <= 0 || AutoScrollCheck.IsChecked != true)
+ {
+ return;
+ }
+
+ _autoScrollTicks--;
+ (ConsoleList.Scroll as ScrollViewer)?.ScrollToEnd();
+ }
+
+ private static IBrush BrushForLine(string line)
+ {
+ if (line.Contains("[ERROR]", StringComparison.Ordinal) ||
+ line.Contains("[CRITICAL]", StringComparison.Ordinal))
+ {
+ return ErrorLineBrush;
+ }
+
+ if (line.Contains("[WARNING]", StringComparison.Ordinal))
+ {
+ return WarningLineBrush;
+ }
+
+ if (line.Contains("[INFO]", StringComparison.Ordinal))
+ {
+ return InfoLineBrush;
+ }
+
+ if (line.Contains("[DEBUG]", StringComparison.Ordinal) ||
+ line.Contains("[TRACE]", StringComparison.Ordinal))
+ {
+ return DimLineBrush;
+ }
+
+ return DefaultLineBrush;
+ }
+
+ private async Task CopyConsoleAsync()
+ {
+ if (_consoleLines.Count == 0 || Clipboard is null)
+ {
+ return;
+ }
+
+ var text = string.Join(Environment.NewLine, _consoleLines.Select(line => line.Text));
+ await Clipboard.SetTextAsync(text);
+ }
+}
diff --git a/src/SharpEmu.GUI/SharpEmu.GUI.csproj b/src/SharpEmu.GUI/SharpEmu.GUI.csproj
new file mode 100644
index 0000000..8b6dc25
--- /dev/null
+++ b/src/SharpEmu.GUI/SharpEmu.GUI.csproj
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ false
+ 0.0.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SharpEmu.GUI/packages.lock.json b/src/SharpEmu.GUI/packages.lock.json
new file mode 100644
index 0000000..51e7f67
--- /dev/null
+++ b/src/SharpEmu.GUI/packages.lock.json
@@ -0,0 +1,186 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Avalonia": {
+ "type": "Direct",
+ "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": "Direct",
+ "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": "Direct",
+ "requested": "[11.3.18, )",
+ "resolved": "11.3.18",
+ "contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
+ "dependencies": {
+ "Avalonia": "11.3.18"
+ }
+ },
+ "Avalonia.Themes.Fluent": {
+ "type": "Direct",
+ "requested": "[11.3.18, )",
+ "resolved": "11.3.18",
+ "contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
+ "dependencies": {
+ "Avalonia": "11.3.18"
+ }
+ },
+ "Tmds.DBus.Protocol": {
+ "type": "Direct",
+ "requested": "[0.21.3, )",
+ "resolved": "0.21.3",
+ "contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
+ },
+ "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=="
+ },
+ "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=="
+ }
+ }
+ }
+}
\ No newline at end of file