VN Presence

Discord Rich Presence daemon for Visual Novels on Linux — detects games launched via Lutris or Steam, looks them up on VNDB, and displays title, cover art, and playtime in your Discord status.

Github Repo License: AGPL v3 C++23

Discord Rich Presence daemon for Visual Novels on Linux. Detects games launched via Lutris or Steam, looks them up on VNDB, and shows the title, cover art, and total playtime in your Discord status.

Voice channel

Voice channel presence with cover Voice channel presence without cover

User activity

User activity with cover User activity without cover


Features

  • Detects games from Lutris (exact name from wrapper) and Steam (AppID → ACF → store name)
  • Fuzzy title matching against VNDB using trigram similarity + full-width character normalisation
  • Fallback: searches the release endpoint and follows the relation back to the parent VN
  • Suppresses explicit cover images (sexual ≥ 1.80 or violence ≥ 1.80) — title still shows
  • Playtime from Lutris DB (pga.db) or Steam VDF (localconfig.vdf) — no API key needed
  • Discord elapsed timer reflects total hours played, not just this session
  • Editable cache.csv for aliases, hard-links, and SKIP entries — reloaded live while running
  • ignore.txt with exact-match for short entries (< 4 chars) to prevent false positives like sh

How it works

Main loop

Every 5 seconds the daemon runs detection, debounces the result, checks the cache, queries VNDB if needed, and updates Discord.


Detection

Lutris passes the game name explicitly as command-line arguments after lutris-wrapper, so the name is always exact. Steam injects SteamAppId as an environment variable into every game process — the daemon reads it from /proc/<pid>/environ, then finds the matching .acf file across all Steam library paths (including custom drives read from libraryfolders.vdf).

After all candidates are collected, the daemon reads field 22 (starttime) from each process’s /proc/<pid>/stat. Candidates are then sorted ascending by starttime, so the process that launched first is always tried first during VNDB resolution. This makes multi-candidate priority deterministic and reproducible across polls.

📝 Note

See the proc/[PID]/stat field reference for a full breakdown of all stat fields.

💡 Tip

Run with --verbose to see candidates list in the debug output:

1
2
3
4
5
[DEBUG] src/main.cpp:73 4 game candidate(s) found:
  [DEBUG] src/main.cpp:75   [lutris] pid=2037324  name="終ノ空 remake"  starttime(clock ticks)=4906595
  [DEBUG] src/main.cpp:75   [lutris] pid=2086738  name="X-Plane 12"  starttime(clock ticks)=4906701
  [DEBUG] src/main.cpp:75   [steam-appid] pid=2037822  name="心象天儀本線 ~Per aspera ad astra~ Demo"  starttime(clock ticks)=4906857
  [DEBUG] src/main.cpp:75   [lutris] pid=2038526  name="サクラノ詩-櫻の森の上を舞う-"  starttime(clock ticks)=4907260

VNDB resolution

