import * as path from 'path'; /** * Pure scoring/filtering helpers for game detection. Lives in the main process * and is exercised by both the foreground-window detector and the legacy * process-name scanner. * * The goal is to dramatically reduce the false-positive rate compared to the * previous "send every running process name to RAWG" approach by combining * multiple signals (window focus, executable path, engine markers, blacklist, * user-managed ignore list) into a confidence score. */ export interface GameCandidateInput { /** Lower-cased base name without extension (e.g. "stardewvalley"). */ processName: string; /** Original process name as reported by the OS (for display). */ rawProcessName?: string; executablePath?: string; windowTitle?: string; pid?: number; bounds?: { width: number; height: number } | undefined; isFullscreen?: boolean; source: 'foreground' | 'process-scan'; /** User-managed ignore list, already lower-cased. */ ignoredProcessNames: ReadonlySet; /** True when an engine signature file was found beside the executable. */ hasEngineSignature?: boolean; } export interface ScoredGameCandidate { processName: string; rawProcessName: string; executablePath?: string; windowTitle?: string; pid?: number; isFullscreen: boolean; bounds?: { width: number; height: number }; confidence: number; source: 'foreground' | 'process-scan'; reasons: string[]; } export const MIN_GAME_CONFIDENCE = 55; /** * Processes that are commonly misclassified as games. Lower-cased base names. * Note: we deliberately blacklist Electron/Chromium/IDE/launcher/comm apps. */ export const HARDCODED_IGNORED_PROCESSES: ReadonlySet = new Set([ '1password', '7zfm', 'agent', 'audiodg', 'bash', 'baloo', 'baloo_file', 'baloorunner', 'bluetoothuiservice', 'brave', 'brave-browser', 'chrome', 'cmd', 'code', 'code-insiders', 'conhost', 'cursor', 'csrss', 'ctfmon', 'dbus', 'dbus-daemon', 'discord', 'discordcanary', 'discordptb', 'dolphin', 'dwm', 'electron', 'epicgameslauncher', 'epicgames', 'explorer', 'fcitx5', 'firefox', 'fontdrvhost', 'gameoverlayui', 'gamemoded', 'gamemode-launcher', 'gamescopereaper', 'gnome-shell', 'gnome-software', 'gnome-terminal', 'init', 'java', 'javaw', 'kdeconnect', 'kdeconnectd', 'kded5', 'kded6', 'keepass', 'keepassxc', 'kernel_task', 'krunner', 'ksmserver', 'lockapp', 'logioptionsplus', 'logitechg', 'login', 'metoyou', 'msedge', 'msedgewebview2', 'msteams', 'node', 'npm', 'nvcontainer', 'nvidia-broadcast', 'nvidia-share', 'nvidia-smi', 'obs', 'obs64', 'obs-studio', 'pipewire', 'plasmashell', 'pluma', 'powershell', 'pwsh', 'pulseaudio', 'remoteapps-service', 'rundll32', 'runtimebroker', 'screen', 'searchapp', 'searchhost', 'shellexperiencehost', 'signal', 'slack', 'spotify', 'spotifywebhelper', 'sshd', 'startmenuexperiencehost', 'steam', 'steamservice', 'steamwebhelper', 'svchost', 'system', 'systemd', 'systemsettings', 'systemsoundsservice', 'taskhost', 'taskhostw', 'taskmgr', 'teams', 'telegram', 'telegramdesktop', 'textinputhost', 'thunderbird', 'tracker-miner-fs', 'tray', 'utilman', 'vivaldi', 'whatsapp', 'wininit', 'winlogon', 'xdg-desktop-portal', 'xorg', 'xwayland', 'yakuake', 'zoom' ]); const GENERIC_SUFFIX_NAMES = [ 'agent', 'browser', 'daemon', 'helper', 'indexer', 'launcher', 'monitor', 'renderer', 'runner', 'service', 'tray', 'updater', 'watcher', 'worker', 'portal', 'sync', 'broker', 'host' ].join('|'); const IGNORE_NAME_PATTERNS: readonly RegExp[] = [ new RegExp(`(^|[-_\\s.])(${GENERIC_SUFFIX_NAMES})([-_\\s.]|$)`, 'iu'), /^kworker/i, /^kthread/i, /^kpipefs/i, /^(at-spi|gvfs|ibus|kded|kglobalaccel|knotify|polkit|pulse|systemd)/i ]; /** Known game install root markers, case-insensitive substrings of the exe path. */ const KNOWN_GAME_PATH_MARKERS: readonly RegExp[] = [ /[\\/]steamapps[\\/]common[\\/]/i, /[\\/]steamlibrary[\\/]/i, /[\\/]epic games[\\/]/i, /[\\/]epicgameslauncher[\\/]/i, /[\\/]gog galaxy[\\/]games[\\/]/i, /[\\/]gog\.com[\\/]games[\\/]/i, /[\\/]gog games[\\/]/i, /[\\/]ea games[\\/]/i, /[\\/]origin games[\\/]/i, /[\\/]battle\.net[\\/]/i, /[\\/]ubisoft[\\/]/i, /[\\/]riot games[\\/]/i, /[\\/]itch[\\/]apps[\\/]/i, /[\\/]\.itch[\\/]apps[\\/]/i, /[\\/]heroic[\\/]games[\\/]/i, /[\\/]lutris[\\/]/i, /[\\/]games[\\/]/i, // Proton / Wine prefixes used by Steam/Lutris/Heroic /[\\/]proton[\\/]/i, /[\\/]pfx[\\/]drive_c[\\/]/i, /[\\/]\.wine[\\/]drive_c[\\/]program files[\\/]/i ]; /** Path segments that strongly indicate the process is NOT a game. */ const NON_GAME_PATH_MARKERS: readonly RegExp[] = [ /[\\/]appdata[\\/]local[\\/]temp[\\/]/i, /[\\/]temp[\\/]/i, /[\\/]node_modules[\\/]/i, /[\\/]chromium[\\/]/i, /[\\/]appdata[\\/]roaming[\\/]discord[\\/]/i, /[\\/]appdata[\\/]roaming[\\/]spotify[\\/]/i, /[\\/]windows[\\/]system32[\\/]/i, /[\\/]windows[\\/]syswow64[\\/]/i, /[\\/]\.cache[\\/]/i, /[\\/]snap[\\/]firefox[\\/]/i, /[\\/]snap[\\/]spotify[\\/]/i ]; /** File names placed beside a game's executable that reveal its engine. */ export const ENGINE_SIGNATURE_FILES: readonly string[] = [ 'UnityPlayer.dll', 'libUnityPlayer.so', 'UnityCrashHandler64.exe', 'UnityCrashHandler32.exe', // Unreal Engine: foo-Win64-Shipping.exe sits in /Binaries/Win64/ 'UnrealEditor.exe', 'UE4PrereqSetup_x64.exe', 'UE4Game.dll', 'UE5Game.dll', // Godot 'Godot.exe', 'libgodot.so', // Source engine 'tier0.dll', 'engine.dll', 'hl2.exe', // RPG Maker 'nw.dll', // CryEngine 'CryGameSDK.dll' ]; export function normalizeProcessKey(value: string): string { return path.basename(value.trim()) .replace(/\.(exe|bin|app|out)$/iu, '') .replace(/[_-]+/gu, ' ') .replace(/\s+/gu, ' ') .trim() .toLowerCase(); } export function shouldIgnoreProcess( processName: string, userIgnored: ReadonlySet ): boolean { const key = normalizeProcessKey(processName); if (!key) { return true; } if (userIgnored.has(key) || HARDCODED_IGNORED_PROCESSES.has(key)) { return true; } if (/^\d+$/.test(key)) { return true; } if (key.length < 4) { return true; } return IGNORE_NAME_PATTERNS.some((pattern) => pattern.test(key)); } export function pathMatchesKnownGameRoot(executablePath: string | undefined): boolean { if (!executablePath) { return false; } return KNOWN_GAME_PATH_MARKERS.some((pattern) => pattern.test(executablePath)); } export function pathMatchesNonGameRoot(executablePath: string | undefined): boolean { if (!executablePath) { return false; } return NON_GAME_PATH_MARKERS.some((pattern) => pattern.test(executablePath)); } interface ConfidenceScore { confidence: number; reasons: string[]; } function computeConfidence(input: GameCandidateInput, rawProcessName: string): ConfidenceScore { let confidence = 0; const reasons: string[] = []; const add = (points: number, reason: string): void => { confidence += points; reasons.push(reason); }; if (input.source === 'foreground') { add(35, 'foreground-window'); } if (pathMatchesKnownGameRoot(input.executablePath)) { add(30, 'known-game-folder'); } if (input.hasEngineSignature) { add(25, 'engine-signature'); } if (input.isFullscreen) { add(15, 'fullscreen'); } const width = input.bounds?.width ?? 0; const height = input.bounds?.height ?? 0; if (width >= 800 && height >= 600) { add(5, 'large-window'); } const title = input.windowTitle?.trim() ?? ''; if (title.length >= 3 && /[A-Za-z]/u.test(title)) { add(10, 'window-title'); } if (/[A-Z]/u.test(rawProcessName) && /[a-z]/u.test(rawProcessName)) { add(3, 'mixed-case-name'); } if (input.executablePath && /\.exe$/iu.test(input.executablePath)) { confidence += 2; } return { confidence: Math.min(100, confidence), reasons }; } export function scoreCandidate(input: GameCandidateInput): ScoredGameCandidate | null { const rawProcessName = input.rawProcessName ?? input.processName; const normalizedKey = normalizeProcessKey(input.processName); if (!normalizedKey) { return null; } if (shouldIgnoreProcess(normalizedKey, input.ignoredProcessNames)) { return null; } if (pathMatchesNonGameRoot(input.executablePath)) { return null; } const { confidence, reasons } = computeConfidence(input, rawProcessName); const title = input.windowTitle?.trim() ?? ''; // Process-scan candidates must clear a higher bar: without a foreground or // path signal the confidence will stay below the threshold, which is the // whole point - no more silent RAWG lookups for arbitrary processes. return { processName: normalizedKey, rawProcessName, executablePath: input.executablePath, windowTitle: title || undefined, pid: input.pid, isFullscreen: !!input.isFullscreen, bounds: input.bounds, confidence, source: input.source, reasons }; } /** Returns whether a confidence score clears the "report to peers" threshold. */ export function meetsGameConfidence(candidate: ScoredGameCandidate | null): boolean { return !!candidate && candidate.confidence >= MIN_GAME_CONFIDENCE; }