feat: add Discord rich presence support

This commit is contained in:
DarthM
2026-08-31 02:15:43 -04:00
parent 243eb86021
commit be691bd08d
5 changed files with 532 additions and 2 deletions
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <cstdint>
#include <string>
// Host implementation of Dolphin's /dev/dolphin Discord contract. The guest
// sends only strings and big-endian integer fields; the IPC wire protocol is
// owned here so translated game code never needs host SDK headers.
namespace DiscordPresence {
struct Activity {
std::string details;
std::string state;
std::string largeImageKey;
std::string largeImageText;
std::string smallImageKey;
std::string smallImageText;
int64_t startTimestamp = 0;
int64_t endTimestamp = 0;
uint32_t partySize = 0;
uint32_t partyMax = 0;
};
void Initialize(const std::string& basicClientId, const std::string& basicTitle);
void SetClient(const std::string& clientId);
void SetActivity(Activity activity);
void Reset();
void Shutdown();
} // namespace DiscordPresence
+24
View File
@@ -53,6 +53,11 @@ struct RuntimeUserConfig {
std::optional<bool> audioMixWorker;
std::optional<bool> attenuateMusicWhenMediaPlays;
std::optional<bool> networkEnabled;
std::optional<bool> discordPresenceEnabled;
// The application ID of the WiiCompiled Discord application. This is only
// used by the base product; Retro Rewind supplies its own ID through the
// standard Dolphin /dev/dolphin interface.
std::optional<std::string> discordClientId;
std::optional<std::string> nandRoot;
std::optional<std::string> dvdRoot;
// The one canonical Retro Rewind installation, owned and updated by the frontend. Setup records
@@ -286,6 +291,12 @@ inline void EnsureConfigFile() {
"mix_worker = true\n\n"
"[network]\n"
"enabled = true\n\n"
"[discord]\n"
"# Rich Presence talks only to a locally-running Discord client.\n"
"# Retro Rewind supplies its official app ID automatically. Set this\n"
"# to WiiCompiled's Discord application ID for basic base-game presence.\n"
"enabled = true\n"
"# client_id = \"123456789012345678\"\n\n"
"[paths]\n"
"# dvd_root = \"D:\\\\MarioKartWii\\\\DATA\"\n"
"# nand_root = \"D:\\\\WiiNand\"\n"
@@ -418,6 +429,8 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
config.attenuateMusicWhenMediaPlays =
FindConfigValue<bool>(document, "audio", "attenuate_music_when_media_plays");
config.networkEnabled = FindConfigValue<bool>(document, "network", "enabled");
config.discordPresenceEnabled = FindConfigValue<bool>(document, "discord", "enabled");
config.discordClientId = FindConfigValue<std::string>(document, "discord", "client_id");
config.nandRoot = FindConfigValue<std::string>(document, "paths", "nand_root");
config.dvdRoot = FindConfigValue<std::string>(document, "paths", "dvd_root");
@@ -815,6 +828,14 @@ inline std::string RetroRewindRoot(std::string fallback = "") {
return Get().retroRewindRoot.value_or(std::move(fallback));
}
inline bool DiscordPresenceEnabled(bool fallback = true) {
return Get().discordPresenceEnabled.value_or(fallback);
}
inline std::string DiscordClientId(std::string fallback = "") {
return Get().discordClientId.value_or(std::move(fallback));
}
inline const std::vector<std::string>& OverlayRoots() {
return Get().overlayRoots;
}
@@ -871,6 +892,9 @@ inline void LogLoadedConfig() {
if (config.networkEnabled) {
std::cout << " network_enabled=" << (*config.networkEnabled ? "true" : "false");
}
if (config.discordPresenceEnabled) {
std::cout << " discord_enabled=" << (*config.discordPresenceEnabled ? "true" : "false");
}
if (config.nandRoot) {
std::cout << " nand_root=" << *config.nandRoot;
}
+408
View File
@@ -0,0 +1,408 @@
#include "discord_presence.h"
#include "runtime_log.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include <sstream>
#include <string_view>
#include <utility>
#include <vector>
#if defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#else
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#endif
namespace DiscordPresence {
namespace {
constexpr uint32_t kHandshakeOpcode = 0;
constexpr uint32_t kFrameOpcode = 1;
constexpr size_t kMaxClientIdLength = 32;
bool IsClientId(std::string_view value) {
return !value.empty() && value.size() <= kMaxClientIdLength &&
std::all_of(value.begin(), value.end(), [](unsigned char ch) { return std::isdigit(ch) != 0; });
}
std::string EscapeJson(std::string_view value) {
std::string escaped;
escaped.reserve(value.size());
for (const unsigned char ch : value) {
switch (ch) {
case '\\': escaped += "\\\\"; break;
case '\"': escaped += "\\\""; break;
case '\b': escaped += "\\b"; break;
case '\f': escaped += "\\f"; break;
case '\n': escaped += "\\n"; break;
case '\r': escaped += "\\r"; break;
case '\t': escaped += "\\t"; break;
default:
if (ch < 0x20) {
static constexpr char kHex[] = "0123456789abcdef";
escaped += "\\u00";
escaped += kHex[ch >> 4];
escaped += kHex[ch & 0x0f];
} else {
escaped += static_cast<char>(ch);
}
}
}
return escaped;
}
void AppendJsonString(std::ostringstream& output, bool& hasValue, std::string_view key, std::string_view value) {
if (value.empty()) {
return;
}
if (hasValue) {
output << ',';
}
output << '\"' << key << "\":\"" << EscapeJson(value) << '\"';
hasValue = true;
}
std::string BuildActivityPayload(const Activity& activity) {
std::ostringstream json;
json << "{\"cmd\":\"SET_ACTIVITY\",\"nonce\":\"wiicompiled\",\"args\":{\"pid\":";
#if defined(_WIN32)
json << static_cast<unsigned long>(::GetCurrentProcessId());
#else
json << static_cast<long>(::getpid());
#endif
json << ",\"activity\":{";
bool hasActivityField = false;
AppendJsonString(json, hasActivityField, "details", activity.details);
AppendJsonString(json, hasActivityField, "state", activity.state);
if (hasActivityField) {
json << ',';
}
json << "\"assets\":{";
bool hasAsset = false;
AppendJsonString(json, hasAsset, "large_image", activity.largeImageKey);
AppendJsonString(json, hasAsset, "large_text", activity.largeImageText);
AppendJsonString(json, hasAsset, "small_image", activity.smallImageKey);
AppendJsonString(json, hasAsset, "small_text", activity.smallImageText);
json << "},\"timestamps\":{";
bool hasTimestamp = false;
if (activity.startTimestamp > 0) {
json << "\"start\":" << activity.startTimestamp;
hasTimestamp = true;
}
if (activity.endTimestamp > 0) {
if (hasTimestamp) {
json << ',';
}
json << "\"end\":" << activity.endTimestamp;
}
json << "},\"party\":{";
if (activity.partySize > 0 || activity.partyMax > 0) {
json << "\"size\":[" << activity.partySize << ',' << activity.partyMax << ']';
}
json << "},\"instance\":false}}}";
return json.str();
}
class Client {
public:
void Initialize(const std::string& clientId, const std::string& title) {
std::lock_guard<std::mutex> lock(mutex_);
basicClientId_ = IsClientId(clientId) ? clientId : std::string{};
basicActivity_ = {};
basicActivity_.details = title;
basicActivity_.startTimestamp = UnixSeconds();
customClient_ = false;
activity_ = basicActivity_;
ReconnectAndSendLocked();
}
void SetClient(const std::string& clientId) {
std::lock_guard<std::mutex> lock(mutex_);
if (!IsClientId(clientId)) {
RT_LOG(RT_TAG_RUNTIME) << "Ignoring invalid Discord client ID from /dev/dolphin" << std::endl;
return;
}
if (clientId_ == clientId && connected_) {
return;
}
customClient_ = true;
clientId_ = clientId;
CloseLocked();
ReconnectAndSendLocked();
}
void SetActivity(Activity activity) {
std::lock_guard<std::mutex> lock(mutex_);
if (!customClient_) {
return;
}
activity_ = std::move(activity);
SendActivityLocked();
}
void Reset() {
std::lock_guard<std::mutex> lock(mutex_);
customClient_ = false;
clientId_.clear();
activity_ = basicActivity_;
CloseLocked();
ReconnectAndSendLocked();
}
void Shutdown() {
std::lock_guard<std::mutex> lock(mutex_);
CloseLocked();
}
private:
static int64_t UnixSeconds() {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
std::string ActiveClientIdLocked() const {
return customClient_ ? clientId_ : basicClientId_;
}
bool WriteFrameLocked(uint32_t opcode, std::string_view payload) {
std::array<uint8_t, 8> header{};
const uint32_t size = static_cast<uint32_t>(payload.size());
for (size_t i = 0; i < 4; ++i) {
header[i] = static_cast<uint8_t>(opcode >> (i * 8));
header[4 + i] = static_cast<uint8_t>(size >> (i * 8));
}
return WriteAllLocked(header.data(), header.size()) &&
WriteAllLocked(reinterpret_cast<const uint8_t*>(payload.data()), payload.size());
}
void ReconnectAndSendLocked() {
const std::string clientId = ActiveClientIdLocked();
if (clientId.empty() || !ConnectLocked()) {
return;
}
const std::string handshake = "{\"v\":1,\"client_id\":\"" + clientId + "\"}";
if (!WriteFrameLocked(kHandshakeOpcode, handshake)) {
CloseLocked();
return;
}
if (!ReadReadyLocked()) {
CloseLocked();
return;
}
SendActivityLocked();
}
void SendActivityLocked() {
if (!connected_ && !ConnectLocked()) {
return;
}
if (ActiveClientIdLocked().empty()) {
return;
}
if (!WriteFrameLocked(kFrameOpcode, BuildActivityPayload(activity_))) {
CloseLocked();
}
}
#if defined(_WIN32)
bool ConnectLocked() {
if (connected_) {
return true;
}
for (unsigned int index = 0; index < 10; ++index) {
const std::string name = "\\\\.\\pipe\\discord-ipc-" + std::to_string(index);
handle_ = ::CreateFileA(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr);
if (handle_ != INVALID_HANDLE_VALUE) {
DWORD mode = PIPE_READMODE_BYTE;
::SetNamedPipeHandleState(handle_, &mode, nullptr, nullptr);
connected_ = true;
return true;
}
}
return false;
}
bool WriteAllLocked(const uint8_t* data, size_t size) {
while (size != 0) {
DWORD written = 0;
if (!::WriteFile(handle_, data, static_cast<DWORD>(size), &written, nullptr) || written == 0) {
return false;
}
data += written;
size -= written;
}
return true;
}
bool ReadAllLocked(uint8_t* data, size_t size) {
while (size != 0) {
DWORD read = 0;
if (!::ReadFile(handle_, data, static_cast<DWORD>(size), &read, nullptr) || read == 0) {
return false;
}
data += read;
size -= read;
}
return true;
}
void CloseLocked() {
if (handle_ != INVALID_HANDLE_VALUE) {
::CloseHandle(handle_);
handle_ = INVALID_HANDLE_VALUE;
}
connected_ = false;
}
HANDLE handle_ = INVALID_HANDLE_VALUE;
#else
bool ConnectLocked() {
if (connected_) {
return true;
}
std::array<std::string, 5> roots{};
size_t rootCount = 0;
if (const char* runtimeDir = std::getenv("XDG_RUNTIME_DIR"); runtimeDir && *runtimeDir) {
roots[rootCount++] = runtimeDir;
}
if (const char* tempDir = std::getenv("TMPDIR"); tempDir && *tempDir) {
roots[rootCount++] = tempDir;
}
if (const char* tempDir = std::getenv("TMP"); tempDir && *tempDir && rootCount < roots.size()) {
roots[rootCount++] = tempDir;
}
if (const char* tempDir = std::getenv("TEMP"); tempDir && *tempDir && rootCount < roots.size()) {
roots[rootCount++] = tempDir;
}
roots[rootCount++] = "/tmp";
for (size_t rootIndex = 0; rootIndex < rootCount; ++rootIndex) {
for (unsigned int index = 0; index < 10; ++index) {
const std::string path = roots[rootIndex] + "/discord-ipc-" + std::to_string(index);
if (path.size() >= sizeof(sockaddr_un::sun_path)) {
continue;
}
const int socketFd = ::socket(AF_UNIX, SOCK_STREAM, 0);
if (socketFd < 0) {
continue;
}
sockaddr_un address{};
address.sun_family = AF_UNIX;
std::memcpy(address.sun_path, path.c_str(), path.size() + 1);
if (::connect(socketFd, reinterpret_cast<const sockaddr*>(&address), sizeof(address)) == 0) {
timeval timeout{};
timeout.tv_sec = 1;
::setsockopt(socketFd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
fd_ = socketFd;
connected_ = true;
return true;
}
::close(socketFd);
}
}
return false;
}
bool WriteAllLocked(const uint8_t* data, size_t size) {
while (size != 0) {
const ssize_t written = ::send(fd_, data, size, MSG_NOSIGNAL);
if (written <= 0) {
return false;
}
data += written;
size -= static_cast<size_t>(written);
}
return true;
}
bool ReadAllLocked(uint8_t* data, size_t size) {
while (size != 0) {
const ssize_t read = ::recv(fd_, data, size, 0);
if (read <= 0) {
return false;
}
data += read;
size -= static_cast<size_t>(read);
}
return true;
}
void CloseLocked() {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
connected_ = false;
}
int fd_ = -1;
#endif
bool ReadReadyLocked() {
std::array<uint8_t, 8> header{};
if (!ReadAllLocked(header.data(), header.size())) {
return false;
}
uint32_t opcode = 0;
uint32_t size = 0;
for (size_t i = 0; i < 4; ++i) {
opcode |= static_cast<uint32_t>(header[i]) << (i * 8);
size |= static_cast<uint32_t>(header[4 + i]) << (i * 8);
}
if (opcode != kFrameOpcode || size > 1024 * 1024) {
return false;
}
std::vector<uint8_t> payload(size);
if (!ReadAllLocked(payload.data(), payload.size())) {
return false;
}
const std::string_view json(reinterpret_cast<const char*>(payload.data()), payload.size());
return json.find("\"evt\":\"READY\"") != std::string_view::npos;
}
std::mutex mutex_;
bool connected_ = false;
bool customClient_ = false;
std::string basicClientId_;
std::string clientId_;
Activity basicActivity_;
Activity activity_;
};
Client g_client;
} // namespace
void Initialize(const std::string& basicClientId, const std::string& basicTitle) {
g_client.Initialize(basicClientId, basicTitle);
}
void SetClient(const std::string& clientId) {
g_client.SetClient(clientId);
}
void SetActivity(Activity activity) {
g_client.SetActivity(std::move(activity));
}
void Reset() {
g_client.Reset();
}
void Shutdown() {
g_client.Shutdown();
}
} // namespace DiscordPresence
+63 -2
View File
@@ -4,6 +4,7 @@
#include "nand_internal.h"
#include "discord_presence.h"
#include "runtime_log.h"
extern "C" void OSSleepThread_HLE_801aa9b8(CpuContext* ctx);
@@ -201,9 +202,69 @@ static int32_t HandleDolphinIoctlv(uint32_t cmd, uint32_t numIn, uint32_t numOut
}
case DOLPHIN_IOCTL_SET_SPEED_LIMIT:
case DOLPHIN_IOCTL_DISCORD_SET_CLIENT:
case DOLPHIN_IOCTL_DISCORD_SET_PRESENCE:
return ISFS_OK;
case DOLPHIN_IOCTL_DISCORD_SET_CLIENT: {
if (numIn != 1 || numOut != 0 || vectorPtr == 0) {
return ISFS_EINVAL;
}
const IosVector client = ReadIosVector(vectorPtr, 0);
if (!IsValidGuestRange(client.address, client.size)) {
return ISFS_EINVAL;
}
if (RuntimeConfigFile::DiscordPresenceEnabled()) {
DiscordPresence::SetClient(ReadGuestCString(client.address, client.size));
}
return ISFS_OK;
}
case DOLPHIN_IOCTL_DISCORD_SET_PRESENCE: {
if (numIn != 10 || numOut != 0 || vectorPtr == 0) {
return ISFS_EINVAL;
}
std::array<IosVector, 10> values{};
for (uint32_t index = 0; index < values.size(); ++index) {
values[index] = ReadIosVector(vectorPtr, index);
if (!IsValidGuestRange(values[index].address, values[index].size)) {
return ISFS_EINVAL;
}
}
if (RuntimeConfigFile::DiscordPresenceEnabled()) {
DiscordPresence::Activity activity;
activity.details = ReadGuestCString(values[0].address, values[0].size);
activity.state = ReadGuestCString(values[1].address, values[1].size);
activity.largeImageKey = ReadGuestCString(values[2].address, values[2].size);
activity.largeImageText = ReadGuestCString(values[3].address, values[3].size);
activity.smallImageKey = ReadGuestCString(values[4].address, values[4].size);
activity.smallImageText = ReadGuestCString(values[5].address, values[5].size);
if (values[6].size >= 8 && Memory::Contains(values[6].address, 8)) {
activity.startTimestamp = static_cast<int64_t>(
(static_cast<uint64_t>(Memory::Read32(values[6].address)) << 32) |
Memory::Read32(values[6].address + 4));
}
if (values[7].size >= 8 && Memory::Contains(values[7].address, 8)) {
activity.endTimestamp = static_cast<int64_t>(
(static_cast<uint64_t>(Memory::Read32(values[7].address)) << 32) |
Memory::Read32(values[7].address + 4));
}
if (values[8].size >= 4) {
activity.partySize = Memory::Read32(values[8].address);
}
if (values[9].size >= 4) {
activity.partyMax = Memory::Read32(values[9].address);
}
DiscordPresence::SetActivity(std::move(activity));
}
return ISFS_OK;
}
case DOLPHIN_IOCTL_DISCORD_RESET:
if (numIn != 0 || numOut != 0) {
return ISFS_EINVAL;
}
if (RuntimeConfigFile::DiscordPresenceEnabled()) {
DiscordPresence::Reset();
}
return ISFS_OK;
case DOLPHIN_IOCTL_GET_SYSTEM_TIME: {
+7
View File
@@ -49,6 +49,7 @@
#include "system_bridge.h"
#include "ppc_runtime.h"
#include "aurora_events.h"
#include "discord_presence.h"
#include "fiber_manager.h"
#include "hle_stubs.h"
#include "runtime_config.h"
@@ -1292,6 +1293,9 @@ int RuntimeMain(int argc, char** argv) {
throw std::invalid_argument("The game runtime does not accept command-line options; use Config.toml through the installed host.");
}
RuntimeConfigFile::LogLoadedConfig();
if (RuntimeConfigFile::DiscordPresenceEnabled()) {
DiscordPresence::Initialize(RuntimeConfigFile::DiscordClientId(), "Mario Kart Wii");
}
SystemBridge::Initialize();
TranslatedFunctionRegistry::Finalize();
@@ -1418,6 +1422,7 @@ int RuntimeMain(int argc, char** argv) {
Fiber::GuestFiberManager::Shutdown();
WindowPlacementPersistence::Flush(true);
aurora_shutdown();
DiscordPresence::Shutdown();
SetRuntimeExitCodeImpl(0);
ShutdownProcessTranscript();
return 0;
@@ -1435,6 +1440,7 @@ int RuntimeMain(int argc, char** argv) {
Fiber::GuestFiberManager::Shutdown();
WindowPlacementPersistence::Flush(true);
aurora_shutdown();
DiscordPresence::Shutdown();
ShutdownProcessTranscript();
return 1;
} catch (const std::exception& ex) {
@@ -1446,6 +1452,7 @@ int RuntimeMain(int argc, char** argv) {
Fiber::GuestFiberManager::Shutdown();
WindowPlacementPersistence::Flush(true);
aurora_shutdown();
DiscordPresence::Shutdown();
ShutdownProcessTranscript();
return 1;
}