Status: all five launchers shipped (Steam 2026-09-25, Epic/GOG/Origin/
EA app/Battle.net + foreground ranking 2026-10-04) — issue
#239.
Privacy posture: local reads only. No storefront web/partner APIs, no
telemetry transport, no process-memory inspection. Same contract as
telemetry.md: detection reads manifest files and registry
keys on the user's machine, and nothing leaves the device.
Rivulet's game-capture picker listed "games" purely by a window-size/title heuristic (non-empty title, >640×480) — it cannot tell a game apart from a large browser window. Launcher-based identification resolves installed games and the currently running game from the storefront launchers the user actually owns.
| Launcher | Installed games (catalog) | Running game | Status |
|---|---|---|---|
| Steam | HKCU\SOFTWARE\Valve\Steam → SteamPath; steamapps\libraryfolders.vdf lists every library; per game steamapps\appmanifest_<appid>.acf (name, installdir, StateFlags bit 2 = installed); installdir resolves to steamapps\common\<installdir> and is scanned for launch executables on demand (see below); the root itself comes from HKCU\SOFTWARE\Valve\Steam on Windows and from the XDG data directory elsewhere (see below) |
HKLM\SOFTWARE\WOW6432Node\Valve\Steam\Apps\<appid> → Running = DWORD:1 |
✅ shipped (rivulet-core/src/game_detection.rs) |
| Epic Games Store | HKLM\SOFTWARE\WOW6432Node\Epic Games\EpicGamesLauncher → AppDataPath; JSON manifests ...\Data\Manifests\*.item (AppName, DisplayName, InstallLocation, LaunchExecutable) |
— (foreground-window scoring) | ✅ shipped |
| GOG | HKLM\SOFTWARE\WOW6432Node\GOG.com\Games per-game keys (gameID, name, path, exe) |
— (foreground-window scoring) | ✅ shipped |
| Origin / EA app | %ProgramData%\Origin\LocalContent\<Game>\*.mfst (&id= / origin2://game/<id>); EA app under %ProgramData%\EA Desktop\LocalContent |
— (foreground-window scoring) | ✅ shipped |
| Battle.net | HKLM\SOFTWARE\WOW6432Node\Blizzard Entertainment per-title keys (InstallPath, ExecutablePath) |
— (foreground-window scoring) | ✅ shipped |
Launchers without a running signal (Epic, GOG, Origin/EA app, Battle.net)
contribute through foreground-window matching: when a heuristic window's
title matches an installed game's launch executable or display name, the
window resolves to that manifest identity (Score::Medium) instead of
staying a raw window title. The comparison is exact after normalization
(case, - Steam, :: … decorations, punctuation are stripped), so
Diablo II never matches Diablo IV.
Steam's appmanifest_*.acf carries no LaunchExecutable — the field
Epic and Battle.net publish and Steam does not. Without an executable list,
a Steam game could only ever be matched by its display name, so a window
titled after the binary (hl2.exe, Cyberpunk2077.exe) stayed a raw
heuristic window.
scan_steam_executables(game_dir) therefore reads the .exe files of the
resolved install directory plus a bounded number of immediate
subdirectories, sorted and deduplicated. Two hard bounds keep it cheap and
predictable:
- at most 32 executables per game (
MAX_EXECUTABLES_PER_GAME) — a folder can hold hundreds of.exefiles (redist runtimes, launchers, anti-cheat stubs) that can never own a top-level window; - at most 8 subdirectories per game (
MAX_SUBDIRS_PER_GAME) — Steam games nest their entry point one level down often enough (<game>/<publisher>/game.exe), but a full recursive walk of a 100 GB install tree is not worth a title comparison.
The scan is lazy, not eager. list_installed_games_in_library() only
resolves installdir to a real path and never touches the install tree,
because it runs on the GUI thread. detect_running_game() first ranks by
the free signals (Steam's registry Running = 1, display names) and only
runs enrich_executables() if a heuristic window is still unexplained.
The measured cost of the eager variant was 3.3 s for a 120-game
library on the GUI thread; lazy plus the per-session
STEAM_EXECUTABLE_CACHE pays it at most once per game directory, and
usually not at all.
Trade-off: a game installed while Rivulet runs keeps an empty executable
list until the cache is dropped. rescan_steam_executables() is the
explicit counterpart — it drops the cache and clears the lists, because
enrich_executables() deliberately skips games that already have
executables. It is not wired into the picker refresh; a stale-but-instant
list beats a correct-but-blocking one.
The scene-device picker shows a mark next to every section header, drawn by
Rivulet itself (rivulet-gui/src/launcher_icons.rs). These are not the
stores' logos, and that is a licensing decision, not a shortcut:
| Vendor | Guideline | Consequence |
|---|---|---|
| Epic | Trademark Usage Guidelines (Non-Licensee): logos must not be used "without express written permission" | written permission required |
| Valve | Steam Branding Guidelines: the logo must "stand alone and may not be combined with any object, including but not limited to other logos" | a row of launcher logos violates it |
| Blizzard | own Logo & Trademark Guidelines | no blanket third-party grant |
| EA | brand guidelines gate third-party branding | approval required |
| CD PROJEKT | logotypes released via media contacts only | press use, not a blanket grant |
So the marks are plain geometry painted with egui's Painter — bars,
chevrons, a ring, a diamond, a hexagon, petals, and a window frame for the
heuristic fallback. None of them reproduces a storefront logo: no gear, no
shield, no ribbon, no wordmark. Colours are brand-adjacent but darkened and
desaturated, so a store stays tellable apart without imitating its mark.
Consequences of drawing them instead of shipping assets:
- no third-party trademark is distributed by Rivulet,
- no binary assets in the repo, and nothing to re-license on upgrade,
- crisp at any DPI and follows the UI's own scaling,
rivulet-core/tests/ci_pinning.rs(launcher_marks_are_self_designed_and_embed_no_vendor_logos) fails if a vendor logo file is ever embedded in the GUI crate or if the marks stop being painted geometry.
| Launcher | Shape |
|---|---|
| Steam | ascending bars |
| Epic | chevron pair |
| GOG | ring with centre dot |
| Origin | diamond |
| EA app | hexagon |
| Battle.net | four petals |
| (heuristic) | window frame |
| Score | Evidence |
|---|---|
High |
Launcher signal (Steam: Running = 1 registry value) |
Medium |
Foreground window matches an installed game's executable/name |
Low |
Size/title window heuristic (existing enumerate_game_windows list) |
detect_running_game() returns the ranked list: Steam's running signal
(High), then foreground matches (Medium), then every heuristic window
(Low). The scene-item picker renders it grouped by launcher — Steam, Epic,
GOG, Origin, EA app, Battle.net — with the raw heuristic windows last under
an "Other windows" header. The ranking itself is the pure function
rank_candidates(installed, running_app_ids, windows), so the confidence
order is unit-tested without any launcher or game installed.
Steam is the one launcher whose installed-game source is not a Windows
registry, and it is the one that works on Linux today. Instead of
HKCU\SOFTWARE\Valve\Steam -> SteamPath, the root is resolved through the
XDG base directories, in this probe order (first existing wins):
$XDG_DATA_HOME/Steam(default~/.local/share/Steam) — where Steam actually installs~/.steam/steamand~/.steam/root— the desktop-entry aliases, usually symlinks to (1). Probing rather than merging is deliberate: reading several aliases of one directory would enumerate the same games twice~/.var/app/com.valvesoftware.Steam/data/Steam— the Flatpak install
A missing candidate is skipped and a completely absent Steam degrades to an
empty catalog, never to an error. $XDG_DATA_HOME is honoured only when it is
an absolute path, as the Base Directory Specification requires; empty and
relative values fall through to ~/.local/share.
Everything downstream of the root — libraryfolders.vdf,
appmanifest_<appid>.acf, rank_candidates — is the same code as on Windows,
so a Linux user gets real game:steam:<appid> identities in the picker. The
window list itself already comes from xcap on Linux.
Steam games reach Score::Medium there, not Score::High: the
Running = 1 registry value has no counterpart. The only portable equivalent
is Steam's SteamAppId in another process' startup environment, and reading
that is the process-inspection route issue #239 explicitly rules out — the
ci_pinning guard bans the procfs entry points so the ceiling cannot be
quietly lifted. Epic, GOG, Origin/EA app and Battle.net stay Windows-gated:
their manifests are registry-backed and I did not find layouts for their Linux
ports that I could verify rather than guess.
Scene sources persist the chosen identity as the source device_id,
mirroring the camera:/monitor: conventions from #216:
game:steam:<appid> e.g. game:steam:730
game:epic:<AppName> e.g. game:epic:Fortnite
game:gog:<gameID> e.g. game:gog:1207658924
game:origin:<id> e.g. game:origin:1172470
game:eaapp:<id> EA app, its own namespace after the Origin migration
game:battlenet:<title> e.g. game:battlenet:Diablo IV
game:<window-id> heuristic windows (unchanged, no launcher segment)
Heuristic windows deliberately keep the pre-#239 game:<window-id> form
without a launcher segment: a raw window's id is the only stable key it has,
so adding a segment would silently re-id every already-persisted scene
source.
Windows-only in this slice (winreg is a Windows-only dependency; the
module is cfg-gated). Linux/macOS keep the existing window heuristic
until their launcher paths (e.g. ~/.steam/steam on Linux) are added as a
follow-up slice.
- Detection reads only local registry keys and manifest files.
- No HTTP/transport in the detection module.
- No
ReadProcessMemory(theSteamAppIdenv-var route is explicitly a non-goal in issue #239). - Missing launchers/keys degrade to an empty list — never an error, never a network fallback.