diff --git a/src/SharpEmu.GUI/DiscordRichPresence.cs b/src/SharpEmu.GUI/DiscordRichPresence.cs
new file mode 100644
index 0000000..880a102
--- /dev/null
+++ b/src/SharpEmu.GUI/DiscordRichPresence.cs
@@ -0,0 +1,252 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.IO.Pipes;
+using System.Text;
+using System.Text.Json;
+
+namespace SharpEmu.GUI;
+
+///
+/// Minimal Discord Rich Presence client. Speaks Discord's local IPC
+/// protocol (framed JSON over the discord-ipc-N named pipe) directly, so no
+/// external dependency is needed. All failures are silent: no Discord, no
+/// presence, no impact on the launcher.
+///
+public sealed class DiscordRichPresence : IDisposable
+{
+ private const int OpHandshake = 0;
+ private const int OpFrame = 1;
+ private const int MaxFrameBytes = 64 * 1024;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ private readonly object _gate = new();
+ private readonly string _clientId;
+ private readonly AutoResetEvent _signal = new(false);
+ private readonly Thread _worker;
+
+ private NamedPipeClientStream? _pipe;
+ private PresenceState? _desired;
+ private PresenceState? _sent;
+ private volatile bool _disposed;
+
+ private sealed record PresenceState(string Details, string? State, long? StartUnixSeconds);
+
+ public DiscordRichPresence(string clientId)
+ {
+ _clientId = clientId;
+ _worker = new Thread(WorkerLoop)
+ {
+ IsBackground = true,
+ Name = "DiscordRichPresence",
+ };
+ _worker.Start();
+ }
+
+ ///
+ /// Requests the given presence; the worker applies it asynchronously and
+ /// retries (with reconnection) until it is delivered or replaced.
+ ///
+ public void SetPresence(string details, string? state = null, long? startUnixSeconds = null)
+ {
+ lock (_gate)
+ {
+ _desired = new PresenceState(details, state, startUnixSeconds);
+ }
+
+ Trace($"queued details=\"{details}\" state=\"{state}\"");
+ _signal.Set();
+ }
+
+ public void Dispose()
+ {
+ _disposed = true;
+ _signal.Set();
+ ClosePipe();
+ }
+
+ private void WorkerLoop()
+ {
+ while (!_disposed)
+ {
+ // Woken immediately on presence changes; the periodic tick
+ // retries after connection failures (e.g. Discord starts later).
+ _signal.WaitOne(TimeSpan.FromSeconds(15));
+ if (_disposed || _clientId.Length == 0)
+ {
+ continue;
+ }
+
+ PresenceState? desired;
+ lock (_gate)
+ {
+ desired = _desired;
+ }
+
+ if (desired is null || desired == _sent)
+ {
+ continue;
+ }
+
+ try
+ {
+ EnsureConnected();
+ SendActivity(desired);
+ _sent = desired;
+ Trace($"sent details=\"{desired.Details}\" state=\"{desired.State}\"");
+ }
+ catch (Exception exception)
+ {
+ // Connection died (or never existed); reconnect on a later tick.
+ Trace($"send failed: {exception.Message}");
+ ClosePipe();
+ _sent = null;
+ }
+ }
+ }
+
+ private void EnsureConnected()
+ {
+ if (_pipe is { IsConnected: true })
+ {
+ return;
+ }
+
+ ClosePipe();
+ Exception? lastError = null;
+ for (var index = 0; index < 10; index++)
+ {
+ var pipe = new NamedPipeClientStream(
+ ".",
+ $"discord-ipc-{index}",
+ PipeDirection.InOut,
+ PipeOptions.Asynchronous);
+ try
+ {
+ pipe.Connect(500);
+ _pipe = pipe;
+ WriteFrame(OpHandshake, JsonSerializer.Serialize(new
+ {
+ v = 1,
+ client_id = _clientId,
+ }));
+
+ // Discord replies with a READY (or error) frame; consume it
+ // so the pipe buffer stays drained.
+ _ = ReadFrame();
+ return;
+ }
+ catch (Exception exception)
+ {
+ lastError = exception;
+ pipe.Dispose();
+ _pipe = null;
+ }
+ }
+
+ throw lastError ?? new IOException("Discord IPC pipe not found.");
+ }
+
+ private void SendActivity(PresenceState presence)
+ {
+ var payload = new Dictionary
+ {
+ ["cmd"] = "SET_ACTIVITY",
+ ["nonce"] = Guid.NewGuid().ToString(),
+ ["args"] = new Dictionary
+ {
+ ["pid"] = Environment.ProcessId,
+ ["activity"] = new Dictionary
+ {
+ ["details"] = presence.Details,
+ ["state"] = presence.State,
+ ["timestamps"] = presence.StartUnixSeconds is { } start
+ ? new Dictionary { ["start"] = start }
+ : null,
+ ["assets"] = new Dictionary
+ {
+ ["large_image"] = "logo",
+ ["large_text"] = "SharpEmu",
+ },
+ },
+ },
+ };
+
+ WriteFrame(OpFrame, JsonSerializer.Serialize(payload, JsonOptions));
+
+ // Consume Discord's acknowledgement to keep the pipe drained.
+ _ = ReadFrame();
+ }
+
+ private void WriteFrame(int opcode, string json)
+ {
+ var pipe = _pipe ?? throw new IOException("Discord IPC pipe is not connected.");
+ var payload = Encoding.UTF8.GetBytes(json);
+ var frame = new byte[8 + payload.Length];
+ BitConverter.TryWriteBytes(frame.AsSpan(0, 4), opcode);
+ BitConverter.TryWriteBytes(frame.AsSpan(4, 4), payload.Length);
+ payload.CopyTo(frame, 8);
+ pipe.Write(frame, 0, frame.Length);
+ pipe.Flush();
+ }
+
+ private string ReadFrame()
+ {
+ var pipe = _pipe ?? throw new IOException("Discord IPC pipe is not connected.");
+ var header = ReadExactly(pipe, 8);
+ var length = BitConverter.ToInt32(header, 4);
+ if (length is < 0 or > MaxFrameBytes)
+ {
+ throw new IOException($"Discord IPC frame length {length} is out of range.");
+ }
+
+ return Encoding.UTF8.GetString(ReadExactly(pipe, length));
+ }
+
+ private static byte[] ReadExactly(NamedPipeClientStream pipe, int count)
+ {
+ var buffer = new byte[count];
+ var read = 0;
+ while (read < count)
+ {
+ var chunk = pipe.Read(buffer, read, count - read);
+ if (chunk <= 0)
+ {
+ throw new IOException("Discord IPC pipe closed.");
+ }
+
+ read += chunk;
+ }
+
+ return buffer;
+ }
+
+ private static void Trace(string message)
+ {
+ if (string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_LOG_DISCORD"),
+ "1",
+ StringComparison.Ordinal))
+ {
+ Console.Error.WriteLine($"[DISCORD] {message}");
+ }
+ }
+
+ private void ClosePipe()
+ {
+ var pipe = _pipe;
+ _pipe = null;
+ try
+ {
+ pipe?.Dispose();
+ }
+ catch (Exception)
+ {
+ // Best effort; the pipe may already be broken.
+ }
+ }
+}
diff --git a/src/SharpEmu.GUI/EmulatorProcess.cs b/src/SharpEmu.GUI/EmulatorProcess.cs
index f7b120d..c713456 100644
--- a/src/SharpEmu.GUI/EmulatorProcess.cs
+++ b/src/SharpEmu.GUI/EmulatorProcess.cs
@@ -85,6 +85,14 @@ internal sealed class EmulatorProcess : IDisposable
return;
}
+ // Prefer terminating the job: it kills the whole tree, including
+ // any children the emulator spawned, even when the main process
+ // is wedged in a GPU driver call.
+ if (_jobHandle != 0)
+ {
+ _ = TerminateJobObject(_jobHandle, 1);
+ }
+
if (_processHandle != 0)
{
_ = TerminateProcess(_processHandle, 1);
@@ -639,6 +647,10 @@ internal sealed class EmulatorProcess : IDisposable
[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 TerminateJobObject(nint job, uint exitCode);
+
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(nint handle);
diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs
index 85c6c9b..4af24c3 100644
--- a/src/SharpEmu.GUI/GuiSettings.cs
+++ b/src/SharpEmu.GUI/GuiSettings.cs
@@ -31,6 +31,16 @@ public sealed class GuiSettings
public string? EmulatorPath { get; set; }
+ /// Publish launcher/game status to Discord Rich Presence.
+ public bool DiscordRichPresence { get; set; } = true;
+
+ ///
+ /// Discord application ID used for Rich Presence; the default is the
+ /// SharpEmu application. Override to rebrand what Discord shows as
+ /// "Playing …" (register at discord.com/developers/applications).
+ ///
+ public string DiscordClientId { get; set; } = "1525606762248540221";
+
// 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");
diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml
index 80bc6b7..3693610 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml
+++ b/src/SharpEmu.GUI/MainWindow.axaml
@@ -252,10 +252,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
+
+
+
+
+
diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs
index fd3e612..844bb18 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml.cs
+++ b/src/SharpEmu.GUI/MainWindow.axaml.cs
@@ -44,6 +44,13 @@ public partial class MainWindow : Window
private string? _emulatorExePath;
private bool _isRunning;
private int _autoScrollTicks;
+
+ // Discord Rich Presence state.
+ private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ private DiscordRichPresence? _discord;
+ private string? _runningGameName;
+ private string? _runningGameTitleId;
+ private long _runningSinceUnixSeconds;
private int _detailLoadGeneration;
private int _backdropGeneration;
@@ -82,12 +89,17 @@ public partial class MainWindow : Window
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected();
- StopButton.Click += (_, _) => _emulator?.Stop();
+ StopButton.Click += (_, _) => StopEmulator();
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true;
TitleMusicToggle.IsCheckedChanged += (_, _) => OnTitleMusicToggled();
+ DiscordToggle.IsCheckedChanged += (_, _) =>
+ {
+ _settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
+ UpdateDiscordPresence();
+ };
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
CtxLaunch.Click += (_, _) => LaunchSelected();
@@ -164,7 +176,7 @@ public partial class MainWindow : Window
if ((pressed & 0x2000) != 0) // Circle
{
- _emulator?.Stop();
+ StopEmulator();
}
_previousPadButtons = pad.Buttons;
@@ -235,9 +247,45 @@ public partial class MainWindow : Window
_settings = GuiSettings.Load();
ApplySettingsToControls();
LocateEmulator();
+ UpdateDiscordPresence();
await RescanLibraryAsync();
}
+ // ---- Discord Rich Presence ----
+
+ ///
+ /// Publishes the launcher state to Discord: browsing while idle, the
+ /// running game (with elapsed time) during emulation. No-ops when
+ /// disabled or when no Discord application ID is configured.
+ ///
+ private void UpdateDiscordPresence()
+ {
+ if (!_settings.DiscordRichPresence || _settings.DiscordClientId.Length == 0)
+ {
+ _discord?.Dispose();
+ _discord = null;
+ return;
+ }
+
+ _discord ??= new DiscordRichPresence(_settings.DiscordClientId);
+ if (_isRunning && _runningGameName is { } gameName)
+ {
+ _discord.SetPresence(
+ $"Playing {gameName}",
+ _runningGameTitleId,
+ _runningSinceUnixSeconds);
+ }
+ else
+ {
+ // Discord does not render activities without timestamps, so the
+ // browsing state carries the launcher's start time.
+ _discord.SetPresence(
+ "Browsing the library",
+ $"{_allGames.Count} game(s)",
+ _launcherStartUnixSeconds);
+ }
+ }
+
private void OnWindowClosing()
{
ReadControlsIntoSettings();
@@ -245,6 +293,7 @@ public partial class MainWindow : Window
_consoleFlushTimer.Stop();
_gamepadTimer.Stop();
_sndPreview.Stop();
+ _discord?.Dispose();
_emulator?.Dispose();
DropFileLog();
}
@@ -275,6 +324,7 @@ public partial class MainWindow : Window
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
LogToFileToggle.IsChecked = _settings.LogToFile;
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
+ DiscordToggle.IsChecked = _settings.DiscordRichPresence;
}
private void ReadControlsIntoSettings()
@@ -284,6 +334,7 @@ public partial class MainWindow : Window
_settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
_settings.LogToFile = LogToFileToggle.IsChecked == true;
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
+ _settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
}
private string SelectedLogLevel()
@@ -382,6 +433,7 @@ public partial class MainWindow : Window
_allGames.AddRange(games);
RefreshVisibleGames();
LoadGameDetailsInBackground(games);
+ UpdateDiscordPresence();
StatusBarRight.Text = folders.Length == 0
? "Add a game folder to populate the library."
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
@@ -1009,10 +1061,38 @@ public partial class MainWindow : Window
_emulator = emulator;
_isRunning = true;
+ _runningGameName = displayName;
+ _runningGameTitleId = _allGames
+ .FirstOrDefault(game => game.Path.Equals(ebootPath, StringComparison.OrdinalIgnoreCase))?
+ .TitleId;
+ _runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush;
StatusText.Text = $"Running — {displayName}";
StatusBarRight.Text = $"Running {displayName}";
UpdateRunButtons();
+ UpdateDiscordPresence();
+ }
+
+ ///
+ /// Stops the running game and updates status/presence immediately. The
+ /// process-exit path still runs when the corpse is collected, but a game
+ /// wedged in a GPU driver call can keep its process alive for a long
+ /// time after termination — the launcher should not look (or tell
+ /// Discord it is) "playing" during that window.
+ ///
+ private void StopEmulator()
+ {
+ if (!_isRunning)
+ {
+ return;
+ }
+
+ _emulator?.Stop();
+ _runningGameName = null;
+ _runningGameTitleId = null;
+ StatusText.Text = "Stopping…";
+ StatusBarRight.Text = "Stopping…";
+ UpdateDiscordPresence();
}
///
@@ -1069,7 +1149,10 @@ public partial class MainWindow : Window
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
StatusText.Text = $"Exited with code {exitCode} ({meaning})";
StatusBarRight.Text = "Idle";
+ _runningGameName = null;
+ _runningGameTitleId = null;
UpdateRunButtons();
+ UpdateDiscordPresence();
}
private void UpdateRunButtons()