Before the similarity check, full-width ASCII (!2→!2, (→() is normalised to half-width so Japanese game names from Lutris match VNDB’s stored titles. There is also a substring boost — if the query appears inside the returned title, the score is raised to at least 0.6, handling cases where the detected name is a short prefix of a long VNDB title.


Cache TTL

VNDB results are stored persistently in cache.csv. On each cache hit the daemon compares the entry’s cached_at timestamp against VNDB_CACHE_TTL. If the entry is older than the TTL, it is treated as expired and a fresh VNDB query is issued, updating the stored data and timestamp.

This means ratings, cover images, and release dates in the cache are automatically refreshed over time without any manual intervention. The log line when a re-query fires looks like:

1
[INFO] Cache expired for "..."  age=1442min/1440min — re-querying VNDB

Playtime → Discord elapsed timer


Cache file (cache.csv / cache.db)

Located at ~/.config/vn-discord-rpc/cache.csv (default) or cache.db when CACHE_USE_DB = true. Reloaded automatically whenever the file changes on disk.

ColumnPurpose
keyDetected Visual Novels name (the search term)
aliasRedirect this key to a different search term
vndb_idv562, v67, SKIP, or empty
titleRomanised VNDB title (auto-filled)
alt_titleOriginal script title, e.g. Japanese (auto-filled)
image_urlCover image URL (auto-filled, blank if explicit(sexual or violence))
image_sexualVNDB sexual rating 0–2 (auto-filled)
image_violenceVNDB violence rating 0–2 (auto-filled)
ratingVNDB community rating 0–100 (auto-filled)
releasedRelease date (auto-filled)
cached_atUnix timestamp of last write (auto-filled)

Examples:

1
2
3
4
5
6
7
8
# Alias — fix wrong or garbled detection
Nice boat!,School Days,,,,,,,,

# Skip — suppress presence for this title
妹ぱらだいす!2,,SKIP,,,,,,,

# Hard-link — bypass VNDB query, point directly to an entry
My VN Title,,v67,,,,,,,

Ignore list (ignore.txt)

Located at ~/.config/vn-discord-rpc/ignore.txt. One entry per line, # for comments. Reloaded automatically while running.

Matching rules:

  • Entry < 4 characters: exact match (case-insensitive) — (ie. prevents sh matching Higurashi)
  • Entry ≥ 4 characters: case-insensitive substring match

The default file includes common false-positives: Steam runtimes, Proton, Wine helpers, and launchers.

⚠️ Warning

If a title has no VNDB match and is not in the ignore list, the daemon will not add it to ignore automatically. It will retry the VNDB query on every detection. Add a SKIP entry in cache.csv or an entry in ignore.txt to suppress it permanently.


Configuration (src/config.hpp)

🚨 Caution

Be caution on changing IMAGE_SEXUAL and IMAGE_VIOLENCE thresholds in src/config.hpp. Those thresholds are set to 1.80 (slightly below VNDB’s Explicit level of 2.00) because VNDB image ratings are user-reported and may be underrated by a small margin — the 0.20 buffer ensures borderline explicit covers are still suppressed. Raising the threshold above 2.00 would cause explicit (pornographic) cover art to appear in your Discord status, visible to everyone on your friends list. Displaying explicit content in Discord Rich Presence may violates Discord’s Terms of Service and may result in a permanent account ban.

ConstantDefaultDescription
DISCORD_APP_ID1482345564698841189Discord application ID
DISCORD_ACTIVITY_TYPE0Activity type: 0=Game, 1=Streaming, 2=Listening, 3=Watching
IMAGE_SEXUAL1.80Maximum sexual rating before cover is suppressed
IMAGE_VIOLENCE1.80Maximum violence rating before cover is suppressed
IMAGE_VOTECOUNT5Minimum vote count before ratings are trusted
VNDB_MIN_SIMILARITY0.35Minimum trigram score to accept a VNDB match
POLL_INTERVAL5sHow often to scan /proc for running processes
VNDB_CACHE_TTL24hHow long a cache entry is valid before re-querying VNDB
STABLE_TITLE_POLLS2Polls a candidate must be stable before acting
VNDB_MAX_RESULTS1Maximum results per VNDB query (index 0 is used)
CACHE_USE_DBfalseUse SQLite (cache.db) instead of CSV (experimental)
RPC_STATE_READING"Reading"Discord state string while a VN is active
RPC_STATE_IDLE"Idle"Discord state string while idle
RPC_DEFAULT_DETAILS"Playing a Visual Novel"Details line when no VNDB match is found
RPC_SMALL_IMG_KEY"vndb_logo"Discord asset key for the small VNDB logo image
RPC_SMALL_IMG_TEXT"VNDB"Tooltip text for the small image

Architecture

The project is composed of nine focused, single-responsibility modules:

ModuleSource FilesResponsibility
Entry pointmain.cppMain loop, debounce state machine, candidate resolution pipeline
Process scannerprocess_scanner.cpp/.hppIterates /proc, detects Lutris and Steam game processes, reads starttime
Steam detectorsteam_detector.cpp/.hppReads SteamAppId env vars, parses ACF manifests and VDF playtime files
VNDB clientvndb_client.cpp/.hppHTTP POST to VNDB Kana API, trigram matching, release endpoint fallback
VN cachevn_cache.cpp/.hppPersistent CSV/SQLite cache with alias, SKIP, TTL, and live-reload logic
Ignore listignore_list.cpp/.hppLive-reloadable process-name suppression list with exact/substring rules
RPC managerrpc_manager.cpp/.hppDiscord IPC wrapper with rate limiting, deferred flush, and change detection
Configconfig.hppAll compile-time constants in one place
Loggerlogger.hppThread-safe ANSI-coloured logger singleton with LOG_DEBUG/INFO/WARN/ERR macros
Lutris DBlutris_db.cpp/.hppReads and formats playtime from Lutris’s SQLite pga.db

Key Design Decisions

Deterministic multi-candidate priority — when multiple games are running, candidates are sorted by /proc/<pid>/stat field 22 (starttime, clock ticks since boot). The process that launched earliest is always tried first, making priority stable and reproducible across polls without any user configuration.

Discord rate-limit compliance — Discord silently drops SET_ACTIVITY calls faster than ~15 seconds apart. RpcManager tracks the wall-clock time of the last successful push and defers any call that falls within a 16-second window. Deferred updates are flushed on the next runCallbacks() tick, ensuring no update is ever lost.

No presence flicker on stable sessions — the early-out in the main loop checks whether the currently displayed VN is still in the candidate set before doing any cache or VNDB work. If it is still running, the loop sleeps immediately, so Discord is never unnecessarily cleared and re-set.

Cache-first, ignore-list-second — VNDB HTTP queries are only issued on a true cache miss or TTL expiry. Candidates that fail VNDB resolution are added to the ignore list so subsequent polls skip them instantly, letting the next candidate be tried without any network round trip.

Live file reloading without restart — both cache.csv and ignore.txt are checked for mtime changes on every poll using std::filesystem::last_write_time. Edits made while the daemon is running take effect within one poll cycle with no signals or process restart needed.

Explicit content safety — image ratings are only trusted when backed by at least IMAGE_VOTECOUNT votes. Cover URLs are stripped from cache entries for explicit results so they can never accidentally appear after a cache reload even if thresholds are later changed.


Building

📌 Important

Initialise submodules before building — the two header-only dependencies are not downloaded automatically.

1
git submodule update --init --recursive
1
2
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build

Dependencies

LibraryPurpose
discord-presenceDiscord Rich Presence (modern C++ rewrite)
nlohmann/jsonJSON parsing
libcurlHTTP requests to VNDB API
libsqlite3Read Lutris playtime database

Usage

💡 Tip

Run with --verbose if a title is not being detected or matched — the debug output shows exactly which process was found, what VNDB returned, and the similarity score.

1
2
3
./vn-discord-rpc            # normal
./vn-discord-rpc --verbose  # debug logging
./vn-discord-rpc --help

Launch a game through Lutris or Steam, and the daemon will detect it automatically. Press Ctrl+C to quit cleanly.


File locations

FilePath
Cache (CSV)~/.config/vn-discord-rpc/cache.csv
Cache (SQLite, experimental)~/.config/vn-discord-rpc/cache.db
Ignore list~/.config/vn-discord-rpc/ignore.txt
Lutris DB~/.local/share/lutris/pga.db
Steam VDF~/.local/share/Steam/userdata/<id>/config/localconfig.vdf
Licensed under CC BY-NC-SA 4.0