diff --git a/docs/development/steam-game-imports.md b/docs/development/steam-game-imports.md index 04065e3a5..2e4a0cc3e 100644 --- a/docs/development/steam-game-imports.md +++ b/docs/development/steam-game-imports.md @@ -52,6 +52,12 @@ again. A failed title lookup undoes nothing. The launcher likewise drops artwork fetched for the app the files named before and fetches it again; pictures the user chose are kept. +A game that stays a shortcut but sits in a Steam install directory with a single matching +manifest links its shortcut prefix to that app's existing `compatdata/`, so cloud +saves the client downloaded are the ones the shortcut sees. A Steam library install only +uses its own library's prefix. An existing shortcut prefix is never replaced, a link to a +missing prefix is removed first, and nothing is copied. + The account's `.droiddeck-routes.json` maps shortcut IDs to real Steam app IDs so the Android launcher uses the same title and Proton prefix as Steam. Other accounts do not inherit those routes. Managed shortcuts are replaced; user-created shortcuts and diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games index c902676d3..ac8213d41 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games @@ -71,7 +71,11 @@ def store_game(game): def manifest_ids(steamapps, name): matches = [] - for manifest in Path(steamapps).glob("appmanifest_*.acf"): + try: + manifests = list(Path(steamapps).glob("appmanifest_*.acf")) + except OSError: + return matches + for manifest in manifests: try: install = re.search(r'"installdir"\s*"([^"\\]*)"', manifest.read_text(), re.I) if install and install[1] == name: @@ -81,13 +85,21 @@ def manifest_ids(steamapps, name): return matches -def identify(game): +def manifest_identity(game): folder = Path(game["folder"]) steamapps = folder.parent.parent for library in ([steamapps] if folder.parent.name == "common" and steamapps.name == "steamapps" else []) + [folder.parent / WINNATIVE]: matches = [appid for appid in manifest_ids(library, folder.name) if appid != SPACEWAR] if matches: - return matches[0] if len(matches) == 1 else None + return (matches[0], library) if len(matches) == 1 else None + return False + + +def identify(game): + folder = Path(game["folder"]) + found = manifest_identity(game) + if found is not False: + return found[0] if found is not None else None ids = set() for parent in {folder, Path(game["exe"]).parent}: try: @@ -508,6 +520,57 @@ def release(steam_root, game, stale, reason): print(f"droiddeck-steam-games: {game.get('name', appid)} {reason}; it is no longer Steam app {appid}") +PREFIX_LINKS = ".droiddeck-prefix-links.json" + + +def steam_prefix(steam_root, game): + identity = manifest_identity(game) + if not identity or not identity[0]: + return None, None + appid, steamapps = identity + installed = steamapps == Path(game["folder"]).parent.parent + for library in [steamapps.parent] if installed else dict.fromkeys([steamapps.parent] + libraries(steam_root)): + prefix = library / "steamapps/compatdata" / str(appid) + if (prefix / "pfx/drive_c").is_dir(): + return appid, str(prefix) + return appid, None + + +def share_prefix(steam_root, game): + shortcut = Path(steam_root) / "steamapps/compatdata" / str(game["appid"] & 0xFFFFFFFF) + record = Path(steam_root) / "steamapps" / PREFIX_LINKS + links = read_json(record, {}) + if not isinstance(links, dict): + links = {} + before = dict(links) + key = str(shortcut) + name = game.get("name", "game") + appid, target = steam_prefix(steam_root, game) + try: + current = os.readlink(shortcut) if os.path.islink(shortcut) else None + if current is not None and links.get(key) == current and current != target: + if target is not None or (not os.path.exists(shortcut) and Path(current).parent.parent.is_dir()): + shortcut.unlink() + current = None + if current is None: + links.pop(key, None) + if current is None and os.path.lexists(shortcut): + if target is not None: + print(f"droiddeck-steam-games: {name} keeps its own prefix; Steam app {appid} saves are in {target}") + elif current is None and target is not None: + shortcut.parent.mkdir(parents=True, exist_ok=True) + shortcut.symlink_to(target) + links[key] = target + print(f"droiddeck-steam-games: {name} shares the saves prefix of Steam app {appid}") + except OSError as error: + print(f"droiddeck-steam-games: {name} keeps its own prefix ({error})") + if links != before: + try: + save_json(record, links) + except OSError as error: + print(f"droiddeck-steam-games: prefix links not recorded ({error})") + + def route(steam_root, acct, games): snapshot = read_json(Path(acct) / "config" / STATE, {}) if not isinstance(snapshot, dict): @@ -557,6 +620,7 @@ def route(steam_root, acct, games): elif named and seeded(steamapps, game, appid): routes[str(game["appid"])] = appid continue + share_prefix(steam_root, game) shortcuts.append(game) try: park(steamapps) diff --git a/tools/tests/test_steam_games.py b/tools/tests/test_steam_games.py index 16c08e142..13235e68b 100644 --- a/tools/tests/test_steam_games.py +++ b/tools/tests/test_steam_games.py @@ -3,6 +3,7 @@ import importlib.util import json import os from pathlib import Path +import shutil import subprocess import tempfile import unittest @@ -56,6 +57,171 @@ class ImportsTest(unittest.TestCase): self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-42').resolve()) self.assertEqual(b'game', self.exe.read_bytes()) + def steam_install(self, appid=42): + library = self.root / 'Library' + folder = library / 'steamapps/common/Example' + folder.mkdir(parents=True) + (folder / 'Example.exe').write_bytes(b'game') + (library / f'steamapps/appmanifest_{appid}.acf').write_text(f'"AppState"\n{{\n"appid" "{appid}"\n"installdir" "Example"\n}}\n') + saves = library / f'steamapps/compatdata/{appid}/pfx/drive_c/users/steamuser' + saves.mkdir(parents=True) + (saves / 'save.dat').write_bytes(b'cloud') + return dict(self.game, folder=str(folder), exe=str(folder / 'Example.exe'), dir=str(folder)), library + + def shortcut_prefix(self): + return self.steam / 'steamapps/compatdata' / str(self.game['appid'] & 0xFFFFFFFF) + + def link_shortcut_prefix(self, target, owned=True): + (self.steam / 'steamapps/compatdata').mkdir(parents=True, exist_ok=True) + self.shortcut_prefix().symlink_to(target) + if owned: + imports.save_json(self.steam / 'steamapps' / imports.PREFIX_LINKS, + {str(self.shortcut_prefix()): str(target)}) + + def test_shortcut_of_a_steam_install_shares_the_steam_apps_prefix(self): + game, library = self.steam_install() + (self.steam / 'steamapps').mkdir(parents=True) + (self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n') + games, routes = imports.route(self.steam, self.acct, [game]) + self.assertEqual(([game], {}), (games, routes)) + self.assertEqual(library / 'steamapps/compatdata/42', self.shortcut_prefix().resolve()) + self.assertEqual(b'cloud', (self.shortcut_prefix() / 'pfx/drive_c/users/steamuser/save.dat').read_bytes()) + + def test_matching_install_prefix_precedes_an_older_prefix_in_another_library(self): + game, library = self.steam_install() + (self.steam / 'steamapps').mkdir(parents=True) + (self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n') + older = self.steam / 'steamapps/compatdata/42/pfx/drive_c/users/steamuser' + older.mkdir(parents=True) + (older / 'save.dat').write_bytes(b'old') + imports.route(self.steam, self.acct, [game]) + self.assertEqual(library / 'steamapps/compatdata/42', self.shortcut_prefix().resolve()) + self.assertEqual(b'cloud', (self.shortcut_prefix() / 'pfx/drive_c/users/steamuser/save.dat').read_bytes()) + self.assertEqual(b'old', (older / 'save.dat').read_bytes()) + + def test_an_existing_shortcut_prefix_is_never_replaced(self): + game, library = self.steam_install() + (self.steam / 'steamapps').mkdir(parents=True, exist_ok=True) + (self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n') + local = self.shortcut_prefix() / 'pfx/drive_c' + local.mkdir(parents=True) + (local / 'local.dat').write_bytes(b'local') + imports.route(self.steam, self.acct, [game]) + self.assertFalse(self.shortcut_prefix().is_symlink()) + self.assertEqual(b'local', (local / 'local.dat').read_bytes()) + self.assertEqual(b'cloud', (library / 'steamapps/compatdata/42/pfx/drive_c/users/steamuser/save.dat').read_bytes()) + + def test_shortcuts_without_a_manifest_identity_keep_their_own_prefix(self): + game, library = self.steam_install() + (self.steam / 'steamapps').mkdir(parents=True, exist_ok=True) + (self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n') + imports.route(self.steam, self.acct, [self.game, dict(game, folder=str(self.folder.parent / 'Other'))]) + self.assertFalse(os.path.lexists(self.shortcut_prefix())) + + def test_a_manifest_without_a_steam_prefix_links_nothing(self): + game, library = self.steam_install() + shutil.rmtree(library / 'steamapps/compatdata/42') + (self.steam / 'steamapps').mkdir(parents=True, exist_ok=True) + (self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n') + imports.route(self.steam, self.acct, [game]) + self.assertFalse(os.path.lexists(self.shortcut_prefix())) + + def test_an_older_prefix_in_another_library_is_not_linked_for_a_steam_install(self): + game, library = self.steam_install() + shutil.rmtree(library / 'steamapps/compatdata/42') + (self.steam / 'steamapps').mkdir(parents=True, exist_ok=True) + (self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n') + (self.steam / 'steamapps/compatdata/42/pfx/drive_c').mkdir(parents=True) + imports.route(self.steam, self.acct, [game]) + self.assertFalse(os.path.lexists(self.shortcut_prefix())) + + def test_a_dangling_shortcut_prefix_link_is_replaced(self): + game, library = self.steam_install() + self.link_shortcut_prefix(self.root / 'moved') + imports.route(self.steam, self.acct, [game]) + self.assertEqual(library / 'steamapps/compatdata/42', self.shortcut_prefix().resolve()) + + def test_a_dangling_shortcut_prefix_link_is_removed_without_a_replacement(self): + game, library = self.steam_install() + shutil.rmtree(library / 'steamapps/compatdata/42') + self.link_shortcut_prefix(self.root / 'moved') + imports.route(self.steam, self.acct, [game]) + self.assertFalse(os.path.lexists(self.shortcut_prefix())) + + def test_a_link_the_user_made_is_never_changed(self): + game, library = self.steam_install() + for target in (self.root / 'moved', library / 'steamapps/compatdata/42', self.folder): + if os.path.lexists(self.shortcut_prefix()): + self.shortcut_prefix().unlink() + self.link_shortcut_prefix(target, owned=False) + imports.route(self.steam, self.acct, [game]) + self.assertEqual(str(target), os.readlink(self.shortcut_prefix())) + + def test_a_user_link_to_the_matching_prefix_is_never_recorded_or_removed(self): + game, library = self.steam_install() + target = library / 'steamapps/compatdata/42' + self.link_shortcut_prefix(target, owned=False) + imports.route(self.steam, self.acct, [game]) + self.assertEqual(str(target), os.readlink(self.shortcut_prefix())) + self.assertNotIn(str(self.shortcut_prefix()), imports.read_json(self.steam / 'steamapps' / imports.PREFIX_LINKS, {})) + shutil.rmtree(target) + imports.route(self.steam, self.acct, [game]) + self.assertTrue(os.path.islink(self.shortcut_prefix())) + self.assertEqual(str(target), os.readlink(self.shortcut_prefix())) + + def test_our_link_survives_a_library_that_is_temporarily_missing(self): + game, library = self.steam_install() + imports.route(self.steam, self.acct, [game]) + hidden = self.root / 'Hidden' + library.rename(hidden) + imports.route(self.steam, self.acct, [game]) + self.assertTrue(os.path.islink(self.shortcut_prefix())) + hidden.rename(library) + imports.route(self.steam, self.acct, [game]) + self.assertEqual(library / 'steamapps/compatdata/42', self.shortcut_prefix().resolve()) + self.assertEqual(b'cloud', (self.shortcut_prefix() / 'pfx/drive_c/users/steamuser/save.dat').read_bytes()) + + def test_our_link_is_dropped_once_its_prefix_is_deleted(self): + game, library = self.steam_install() + imports.route(self.steam, self.acct, [game]) + shutil.rmtree(library / 'steamapps/compatdata/42') + imports.route(self.steam, self.acct, [game]) + self.assertFalse(os.path.lexists(self.shortcut_prefix())) + self.assertEqual({}, imports.read_json(self.steam / 'steamapps' / imports.PREFIX_LINKS, None)) + + def test_a_replaced_link_record_is_forgotten_when_the_user_makes_a_real_prefix(self): + game, library = self.steam_install() + imports.route(self.steam, self.acct, [game]) + self.shortcut_prefix().unlink() + (self.shortcut_prefix() / 'pfx/drive_c').mkdir(parents=True) + imports.route(self.steam, self.acct, [game]) + self.assertFalse(self.shortcut_prefix().is_symlink()) + self.assertEqual({}, imports.read_json(self.steam / 'steamapps' / imports.PREFIX_LINKS, None)) + + def test_a_shortcut_prefix_with_its_own_saves_is_reported_and_kept(self): + game, library = self.steam_install() + local = self.shortcut_prefix() / 'pfx/drive_c' + local.mkdir(parents=True) + with patch('builtins.print') as printed: + imports.route(self.steam, self.acct, [game]) + self.assertTrue(local.is_dir()) + self.assertIn('keeps its own prefix', printed.call_args_list[-1][0][0]) + + def test_a_winnative_game_shares_the_prefix_the_steam_client_uses(self): + self.winnative_manifest() + (self.steam / 'steamapps/libraryfolders.vdf').parent.mkdir(parents=True, exist_ok=True) + saves = self.steam / 'steamapps/compatdata/42/pfx/drive_c/users/steamuser' + saves.mkdir(parents=True) + (saves / 'save.dat').write_bytes(b'cloud') + imports.share_prefix(self.steam, self.game) + self.assertEqual(self.steam / 'steamapps/compatdata/42', self.shortcut_prefix().resolve()) + + def test_an_unreadable_library_does_not_stop_the_routing(self): + with patch.object(Path, 'glob', side_effect=PermissionError): + games, routes = imports.route(self.steam, self.acct, [self.game]) + self.assertEqual([self.game], games) + self.assertFalse(os.path.lexists(self.shortcut_prefix())) + def test_store_installs_stay_shortcuts_even_when_owned_on_steam(self): self.snapshot() game = dict(self.game, store='amazon')