diff --git a/runtime/include/discord_presence.h b/runtime/include/discord_presence.h new file mode 100644 index 0000000..3bd6025 --- /dev/null +++ b/runtime/include/discord_presence.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +// 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 diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index c028a2b..f7b0a4c 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -53,6 +53,11 @@ struct RuntimeUserConfig { std::optional audioMixWorker; std::optional attenuateMusicWhenMediaPlays; std::optional networkEnabled; + std::optional 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 discordClientId; std::optional nandRoot; std::optional 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(document, "audio", "attenuate_music_when_media_plays"); config.networkEnabled = FindConfigValue(document, "network", "enabled"); + config.discordPresenceEnabled = FindConfigValue(document, "discord", "enabled"); + config.discordClientId = FindConfigValue(document, "discord", "client_id"); config.nandRoot = FindConfigValue(document, "paths", "nand_root"); config.dvdRoot = FindConfigValue(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& 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; } diff --git a/runtime/src/discord_presence.cpp b/runtime/src/discord_presence.cpp new file mode 100644 index 0000000..f312cd0 --- /dev/null +++ b/runtime/src/discord_presence.cpp @@ -0,0 +1,408 @@ +#include "discord_presence.h" + +#include "runtime_log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#include +#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(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(::GetCurrentProcessId()); +#else + json << static_cast(::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 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 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 lock(mutex_); + if (!customClient_) { + return; + } + activity_ = std::move(activity); + SendActivityLocked(); + } + + void Reset() { + std::lock_guard lock(mutex_); + customClient_ = false; + clientId_.clear(); + activity_ = basicActivity_; + CloseLocked(); + ReconnectAndSendLocked(); + } + + void Shutdown() { + std::lock_guard lock(mutex_); + CloseLocked(); + } + +private: + static int64_t UnixSeconds() { + return std::chrono::duration_cast( + 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 header{}; + const uint32_t size = static_cast(payload.size()); + for (size_t i = 0; i < 4; ++i) { + header[i] = static_cast(opcode >> (i * 8)); + header[4 + i] = static_cast(size >> (i * 8)); + } + return WriteAllLocked(header.data(), header.size()) && + WriteAllLocked(reinterpret_cast(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(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(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 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(&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(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(read); + } + return true; + } + + void CloseLocked() { + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } + connected_ = false; + } + + int fd_ = -1; +#endif + + bool ReadReadyLocked() { + std::array 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(header[i]) << (i * 8); + size |= static_cast(header[4 + i]) << (i * 8); + } + if (opcode != kFrameOpcode || size > 1024 * 1024) { + return false; + } + std::vector payload(size); + if (!ReadAllLocked(payload.data(), payload.size())) { + return false; + } + const std::string_view json(reinterpret_cast(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 diff --git a/runtime/src/hle/storage/nand_isfs.cpp b/runtime/src/hle/storage/nand_isfs.cpp index d8a0887..6ea0ad4 100644 --- a/runtime/src/hle/storage/nand_isfs.cpp +++ b/runtime/src/hle/storage/nand_isfs.cpp @@ -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 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( + (static_cast(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( + (static_cast(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: { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 316220e..841cdd2 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -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; }