Skip to main content

modde_games/
detection.rs

1//! Unified game detection across Steam and Heroic launchers.
2//!
3//! Scans all known launcher libraries and returns every detected game
4//! installation, including launcher metadata. This allows the UI and CLI
5//! to present a "pick your game" experience without manual path entry.
6
7use std::path::{Path, PathBuf};
8use std::process::{Command, ExitStatus, Stdio};
9
10use anyhow::{Context, Result};
11use serde_json::Value;
12use tracing::{debug, info, warn};
13
14use modde_core::paths;
15
16/// A game installation detected by scanning launcher libraries.
17#[derive(Debug, Clone)]
18pub struct DetectedGame {
19    /// The modde game_id (e.g. "skyrim-se", "cyberpunk2077").
20    pub game_id: &'static str,
21    /// Human-readable display name.
22    pub display_name: &'static str,
23    /// Absolute path to the game's install directory.
24    pub install_path: PathBuf,
25    /// Which launcher owns this installation.
26    pub source: LauncherSource,
27}
28
29/// Which launcher/store a detected game belongs to.
30#[derive(Debug, Clone)]
31pub enum LauncherSource {
32    Steam {
33        app_id: String,
34        library_path: PathBuf,
35    },
36    HeroicGog {
37        app_id: String,
38    },
39    HeroicEpic {
40        app_id: String,
41    },
42    HeroicSideload {
43        app_id: String,
44    },
45}
46
47impl LauncherSource {
48    fn label_and_id(&self) -> (&str, &str) {
49        match self {
50            LauncherSource::Steam { app_id, .. } => ("Steam", app_id),
51            LauncherSource::HeroicGog { app_id } => ("Heroic/GOG", app_id),
52            LauncherSource::HeroicEpic { app_id } => ("Heroic/Epic", app_id),
53            LauncherSource::HeroicSideload { app_id } => ("Heroic/Sideload", app_id),
54        }
55    }
56
57    /// Launch the game via its detected launcher.
58    ///
59    /// Returns `Ok(Some(ExitStatus))` if we could wait for the game process to exit
60    /// (Heroic), or `Ok(None)` for fire-and-forget launchers (Steam).
61    pub fn launch(&self) -> Result<Option<ExitStatus>> {
62        match self {
63            LauncherSource::Steam { app_id, .. } => {
64                let url = format!("steam://rungameid/{app_id}");
65                info!(%url, "launching via Steam");
66                open::that(&url)
67                    .with_context(|| format!("failed to launch Steam via URI ({url})"))?;
68                Ok(None)
69            }
70            LauncherSource::HeroicGog { app_id }
71            | LauncherSource::HeroicEpic { app_id }
72            | LauncherSource::HeroicSideload { app_id } => {
73                let (bin, base_args) = heroic_command()
74                    .context("Heroic Games Launcher not found (checked flatpak and PATH)")?;
75                info!(%bin, %app_id, "launching via Heroic");
76                let mut cmd = Command::new(&bin);
77                for arg in &base_args {
78                    cmd.arg(arg);
79                }
80                let status = cmd
81                    .args(["--no-gui", "--launch", app_id])
82                    .status()
83                    .with_context(|| format!("failed to launch Heroic ({bin} --no-gui --launch {app_id})"))?;
84                Ok(Some(status))
85            }
86        }
87    }
88}
89
90impl std::fmt::Display for LauncherSource {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        let (label, id) = self.label_and_id();
93        write!(f, "{label} ({id})")
94    }
95}
96
97/// Known game identifiers for matching against launcher databases.
98///
99/// Each entry maps a modde `game_id` to the IDs used by various launchers.
100struct KnownGame {
101    game_id: &'static str,
102    display_name: &'static str,
103    /// Steam app ID (None if not on Steam).
104    steam_app_id: Option<&'static str>,
105    /// Steam directory name under `steamapps/common/`.
106    steam_dir: Option<&'static str>,
107    /// GOG app ID for Heroic (None if not on GOG).
108    gog_app_id: Option<&'static str>,
109    /// Epic/Legendary app name (None if not on Epic).
110    epic_app_id: Option<&'static str>,
111}
112
113/// Registry of all known games and their launcher identifiers.
114const KNOWN_GAMES: &[KnownGame] = &[
115    KnownGame {
116        game_id: "skyrim-se",
117        display_name: "The Elder Scrolls V: Skyrim Special Edition",
118        steam_app_id: Some("489830"),
119        steam_dir: Some("Skyrim Special Edition"),
120        gog_app_id: None,
121        epic_app_id: None,
122    },
123    // AE shares the same Steam dir as SE — detected as skyrim-se by default.
124    // Users can override to skyrim-ae in settings.
125    KnownGame {
126        game_id: "fallout4",
127        display_name: "Fallout 4",
128        steam_app_id: Some("377160"),
129        steam_dir: Some("Fallout 4"),
130        gog_app_id: Some("1998527297"),
131        epic_app_id: None,
132    },
133    KnownGame {
134        game_id: "fallout76",
135        display_name: "Fallout 76",
136        steam_app_id: Some("1151340"),
137        steam_dir: Some("Fallout76"),
138        gog_app_id: None,
139        epic_app_id: None,
140    },
141    KnownGame {
142        game_id: "starfield",
143        display_name: "Starfield",
144        steam_app_id: Some("1716740"),
145        steam_dir: Some("Starfield"),
146        gog_app_id: None,
147        epic_app_id: None,
148    },
149    KnownGame {
150        game_id: "cyberpunk2077",
151        display_name: "Cyberpunk 2077",
152        steam_app_id: Some("1091500"),
153        steam_dir: Some("Cyberpunk 2077"),
154        gog_app_id: Some("1423049311"),
155        epic_app_id: Some("Ginger"),
156    },
157    KnownGame {
158        game_id: "stellar-blade",
159        display_name: "Stellar Blade",
160        steam_app_id: Some("3489700"),
161        // Steam installs under `steamapps/common/Stellar Blade` — if your
162        // install uses the trademark glyph ("Stellar Blade™"), update this.
163        steam_dir: Some("Stellar Blade"),
164        gog_app_id: None,
165        epic_app_id: None,
166    },
167];
168
169/// Detect the Heroic Games Launcher binary.
170///
171/// - Linux: checks flatpak first, then native binary on `$PATH`
172/// - macOS: checks `/Applications/Heroic.app`, then `$PATH`
173/// - Windows: checks standard install path, then `%PATH%`
174///
175/// Returns `(binary, base_args)` — e.g. `("flatpak", ["run", "com.heroicgameslauncher.hgl"])`
176/// or `("heroic", [])`.
177fn heroic_command() -> Option<(String, Vec<String>)> {
178    #[cfg(target_os = "linux")]
179    {
180        // Check flatpak first (common on NixOS / immutable distros)
181        if Command::new("flatpak")
182            .args(["info", "com.heroicgameslauncher.hgl"])
183            .stdout(Stdio::null())
184            .stderr(Stdio::null())
185            .status()
186            .ok()
187            .map(|s| s.success())
188            .unwrap_or(false)
189        {
190            return Some((
191                "flatpak".to_string(),
192                vec!["run".to_string(), "com.heroicgameslauncher.hgl".to_string()],
193            ));
194        }
195
196        // Check native binary on PATH
197        if let Ok(path) = which::which("heroic") {
198            return Some((path.to_string_lossy().to_string(), vec![]));
199        }
200
201        None
202    }
203
204    #[cfg(target_os = "macos")]
205    {
206        let app_path = "/Applications/Heroic.app/Contents/MacOS/Heroic";
207        if std::path::Path::new(app_path).exists() {
208            return Some((app_path.to_string(), vec![]));
209        }
210        if let Ok(path) = which::which("heroic") {
211            return Some((path.to_string_lossy().to_string(), vec![]));
212        }
213        None
214    }
215
216    #[cfg(target_os = "windows")]
217    {
218        if let Some(exe) = modde_core::paths::heroic_exe_path() {
219            return Some((exe.to_string_lossy().to_string(), vec![]));
220        }
221        if let Ok(path) = which::which("heroic") {
222            return Some((path.to_string_lossy().to_string(), vec![]));
223        }
224        None
225    }
226}
227
228/// Find a detected game by its modde game_id.
229///
230/// Convenience wrapper around [`scan_installed_games`] that returns the first
231/// match. Used by both CLI and UI to resolve the launcher for a game.
232pub fn find_detected_game(game_id: &str) -> Option<DetectedGame> {
233    scan_installed_games()
234        .into_iter()
235        .find(|g| g.game_id == game_id)
236}
237
238/// Scan all known launchers for installed games.
239///
240/// Returns every detected game with its install path and launcher source.
241/// A game may appear multiple times if installed via different launchers.
242pub fn scan_installed_games() -> Vec<DetectedGame> {
243    let mut detected = Vec::new();
244
245    scan_steam_libraries(&mut detected);
246    scan_heroic_stores(&mut detected);
247
248    detected
249}
250
251/// Scan all Steam library folders for known games.
252fn scan_steam_libraries(detected: &mut Vec<DetectedGame>) {
253    let libraries = paths::steam_library_folders();
254
255    for lib_path in &libraries {
256        let common_dir = lib_path.join("steamapps/common");
257        if !common_dir.is_dir() {
258            continue;
259        }
260
261        for game in KNOWN_GAMES {
262            let Some(steam_dir) = game.steam_dir else {
263                continue;
264            };
265
266            let install_path = common_dir.join(steam_dir);
267            if install_path.is_dir() {
268                debug!(
269                    game_id = game.game_id,
270                    path = %install_path.display(),
271                    "detected Steam game"
272                );
273                detected.push(DetectedGame {
274                    game_id: game.game_id,
275                    display_name: game.display_name,
276                    install_path,
277                    source: LauncherSource::Steam {
278                        app_id: game.steam_app_id.unwrap_or("unknown").to_string(),
279                        library_path: lib_path.clone(),
280                    },
281                });
282            }
283        }
284    }
285}
286
287/// Scan Heroic's installed game databases (GOG, Epic/Legendary, Sideload).
288fn scan_heroic_stores(detected: &mut Vec<DetectedGame>) {
289    let Some(heroic_dir) = paths::heroic_config_dir() else {
290        return;
291    };
292
293    // GOG store
294    scan_heroic_store_file(
295        &heroic_dir.join("gog_store/installed.json"),
296        |app_id| {
297            KNOWN_GAMES
298                .iter()
299                .find(|g| g.gog_app_id == Some(app_id))
300                .map(|g| (g, HeroicStoreKind::Gog))
301        },
302        detected,
303    );
304
305    // Epic/Legendary store
306    scan_heroic_store_file(
307        &heroic_dir.join("legendary_store/installed.json"),
308        |app_id| {
309            KNOWN_GAMES
310                .iter()
311                .find(|g| g.epic_app_id == Some(app_id))
312                .map(|g| (g, HeroicStoreKind::Epic))
313        },
314        detected,
315    );
316
317    // Sideloaded apps — match by directory name heuristic
318    scan_heroic_sideload(&heroic_dir.join("sideload_apps/installed.json"), detected);
319}
320
321#[derive(Clone, Copy)]
322enum HeroicStoreKind {
323    Gog,
324    Epic,
325}
326
327/// Parse a Heroic `installed.json` and match entries against known games.
328fn scan_heroic_store_file(
329    path: &Path,
330    matcher: impl Fn(&str) -> Option<(&KnownGame, HeroicStoreKind)>,
331    detected: &mut Vec<DetectedGame>,
332) {
333    let data = match std::fs::read_to_string(path) {
334        Ok(d) => d,
335        Err(e) => {
336            debug!(error = %e, path = %path.display(), "failed to read Heroic store file");
337            return;
338        }
339    };
340
341    let parsed: Value = match serde_json::from_str(&data) {
342        Ok(v) => v,
343        Err(e) => {
344            warn!(error = %e, path = %path.display(), "failed to parse Heroic store JSON");
345            return;
346        }
347    };
348
349    let Some(installed) = parsed.get("installed").and_then(|v| v.as_array()) else {
350        debug!(path = %path.display(), "Heroic store file missing 'installed' array");
351        return;
352    };
353
354    for entry in installed {
355        let Some(app_name) = entry.get("appName").and_then(|v| v.as_str()) else {
356            continue;
357        };
358        let Some(install_path) = entry.get("install_path").and_then(|v| v.as_str()) else {
359            continue;
360        };
361
362        let install_path = PathBuf::from(install_path);
363        if !install_path.is_dir() {
364            continue;
365        }
366
367        if let Some((game, kind)) = matcher(app_name) {
368            debug!(
369                game_id = game.game_id,
370                app_name,
371                path = %install_path.display(),
372                "detected Heroic game"
373            );
374            let source = match kind {
375                HeroicStoreKind::Gog => LauncherSource::HeroicGog {
376                    app_id: game.gog_app_id.unwrap_or(app_name).to_string(),
377                },
378                HeroicStoreKind::Epic => LauncherSource::HeroicEpic {
379                    app_id: game.epic_app_id.unwrap_or(app_name).to_string(),
380                },
381            };
382            detected.push(DetectedGame {
383                game_id: game.game_id,
384                display_name: game.display_name,
385                install_path,
386                source,
387            });
388        }
389    }
390}
391
392/// Scan Heroic sideloaded apps — match by directory name against known steam_dir names.
393fn scan_heroic_sideload(path: &Path, detected: &mut Vec<DetectedGame>) {
394    let data = match std::fs::read_to_string(path) {
395        Ok(d) => d,
396        Err(e) => {
397            debug!(error = %e, path = %path.display(), "failed to read Heroic sideload file");
398            return;
399        }
400    };
401
402    let parsed: Value = match serde_json::from_str(&data) {
403        Ok(v) => v,
404        Err(e) => {
405            warn!(error = %e, path = %path.display(), "failed to parse Heroic sideload JSON");
406            return;
407        }
408    };
409
410    let Some(installed) = parsed.get("installed").and_then(|v| v.as_array()) else {
411        debug!(path = %path.display(), "Heroic sideload file missing 'installed' array");
412        return;
413    };
414
415    for entry in installed {
416        let Some(app_name) = entry.get("appName").and_then(|v| v.as_str()) else {
417            continue;
418        };
419        let Some(install_path_str) = entry.get("install_path").and_then(|v| v.as_str()) else {
420            continue;
421        };
422
423        let install_path = PathBuf::from(install_path_str);
424        if !install_path.is_dir() {
425            continue;
426        }
427
428        // Try to match by directory name
429        let dir_name = install_path
430            .file_name()
431            .and_then(|n| n.to_str())
432            .unwrap_or("");
433
434        for game in KNOWN_GAMES {
435            let matches = game
436                .steam_dir
437                .map(|sd| sd.eq_ignore_ascii_case(dir_name))
438                .unwrap_or(false);
439
440            if matches {
441                debug!(
442                    game_id = game.game_id,
443                    app_name,
444                    path = %install_path.display(),
445                    "detected Heroic sideloaded game"
446                );
447                detected.push(DetectedGame {
448                    game_id: game.game_id,
449                    display_name: game.display_name,
450                    install_path,
451                    source: LauncherSource::HeroicSideload {
452                        app_id: app_name.to_string(),
453                    },
454                });
455                break;
456            }
457        }
458    }
459}
460
461/// Find the install path for a specific game by scanning all launchers.
462///
463/// This is used by `GamePlugin::detect_install()` implementations to check
464/// all available sources instead of just hardcoded paths.
465pub fn find_game_install(game_id: &str) -> Option<PathBuf> {
466    // Check settings override first
467    let settings = modde_core::settings::AppSettings::load();
468    if let Some(path) = settings.game_path(game_id) {
469        if path.is_dir() {
470            return Some(path.clone());
471        }
472    }
473
474    // Scan all launchers
475    scan_installed_games()
476        .into_iter()
477        .find(|g| g.game_id == game_id)
478        .map(|g| g.install_path)
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    // ── Heroic store file parsing ─────────────────────────────────────
485
486    fn write_heroic_installed(dir: &std::path::Path, entries: &[(&str, &str)]) {
487        let items: Vec<serde_json::Value> = entries
488            .iter()
489            .map(|(app_name, install_path)| {
490                serde_json::json!({
491                    "appName": app_name,
492                    "install_path": install_path,
493                })
494            })
495            .collect();
496        let json = serde_json::json!({ "installed": items });
497        std::fs::write(dir, serde_json::to_string(&json).unwrap()).unwrap();
498    }
499
500    #[test]
501    fn scan_heroic_gog_detects_known_game() {
502        let tmp = tempfile::tempdir().unwrap();
503        let install_dir = tmp.path().join("cyberpunk");
504        std::fs::create_dir_all(&install_dir).unwrap();
505
506        let store_file = tmp.path().join("installed.json");
507        write_heroic_installed(&store_file, &[("1423049311", &install_dir.to_string_lossy())]);
508
509        let mut detected = Vec::new();
510        scan_heroic_store_file(
511            &store_file,
512            |app_id| {
513                KNOWN_GAMES
514                    .iter()
515                    .find(|g| g.gog_app_id == Some(app_id))
516                    .map(|g| (g, HeroicStoreKind::Gog))
517            },
518            &mut detected,
519        );
520
521        assert_eq!(detected.len(), 1);
522        assert_eq!(detected[0].game_id, "cyberpunk2077");
523        assert_eq!(detected[0].install_path, install_dir);
524        assert!(matches!(detected[0].source, LauncherSource::HeroicGog { .. }));
525    }
526
527    #[test]
528    fn scan_heroic_gog_unknown_game_ignored() {
529        let tmp = tempfile::tempdir().unwrap();
530        let install_dir = tmp.path().join("some_game");
531        std::fs::create_dir_all(&install_dir).unwrap();
532
533        let store_file = tmp.path().join("installed.json");
534        write_heroic_installed(&store_file, &[("9999999999", &install_dir.to_string_lossy())]);
535
536        let mut detected = Vec::new();
537        scan_heroic_store_file(
538            &store_file,
539            |app_id| {
540                KNOWN_GAMES
541                    .iter()
542                    .find(|g| g.gog_app_id == Some(app_id))
543                    .map(|g| (g, HeroicStoreKind::Gog))
544            },
545            &mut detected,
546        );
547
548        assert_eq!(detected.len(), 0, "unknown game should not be added");
549    }
550
551    #[test]
552    fn scan_heroic_nonexistent_install_path_skipped() {
553        let tmp = tempfile::tempdir().unwrap();
554        let store_file = tmp.path().join("installed.json");
555        // Path does not exist on disk
556        write_heroic_installed(&store_file, &[("1423049311", "/nonexistent/cyberpunk")]);
557
558        let mut detected = Vec::new();
559        scan_heroic_store_file(
560            &store_file,
561            |app_id| {
562                KNOWN_GAMES
563                    .iter()
564                    .find(|g| g.gog_app_id == Some(app_id))
565                    .map(|g| (g, HeroicStoreKind::Gog))
566            },
567            &mut detected,
568        );
569
570        assert_eq!(detected.len(), 0, "nonexistent install path should be skipped");
571    }
572
573    #[test]
574    fn scan_heroic_missing_file_is_no_op() {
575        let mut detected = Vec::new();
576        // Should not panic
577        scan_heroic_store_file(
578            std::path::Path::new("/nonexistent/installed.json"),
579            |_| None,
580            &mut detected,
581        );
582        assert_eq!(detected.len(), 0);
583    }
584
585    #[test]
586    fn scan_heroic_malformed_json_is_no_op() {
587        let tmp = tempfile::tempdir().unwrap();
588        let store_file = tmp.path().join("installed.json");
589        std::fs::write(&store_file, "this is not json").unwrap();
590
591        let mut detected = Vec::new();
592        scan_heroic_store_file(
593            &store_file,
594            |_| None,
595            &mut detected,
596        );
597        assert_eq!(detected.len(), 0);
598    }
599
600    #[test]
601    fn scan_heroic_empty_installed_array() {
602        let tmp = tempfile::tempdir().unwrap();
603        let store_file = tmp.path().join("installed.json");
604        std::fs::write(&store_file, r#"{"installed":[]}"#).unwrap();
605
606        let mut detected = Vec::new();
607        scan_heroic_store_file(
608            &store_file,
609            |_| None,
610            &mut detected,
611        );
612        assert_eq!(detected.len(), 0);
613    }
614
615    #[test]
616    fn scan_heroic_sideload_matches_by_dirname() {
617        let tmp = tempfile::tempdir().unwrap();
618        // Create a dir named like the Cyberpunk Steam dir
619        let install_dir = tmp.path().join("Cyberpunk 2077");
620        std::fs::create_dir_all(&install_dir).unwrap();
621
622        let store_file = tmp.path().join("installed.json");
623        write_heroic_installed(&store_file, &[("some_sideload_id", &install_dir.to_string_lossy())]);
624
625        let mut detected = Vec::new();
626        scan_heroic_sideload(&store_file, &mut detected);
627
628        assert_eq!(detected.len(), 1);
629        assert_eq!(detected[0].game_id, "cyberpunk2077");
630        assert!(matches!(detected[0].source, LauncherSource::HeroicSideload { .. }));
631    }
632
633    #[test]
634    fn scan_heroic_sideload_unknown_dirname_ignored() {
635        let tmp = tempfile::tempdir().unwrap();
636        let install_dir = tmp.path().join("Some Unknown Game 2077");
637        std::fs::create_dir_all(&install_dir).unwrap();
638
639        let store_file = tmp.path().join("installed.json");
640        write_heroic_installed(&store_file, &[("some_id", &install_dir.to_string_lossy())]);
641
642        let mut detected = Vec::new();
643        scan_heroic_sideload(&store_file, &mut detected);
644
645        assert_eq!(detected.len(), 0);
646    }
647
648    // ── Steam library scanning ────────────────────────────────────────
649
650    #[test]
651    fn scan_steam_libraries_detects_game_in_common() {
652        let tmp = tempfile::tempdir().unwrap();
653        // Create a fake Steam library with "Cyberpunk 2077" in steamapps/common/
654        let common = tmp.path().join("steamapps/common/Cyberpunk 2077");
655        std::fs::create_dir_all(&common).unwrap();
656
657        // Temporarily override HOME to point to our temp dir so steam_library_folders works
658        // Instead, we directly test scan_steam_libraries by injecting a mock path.
659        // We can do this by patching the paths module — but since we can't do that easily,
660        // we test via the internal helper by constructing the detection directly.
661        let detected_game = KNOWN_GAMES.iter().find(|g| g.game_id == "cyberpunk2077").unwrap();
662        let install_path = common.clone();
663        assert_eq!(install_path.file_name().unwrap(), "Cyberpunk 2077");
664        assert!(install_path.is_dir());
665        // If we had a real Steam library here, scan_steam_libraries would find this.
666        // This is a structural test asserting KNOWN_GAMES has the right steam_dir.
667        assert_eq!(detected_game.steam_dir, Some("Cyberpunk 2077"));
668    }
669
670    // ── LauncherSource display ────────────────────────────────────────
671
672    #[test]
673    fn launcher_source_display_steam() {
674        let src = LauncherSource::Steam {
675            app_id: "1091500".to_string(),
676            library_path: PathBuf::from("/games"),
677        };
678        assert_eq!(src.to_string(), "Steam (1091500)");
679    }
680
681    #[test]
682    fn launcher_source_display_heroic_gog() {
683        let src = LauncherSource::HeroicGog { app_id: "1423049311".to_string() };
684        assert_eq!(src.to_string(), "Heroic/GOG (1423049311)");
685    }
686
687    #[test]
688    fn launcher_source_display_heroic_epic() {
689        let src = LauncherSource::HeroicEpic { app_id: "Ginger".to_string() };
690        assert_eq!(src.to_string(), "Heroic/Epic (Ginger)");
691    }
692
693    #[test]
694    fn launcher_source_display_sideload() {
695        let src = LauncherSource::HeroicSideload { app_id: "custom_app".to_string() };
696        assert_eq!(src.to_string(), "Heroic/Sideload (custom_app)");
697    }
698
699    // ── KNOWN_GAMES integrity ─────────────────────────────────────────
700
701    #[test]
702    fn known_games_ids_are_unique() {
703        let ids: Vec<_> = KNOWN_GAMES.iter().map(|g| g.game_id).collect();
704        let deduped: std::collections::HashSet<_> = ids.iter().collect();
705        assert_eq!(ids.len(), deduped.len(), "KNOWN_GAMES has duplicate game_ids");
706    }
707
708    #[test]
709    fn known_games_includes_supported_games() {
710        use crate::SUPPORTED_GAME_IDS;
711        for &game_id in SUPPORTED_GAME_IDS.iter()
712            .filter(|g| **g != "skyrim-ae")  // AE intentionally shares SE's steam dir
713        {
714            if ["skyrim-se", "fallout4", "cyberpunk2077"].contains(&game_id) {
715                assert!(
716                    KNOWN_GAMES.iter().any(|g| g.game_id == game_id),
717                    "KNOWN_GAMES missing {game_id}"
718                );
719            }
720        }
721    }
722}