Skip to main content

Game Library Detection

Game library detection runs entirely inside the playroll-ui Electron app (main process). It enumerates locally installed games from supported PC store launchers, without requiring any store login, then syncs the result to Supabase so it can power the launcher home view, eligibility checks, and the LaunchPad.

Source code lives in playroll-ui under src/main/library/. The playroll-cpp recorder does not participate in library scanning — but it consumes the synced Supabase result for process detection: eligible_games to enable the quest lane, and the player's user_game_library for the free-play lane (EC-475), with two global vetoes applied to both lanes — the non_games non-game filter and the blacklist_games igdb hard-block (EC-481). See Recording lanes.

Tier-2 store-root matching (EC-518). Beyond exe-basename matching, the UI hands the core the store install roots — the distinct parent directories of installed games' install paths, derived in src/main/library/store-roots.ts (never the manual provider) and pushed via PUT /api/monitor/store-roots inside the monitor-targets sync. The core then promotes any running process whose full exe path lives under one of those containers to a free-play target, even when no catalog or library entry knows its exe (renamed exes, freshly installed siblings, store variants). Promotions are synthetic (igdbId 0, slugs in the reserved fp- namespace), pass through the same non_games/blacklist_games vetoes as every other intake, and are structurally safety-gated core-side (normalizeStoreRootPath rejects drive roots, the Windows dir, and the bare Program Files / Users tiers). Telemetry: monitor.tier2_store_root_match, monitor.store_roots_set.

Goals

  • Local-only detection. No store credentials, no remote API calls. Each detector reads what the launcher already stores on disk (manifests, registry, SQLite, VDF).
  • Best-effort, never-blocking. A failing detector returns an empty list, never throws into the orchestrator. The launcher must still work if Steam is broken or GOG Galaxy is missing.
  • Deterministic identity. A game's id is a UUID v5 derived from provider:providerGameId so the same install always hashes to the same id across machines and reinstalls.

Pipeline

The flow is:

  1. LibraryManager (src/main/library/library-manager.ts) instantiates every detector at startup and starts an initial full scan.
  2. LibraryScanner runs detectors in parallel: first isAvailable() (cheap launcher-present check), then detectInstalled() only for the available ones. Each result is keyed by provider:providerGameId, so duplicate detection across detectors is impossible.
  3. The scanner computes an added / removed / changed diff against the previous scan and pushes the result to the renderer.
  4. LibraryWatcher watches Steam appmanifest_*.acf files (one per Steam library folder) and Epic *.item manifests for fine-grained install/uninstall events; both are debounced (~2s) and trigger a single-store rescan. Other stores rely on the periodic 30-minute full rescan (LIBRARY_SCAN_INTERVAL_MS).
  5. LibrarySync upserts the current list into Supabase via the sync_user_game_library RPC (throttled). The Supabase row is what the UI reads later for catalog matching, eligibility, and the LaunchPad.

A separate module — running-games.ts — enumerates OS processes and matches them against detected game executables to flag which titles are currently running. This is independent from detection but uses the same DetectedGame records.

Detector contract

Every detector implements StoreDetector from src/main/library/detector.ts:

interface StoreDetector {
readonly name: StoreProvider;
isAvailable(): Promise<boolean>;
detectInstalled(): Promise<DetectedGame[]>;
}

isAvailable() is intentionally cheap (registry hit, file existsSync, etc.) so the orchestrator can skip dead launchers without paying for a full enumeration.

A DetectedGame carries the canonical id, provider, install path, executables, install state, size on disk, launch command, and optional metadata (icon, playtime, genres, developers, publishers, release date, platform). Missing fields are normal — most non-Steam stores expose far less metadata locally.

Supported stores

StoreproviderDetection sourceInstall/uninstall watch
Steamsteamlibraryfolders.vdf + appmanifest_*.acf per library folder, plus GoldSrc and Source mod registry pathsYes (chokidar on each library's steamapps/appmanifest_*.acf)
Epic GamesepicManifests in %PROGRAMDATA%\Epic\EpicGamesLauncher\Data\Manifests\*.item + LauncherInstalled.dat. Sub-apps/DLC are skipped when MainGameAppName is present and differs from AppName; standalone titles ship "MainGameAppName": "" and are kept (EC-523). Epic-distributed non-game software (Discord, Voicemod, …) is skipped when AppCategories lacks gamesYes (chokidar on *.item)
GOG GalaxygogWindows Uninstall registry (*_is1, both x86 and x64 views) + per-game goggame-{id}.infoNo — periodic rescan only
Xbox / Microsoft StorexboxPowerShell enumeration of Store-signed UWP packages + AppxManifest.xml parse, with XboxAccountClient token cache for richer metadataNo — periodic rescan only
EA (EA Desktop)ea%PROGRAMDATA%\EA Desktop\machine.ini + EA Desktop install data dir + registry, manifests parsed with fast-xml-parserNo — periodic rescan only
Ubisoft ConnectubisoftHKLM\SOFTWARE\ubisoft\Launcher\Installs (per-gameId install dirs, both x86 and x64 views)No — periodic rescan only
Battle.netbattlenetC:\ProgramData\Battle.net\Agent\product.db + Battle.net uninstall registry, with a built-in product catalog for default and classic titlesNo — periodic rescan only
Amazon Gamesamazon%LOCALAPPDATA%\Amazon Games\Data\Games\Sql\GameInstallInfo.sqliteNo — periodic rescan only
itch.ioitchioButler DB at %APPDATA%\itch\db\butler.db (filtered to classification in tool)No — periodic rescan only
Riot ClientriotRiot client registry keys (HKCR\riotclient, HKLM\SOFTWARE\Classes\riotclient) + built-in product table (League, Valorant, TFT, Wild Rift, 2XKO)No — periodic rescan only
Rockstar Games LauncherrockstarWindows Uninstall registry (Launcher.exe … -uninstall=<titleId> entries) + per-game HKLM\SOFTWARE\(WOW6432Node\)Rockstar Games\<game> keys. The per-game key covers interrupted installs and store-managed copies: install values are read in order InstallFolder (Rockstar-managed), InstallFolderEpic (bought on Epic — the Rockstar launcher manages the install, so it never appears in Epic's manifests), then any unknown InstallFolder* variant. InstallFolderSteam is deliberately left to the Steam detector (the copy has a Steam appmanifest; a second row would duplicate the game) but is counted in telemetry as steam_managed_only.No — periodic rescan only

The StoreProvider union also includes microsoft, other, and manual for legacy / user-added entries that do not come from a live detector.

Identity, names, and deduplication

  • generateGameId(provider, providerGameId) produces a deterministic UUID v5 under a fixed Playroll namespace, so two different machines that have Hades installed via Steam end up with the same game_id.
  • normalizeGameName strips ™ ® ©, collapses whitespace, and trims, so display-side matching does not have to deal with launcher-specific punctuation.
  • The scanner keys games by provider:providerGameId, not by name — a title installed on both Steam and Epic appears as two separate library entries (correct, since each install has its own executable and launch command).

Failure model

  • A detector that throws during detectInstalled() is caught by the scanner and treated as "returned []" — the rest of the scan still completes.
  • A detector that throws during isAvailable() is treated as unavailable.
  • Sync failures bubble up to the manager listener as onError("sync", ...) but do not stop scanning.
  • The scanner refuses to run a second scan while one is already in flight; concurrent triggers (timer + watcher) collapse to a single pass.