Skip to content

feat(discord): Discord Rich Presence (C1) - #705

Open
sven-n wants to merge 4 commits into
mainfrom
claude/discord-presence-mumain-597-l2q09d
Open

sven-n wants to merge 4 commits into
mainfrom
claude/discord-presence-mumain-597-l2q09d

Conversation

@sven-n

@sven-n sven-n commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

The player's state now shows in their Discord profile, for example:

Lv 380 / ML 120 Blade Knight
In Blood Castle 5 · Party 3/5

Outside the world it shows "Logging in" or "Selecting a character". The client talks to the local Discord app over its IPC pipe or socket (discord-ipc-N), using the bundled json.hpp. No proprietary SDK is shipped.

How it works

  • Worker thread (PresenceClient): connects, does the handshake, sends SET_ACTIVITY, answers pings, and reconnects every 15 s while Discord is down. It sends at most one update per 4 s, because Discord throttles faster updates. Changes that arrive in between are merged and only the newest is sent. Every wait has a time limit and can be cancelled, so shutdown and the Off setting take effect at once.
  • Frame loop (RichPresence::Update): reads the game state once a second (scene, level and master level, class, map, party, events), builds the activity and passes it to the worker only when it changed. It never waits on Discord. Map changes, level-ups, party changes and entering or leaving events are all picked up by this once-a-second read. The control socket reads map changes the same way, instead of hooking packet handlers.
  • Events with a level: Blood Castle 1–8, Chaos Castle 1–7, Illusion Temple 1–6. Also Devil Square, Doppelganger, Empire Guardian, and Castle Siege (Valley of Loren while the siege is running).
  • Config: config.ini [Discord] holds Presence (On / HideDetails / Off), ApplicationId, LargeImageKey and SmallImageKey. No application id is built in. Without one the presence stays off, because each server owns its own Discord application.
  • Options window: new Discord row (On / Hide details / Off) below Windowed mode. It applies at once. Hide details drops class and level and keeps location and party.
  • Compile switch ENABLE_DISCORD, following the ENABLE_CONTROL_SOCKET pattern. It defaults to ON and is forced OFF on Android and iOS. When it is OFF, Integration/Discord/*.cpp is not compiled, RichPresence.h compiles to a no-op, and the options window keeps its old layout.
  • Platforms: Windows uses a named pipe. Linux and macOS use a Unix socket under XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP or /tmp; on Linux the Flatpak and Snap locations are searched too.
  • Strings: new strings in Game.en.resx and Game.de.resx. Other locales fall back to English.
  • Docs: user doc in docs/discord.md, covering what is shown, the setting, and the server-operator setup. Build-option table and options-window doc updated.

Code lives in src/source/Integration/Discord/ (namespace Integration::Discord), one class per file, so C2–C6 have a home.

Tests: tests/network/test_discord_presence.cpp, 16 cases:

  • IPC framing and its limits
  • handshake and SET_ACTIVITY payloads, including clearing the presence and invalid UTF-8
  • READY and ERROR detection, including Discord's "evt": null replies (this caught a crash on the worker thread)
  • the presence text in each mode
  • on POSIX, a full handshake and publish against a fake Discord socket, and a prompt stop when Discord isn't running

Verification

  • Linux linux-x64 Debug build: full ctest run, 437/437 passed.
  • With MU_ENABLE_DISCORD undefined, the options window and Winmain compile cleanly. The ENABLE_DISCORD=OFF source filter was checked to drop all 7 .cpp files.
  • On the changed lines: clang-format 21.1.8 range check and cppcheck (CI flags) are clean.
  • Not tested yet: a run against a real Discord client, and a Windows build. The Windows named-pipe code (IpcConnection.cpp, _WIN32 branch) has never been compiled or run. Please try it on Windows with an application id set.

Related issues

Closes #697 (part of #696)

Checklist

🤖 Generated with Claude Code

https://claude.ai/code/session_012feEciycvL8PGjsezyRFqj

claude added 2 commits October 9, 2026 08:09
…IPC pipe

Shows the player's state in their Discord profile, e.g.
"Lv 380 / ML 120 Blade Knight" / "In Blood Castle 5 · Party 3/5", with
"Logging in" and "Selecting a character" outside the world.

- Talks to the Discord app over `discord-ipc-N` (named pipe on Windows,
  Unix socket elsewhere, including the Flatpak and Snap locations) with the
  bundled json.hpp; no proprietary SDK.
- A worker thread connects, handshakes, publishes and reconnects; the frame
  loop only reads the game state once a second and hands over the newest
  activity. Updates are paced to what Discord accepts (one per 4 s, newest
  wins). Nothing blocks or breaks when Discord isn't running.
- Application id and image keys come from config.ini [Discord]; none is
  built in. Without an application id the presence stays off.
- Compile switch ENABLE_DISCORD (default ON, forced OFF on Android/iOS);
  without it RichPresence.h compiles to a no-op.

Part of #697.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012feEciycvL8PGjsezyRFqj
The options window gets a Discord row (On / Hide details / Off) below
Windowed mode; it applies at once and is stored as [Discord] Presence.
Builds without ENABLE_DISCORD keep the window as it was.

docs/discord.md describes what is shown, the setting, and how a server
operator sets up the Discord application and its images.

Closes #697.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012feEciycvL8PGjsezyRFqj

@sven-n sven-n left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part of a review pass over the whole Discord integration — this PR plus #707, #708 and #709 here, and the seven server PRs in MUnique/OpenMU.

This is the strongest code of the eleven PRs. The IPC layer in particular is written like someone who assumed the thing on the other end of the pipe might be hostile, which is the right assumption for a local socket that any process can bind:

  • DecodeHeader rejects unknown opcodes and caps the payload at 64 KiB, and the comment in IpcFrame.h names exactly the attack it's preventing ("trusting it would let it make the client allocate whatever it claims"). That's the one bug I went looking for first — a length-prefixed read from an untrusted peer with no cap — and it isn't there.
  • ReadExact handles partial reads, cancellation and deadlines separately, and a torn header closes the connection rather than being retried into a desynchronised stream.
  • OpenEndpoint checks the path against sizeof(sun_path) before the memcpy, sets FD_CLOEXEC, and uses SO_NOSIGPIPE/MSG_NOSIGNAL so a dead Discord can't kill the client with a signal.
  • The recv returning 0 case is treated as the peer's shutdown rather than as a short read, with a comment saying so.

The threading is also clean: m_desired/m_desiredVersion are written under the lock from the caller, m_sentVersion is touched only by the worker, and the destructor sets m_stopping under the lock before joining. I couldn't find a race.

Two comments inline, both minor — one on fixed buffers, one on the default presence mode, which is a product decision rather than a defect.


Generated by Claude Code

Comment thread src/source/Integration/Discord/PresenceText.cpp Outdated
Comment thread src/source/Data/GameConfig/GameConfigConstants.h Outdated
- The presence texts are formatted into strings which grow with the
  translation (Integration::Discord::FormatWide) instead of fixed 128
  character buffers, which cut longer translations silently.
- HideDetails is the default: level and class of the character only go to
  Discord when the player chooses On. The docs say so, and that the
  character's name is never shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012feEciycvL8PGjsezyRFqj
main replaced the options window with the RmlUi COptionWindow, so the
Discord presence setting moves there: a dropdown (id 8) after the theme
row in both themes, hidden in builds without the presence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Discord] C1: Discord Rich Presence

2 participants