From 79f43d8defca8353df4167b14f4cdc59308c43be Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 00:38:08 -0400 Subject: [PATCH] fix: GOG games miss the registry entries their install script sets (#1159) A GOG download only places the files, so games that look up their own registry keys (Fallout: New Vegas reads HKLM\Software\Bethesda Softworks\FalloutNV "Installed Path") said they were not installed. Every GOG launch now reads the setRegistry actions of the game's goggame-.script and imports them into the prefix's 32-bit view through Proton's reg.exe, once per change of the script, so games already downloaded are fixed on their next launch. --- README.md | 2 +- .../usr/local/bin/droiddeck-store-launch | 121 ++++++++++++++++++ tools/tests/test_store_launch.py | 93 ++++++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ab977bb6..dd9866aef 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch index 0b6f64ee4..48d39813e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch @@ -28,8 +28,11 @@ build wrote is removed - with it the game exited while loading. droiddeck-store-launch """ +import base64 +import binascii import json import os +import re import sys import time import subprocess @@ -364,6 +367,123 @@ def prepare_gog(sidecar, compat_data, launch_dir): return state == "ready" +GOG_SCRIPTS = "goggame-*.script" +GOG_REGISTRY_MARK = ".droiddeck-gog-registry" +GOG_ROOTS = {"HKEY_LOCAL_MACHINE": "HKEY_LOCAL_MACHINE", "HKLM": "HKEY_LOCAL_MACHINE", + "HKEY_CURRENT_USER": "HKEY_CURRENT_USER", "HKCU": "HKEY_CURRENT_USER", + "HKEY_CLASSES_ROOT": "HKEY_CLASSES_ROOT", "HKCR": "HKEY_CLASSES_ROOT"} +WINE_USER = "C:\\users\\steamuser" + + +def gog_paths(folder, product): + app = windows_path(str(folder)) + support = app + "\\support\\" + product + return {"app": app, "productid": product, "support": support, "supportdir": support, + "localappdata": WINE_USER + "\\AppData\\Local", "userdocs": WINE_USER + "\\Documents"} + + +def gog_expand(text, paths): + return re.sub(r"\{([A-Za-z]+)\}", lambda m: paths[m.group(1).lower()], text) + + +def gog_number(data): + if isinstance(data, bool): + raise ValueError + if isinstance(data, int): + return data + text = str(data).strip() + try: + return int(text, 0) + except ValueError: + return int(text, 10) + + +def gog_value(kind, data): + kind = str(kind).lower() + if kind == "string": + text = str(data) + if any(c in text for c in "\r\n\0"): + return None + return '"%s"' % reg_escape(text) + if kind == "dword": + return "dword:%08x" % (gog_number(data) & 0xFFFFFFFF) + if kind == "binary": + return "hex:" + ",".join("%02x" % b for b in base64.b64decode(str(data), validate=True)) + return None + + +def gog_entry(arguments, paths): + if not isinstance(arguments, dict): + return None + try: + root = GOG_ROOTS.get(str(arguments.get("root") or "").upper()) + subkey = gog_expand(str(arguments.get("subkey") or ""), paths).strip("\\") + if root is None or not subkey or any(c in subkey for c in "\r\n\0"): + return None + section = "[%s\\%s]" % (root, subkey) + if not arguments.get("valueType"): + return section + data = arguments.get("valueData") + value = gog_value(arguments["valueType"], gog_expand(data, paths) if isinstance(data, str) else data) + name = gog_expand(str(arguments.get("valueName") or ""), paths) + if value is None or any(c in name for c in "\r\n\0"): + return None + return section + "\r\n" + ('"%s"' % reg_escape(name) if name else "@") + "=" + value + except (KeyError, ValueError, TypeError, binascii.Error): + return None + + +def gog_registry(folder): + entries, skipped = [], 0 + for script in sorted(Path(folder).glob(GOG_SCRIPTS)): + product = script.name[len("goggame-"):-len(".script")] + try: + actions = json.loads(script.read_text(encoding="utf-8-sig")).get("actions") + except (OSError, ValueError, AttributeError): + continue + paths = gog_paths(folder, product) + for action in actions if isinstance(actions, list) else []: + install = action.get("install") if isinstance(action, dict) else None + if not isinstance(install, dict) or install.get("action") != "setRegistry": + continue + entry = gog_entry(install.get("arguments"), paths) + if entry is None: + skipped += 1 + else: + entries.append(entry) + return entries, skipped + + +def apply_gog_registry(folder, compat_data, proton, timeout=90): + entries, skipped = gog_registry(folder) + note = " skipped=%d" % skipped if skipped else "" + if not entries: + return "none" + note + if not compat_data or not proton or not Path(proton).is_file(): + return "no-proton" + note + text = "Windows Registry Editor Version 5.00\r\n\r\n" + "".join(entry + "\r\n\r\n" for entry in entries) + stamp = hashlib.sha256(text.encode("utf-8")).hexdigest() + pfx = Path(compat_data) / "pfx" + marker = pfx / GOG_REGISTRY_MARK + try: + if marker.is_file() and marker.read_text() == stamp and (pfx / "system.reg").is_file(): + return "ready" + note + Path(compat_data).mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="droiddeck-gog-") as tmp: + source = Path(tmp) / "registry.reg" + source.write_bytes(b"\xff\xfe" + text.encode("utf-16-le")) + run = subprocess.run([proton, "run", "reg.exe", "import", windows_path(str(source)), "/reg:32"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout) + if run.returncode or not (pfx / "system.reg").is_file(): + return "import-failed" + note + marker.write_text(stamp) + return "written=%d%s" % (len(entries), note) + except subprocess.TimeoutExpired: + return "timeout" + note + except OSError: + return "import-failed" + note + + def main(argv): if argv and argv[0] == "--exited": rest, code = argv[1:], 0 @@ -384,6 +504,7 @@ def main(argv): launch_dir = os.environ.get("BL_LAUNCH_DIR", "") if store == "gog": prepare_gog(sidecar, compat_data, launch_dir) + log("gog registry: " + apply_gog_registry(folder, compat_data, os.environ.get("BL_STORE_PROTON", ""))) if store == "epic": handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file() if not handled: diff --git a/tools/tests/test_store_launch.py b/tools/tests/test_store_launch.py index 1acf493ac..1c4ac4bba 100644 --- a/tools/tests/test_store_launch.py +++ b/tools/tests/test_store_launch.py @@ -314,3 +314,96 @@ class GogAchievements(unittest.TestCase): with patch.object(store, 'COMET_DIR', base), patch.object(store.subprocess, 'run', return_value=__import__('subprocess').CompletedProcess([], 1)): self.assertEqual('service-unavailable', store.provision_galaxy(str(base / 'compat'), str(base / 'proton'))) self.assertFalse((base / 'compat/pfx/.droiddeck-comet-service').exists()) + + +def gog_script(folder, product, actions): + (folder / ('goggame-%s.script' % product)).write_text(json.dumps({'actions': actions, 'baseProductId': product, 'version': 1})) + + +def set_registry(**arguments): + return {'install': {'action': 'setRegistry', 'arguments': arguments}, 'name': 'setRegistry'} + + +class GogRegistry(unittest.TestCase): + def test_the_scripts_registry_values_are_read_with_the_games_paths(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='1454587428') + gog_script(folder, '1454587428', [ + set_registry(root='HKEY_LOCAL_MACHINE', subkey='Software\\Bethesda Softworks\\FalloutNV', + valueName='Installed Path', valueData='{app}\\', valueType='string'), + set_registry(root='HKEY_LOCAL_MACHINE', subkey='Software\\GOG.com\\Games\\{productId}', + valueName='buildId', valueData='42', valueType='dword'), + set_registry(root='HKEY_CURRENT_USER', subkey='Software\\Game', valueName='', valueData='AAH/', valueType='binary'), + set_registry(root='HKEY_LOCAL_MACHINE', subkey='Software\\Empty'), + set_registry(root='HKEY_LOCAL_MACHINE', subkey='Software\\Game', valueName='x', valueData='1', valueType='qword'), + set_registry(root='HKEY_LOCAL_MACHINE', subkey='Software\\Game', valueName='x', valueData='{nowhere}', valueType='string'), + set_registry(root='HKEY_USERS', subkey='S-1-5', valueName='x', valueData='1', valueType='string'), + {'install': {'action': 'Execute', 'arguments': {'executable': 'setup.exe'}}}, + 'nonsense', + ]) + entries, skipped = store.gog_registry(folder) + app = store.windows_path(str(folder)).replace('\\', '\\\\') + self.assertEqual([ + '[HKEY_LOCAL_MACHINE\\Software\\Bethesda Softworks\\FalloutNV]\r\n"Installed Path"="%s\\\\"' % app, + '[HKEY_LOCAL_MACHINE\\Software\\GOG.com\\Games\\1454587428]\r\n"buildId"=dword:0000002a', + '[HKEY_CURRENT_USER\\Software\\Game]\r\n@=hex:00,01,ff', + '[HKEY_LOCAL_MACHINE\\Software\\Empty]', + ], entries) + self.assertEqual(3, skipped) + + def test_a_game_without_a_script_starts_without_proton(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog') + with patch.object(store.subprocess, 'run') as run: + self.assertEqual('none', store.apply_gog_registry(folder, root + '/compat', root + '/proton')) + run.assert_not_called() + + def test_values_are_imported_into_the_32_bit_view_once_until_the_script_changes(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='1454587428') + proton = Path(root) / 'proton' + proton.write_text('selected Proton') + data = Path(root) / 'compat' + action = set_registry(root='HKLM', subkey='Software\\Bethesda Softworks\\FalloutNV', + valueName='Installed Path', valueData='{app}', valueType='string') + gog_script(folder, '1454587428', [action]) + imported = [] + def run(argv, **kwargs): + self.assertEqual([str(proton), 'run', 'reg.exe', 'import'], argv[:4]) + self.assertEqual('/reg:32', argv[5]) + raw = Path(argv[4][2:].replace('\\', '/')).read_bytes() + self.assertTrue(raw.startswith(b'\xff\xfe')) + imported.append(raw[2:].decode('utf-16-le')) + (data / 'pfx').mkdir(parents=True, exist_ok=True) + (data / 'pfx/system.reg').write_text('Wine registry') + return __import__('subprocess').CompletedProcess(argv, 0) + with patch.object(store.subprocess, 'run', side_effect=run): + self.assertEqual('written=1', store.apply_gog_registry(folder, str(data), str(proton))) + self.assertTrue(imported[0].startswith('Windows Registry Editor Version 5.00\r\n')) + self.assertIn('[HKEY_LOCAL_MACHINE\\Software\\Bethesda Softworks\\FalloutNV]', imported[0]) + self.assertEqual('ready', store.apply_gog_registry(folder, str(data), str(proton))) + self.assertEqual(1, len(imported)) + action['install']['arguments']['valueData'] = '{app}\\' + gog_script(folder, '1454587428', [action]) + self.assertEqual('written=1', store.apply_gog_registry(folder, str(data), str(proton))) + self.assertEqual(2, len(imported)) + + def test_a_failed_import_is_tried_again_next_launch(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='7') + proton = Path(root) / 'proton' + proton.write_text('selected Proton') + gog_script(folder, '7', [set_registry(root='HKLM', subkey='Software\\Game', valueName='a', valueData='b', valueType='string')]) + with patch.object(store.subprocess, 'run', return_value=__import__('subprocess').CompletedProcess([], 1)): + self.assertEqual('import-failed', store.apply_gog_registry(folder, root + '/compat', str(proton))) + self.assertFalse((Path(root) / 'compat/pfx' / store.GOG_REGISTRY_MARK).exists()) + self.assertEqual('no-proton', store.apply_gog_registry(folder, root + '/compat', root + '/missing')) + + def test_every_gog_launch_brings_the_registry_in(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog') + with patch.dict(os.environ, {'BL_LAUNCH_DIR': root, 'STEAM_COMPAT_DATA_PATH': root + '/compat/', 'BL_STORE_PROTON': '/chosen/proton'}), \ + patch.object(store, 'prepare_gog', return_value=False), patch.object(store, 'ask', return_value=None), \ + patch.object(store, 'apply_gog_registry', return_value='ready') as apply: + store.main([str(folder / store.LAUNCHER)]) + apply.assert_called_once_with(folder, root + '/compat', '/chosen/proton')