Files
DroidDeck/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch
T
Kurt Himebauch 3c96df4ab1 feat(ubisoft): Ubisoft Connect store with native sign-in, library, downloads and launch (#2322)
* feat(ubisoft): sign in to Ubisoft Connect and list the owned library

* feat(ubisoft): download games natively through Demux and Ubisoft's CDN

* fix(ubisoft): show the owned games on the store page

* fix(ctl): reach the agent bridge by broadcast when the shell has no content command

* feat(ubisoft): run Ubisoft games in one prefix with Ubisoft Connect installed and the game registered

* feat(ubisoft): launch games through a native stand-in for Ubisoft Connect's local service

* feat(ubisoft): document the launcher pipes, log why owned games have no download, drop the installer after extraction

* feat(ubisoft): Steam-owned Ubisoft games install through Steam, and the launch session carries the game's application id

* feat(ubisoft): answer Denuvo activations through Ubisoft's activation service and log unanswered pipe traffic

* feat(ubisoft): report installed chunks to the game and skip add-ons the account does not own

* fix(ubisoft): the overlay stays off, Ubisoft's install steps pick Windows components, and shared-prefix games keep their own settings

* fix(ubisoft): write the manifest's registry values, and pick the game's exe over Ubisoft's installer stubs

* fix(ubisoft): stream download slices, guard the launch hook, and keep per-game components in the shared prefix

* fix(ubisoft): direct launches in the shared prefix keep their own game id

* ci(ubisoft): build the launcher service after the prepared components are verified

* fix(ubisoft): verify reused files, drop syncs from a signed-out account, and clear activation requests before launch

* fix(ubisoft): fetch again a reused file whose slices cannot be checked
2026-11-10 15:08:37 -05:00

1399 lines
59 KiB
Python
Executable File

#!/usr/bin/python3
import base64
import binascii
import json
import os
import re
import shlex
import shutil
import sys
import time
import subprocess
import tempfile
import hashlib
import threading
from pathlib import Path
LAUNCHER = ".droiddeck-launch.bat"
SIDECAR = ".droiddeck-store.json"
EPIC_CODE = ".droiddeck-epic-code"
EPIC_ARGS = ".droiddeck-epic-args"
EPIC_CLIENT = "/usr/local/bin/droiddeck-epic"
EPIC_CLIENT_CURRENT = ".local/share/droiddeck-epic/current.json"
EPIC_HANDLED = "DROIDDECK_EPIC_HANDLED"
CLIENT_TIMEOUT = 150.0
TIMEOUT = 5.0
STALE_SECONDS = 300
SERVER_WAIT = 8.0
BROWSER = "/usr/local/bin/droiddeck-open-url"
EOS_KEY = "[Software\\\\Epic Games\\\\EOS]"
BROWSER_KEY = "[Software\\\\Wine\\\\WineBrowser]"
def log(line):
print("droiddeck-store-launch: " + line, file=sys.stderr, flush=True)
MAX_DEPTH = 6
def sidecar_at(folder):
try:
sidecar = json.loads((folder / SIDECAR).read_text())
except (OSError, ValueError):
return None
if not isinstance(sidecar, dict) or not sidecar.get("store") or not sidecar.get("id"):
return None
return sidecar
def find_store_game(argv):
for raw in argv:
arg = raw.strip().strip('"').strip("'").replace("\\", "/")
low = arg.lower()
if not (low.endswith(LAUNCHER) or low.endswith(".exe") or low.endswith(".bat")) or "/" not in arg:
continue
folder = Path(arg).parent
for _ in range(MAX_DEPTH):
sidecar = sidecar_at(folder)
if sidecar is not None:
return folder, sidecar
if folder.parent == folder:
break
folder = folder.parent
return None
def drop_stale(folder, now=None):
code = folder / EPIC_CODE
try:
if (now if now is not None else time.time()) - code.stat().st_mtime > STALE_SECONDS:
code.unlink()
return True
except OSError:
pass
return False
def client_wanted():
home = os.environ.get("HOME") or "/root"
return os.access(EPIC_CLIENT, os.X_OK) and os.path.isfile(os.path.join(home, EPIC_CLIENT_CURRENT))
def through_client(folder, sidecar, timeout=CLIENT_TIMEOUT):
extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
namespace, item = str(extra.get("namespace") or ""), str(extra.get("catalogItemId") or "")
if not namespace or not item:
return "no-catalog-ids"
command = [EPIC_CLIENT, "launch", "--app", str(sidecar.get("id")), "--namespace", namespace, "--item", item,
"--folder", str(folder), "--owner", str(os.getppid()), "--timeout", str(int(timeout))]
try:
run = subprocess.run(command, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, timeout=timeout + 30)
answer = json.loads(run.stdout.decode("utf-8", "replace").strip().splitlines()[-1])
except (OSError, ValueError, IndexError, subprocess.TimeoutExpired) as error:
return "error-" + type(error).__name__
if not isinstance(answer, dict):
return "no-answer"
if answer.get("ok") and (folder / EPIC_ARGS).is_file():
return "ok"
return str(answer.get("reason") or "failed")
def ask(channel, request, timeout=TIMEOUT):
req_dir, resp_dir = channel / "req", channel / "resp"
req_dir.mkdir(parents=True, exist_ok=True)
resp_dir.mkdir(parents=True, exist_ok=True)
rid = "%d-%d" % (os.getpid(), int(time.time() * 1000))
tmp = req_dir / (rid + ".json.tmp")
tmp.write_text(json.dumps(request))
os.rename(tmp, req_dir / (rid + ".json"))
answer = resp_dir / (rid + ".json")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
value = json.loads(answer.read_text())
answer.unlink()
return value
except (OSError, ValueError):
time.sleep(0.05)
try:
(req_dir / (rid + ".json")).unlink()
except OSError:
pass
return None
def windows_path(unix):
return "Z:" + unix.replace("/", "\\")
def reg_escape(value):
return value.replace("\\", "\\\\").replace('"', '\\"')
def set_value(reg, key, name, value, now=None):
if not reg.is_file():
return False
line = '"%s"="%s"' % (name, reg_escape(value))
inside, last = False, None
for row in reg.read_text(errors="replace").splitlines():
if row.startswith("["):
inside = row.startswith(key + " ") or row == key
elif inside and row.startswith('"%s"=' % name):
last = row
if last == line:
return True
with open(reg, "a") as out:
out.write("\n%s %d\n%s\n" % (key, int(now if now is not None else time.time()), line))
return True
def remove_value(reg, key, name):
if not reg.is_file():
return False
rows, inside, dropped = [], False, False
for row in reg.read_text(errors="replace").splitlines(keepends=True):
if row.startswith("["):
inside = row.startswith(key + " ") or row.rstrip("\n") == key
elif inside and row.startswith('"%s"=' % name):
dropped = True
continue
rows.append(row)
if dropped:
tmp = reg.with_name(reg.name + ".dd-new")
tmp.write_text("".join(rows))
os.replace(tmp, reg)
return dropped
def options(sidecar):
e = sidecar.get("epic") if isinstance(sidecar.get("epic"), dict) else {}
return {"eos": e.get("eos", True) is not False, "offline": e.get("offline", False) is True}
def server_socket(pfx):
try:
st = os.stat(pfx)
except OSError:
return None
base = os.environ.get("BL_WINE_SERVER_BASE") or "/tmp/.wine-%d" % os.getuid()
return Path(base) / ("server-%x-%x" % (st.st_dev, st.st_ino)) / "socket"
def wait_for_server(pfx, timeout=SERVER_WAIT):
sock = server_socket(pfx)
if sock is None:
return True
deadline = time.monotonic() + timeout
while sock.exists() and time.monotonic() < deadline:
time.sleep(0.1)
return not sock.exists()
def has_value(reg, key, name, value=None):
if not reg.is_file():
return False
inside, last = False, None
for row in reg.read_text(errors="replace").splitlines():
if row.startswith("["):
inside = row.startswith(key + " ") or row == key
elif inside and row.startswith('"%s"=' % name):
last = row
if last is None:
return False
return value is None or last == '"%s"="%s"' % (name, reg_escape(value))
def provision_prefix(compat_data, wait=SERVER_WAIT):
if not compat_data:
return "no-prefix"
pfx = Path(compat_data) / "pfx"
reg = pfx / "user.reg"
if not reg.is_file():
return "prefix-not-created"
busy = not wait_for_server(pfx, wait)
set_value(reg, BROWSER_KEY, "Browsers", BROWSER + ",xdg-open")
removed = remove_value(reg, EOS_KEY, "OverlayPath")
state = "browser-set" if has_value(reg, BROWSER_KEY, "Browsers", BROWSER + ",xdg-open") else "browser-not-written"
if removed:
state += " old-overlay-removed"
return state + (" server-running" if busy else "")
def create_prefix(compat_data, proton, timeout=120):
if not compat_data or not proton or not Path(proton).is_file():
return "no-proton"
reg = Path(compat_data) / "pfx/user.reg"
if reg.is_file():
return "present"
try:
Path(compat_data).mkdir(parents=True, exist_ok=True)
subprocess.run([proton, "run", "cmd.exe", "/d", "/c", "exit"], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
except subprocess.TimeoutExpired:
return "timeout"
except OSError:
return "failed"
return "created" if reg.is_file() else "failed"
CLOUD_TIMEOUT = 15.0
CLOUD_STORES = ("gog", "epic")
STORE_GAME = 10
RAN = 11
RAN_FAILED = 12
BATTLENET = "/usr/local/bin/droiddeck-battlenet"
BATTLENET_EXE = re.compile(r"(?i)/compatdata/2596113170/pfx/drive_c/Program Files \(x86\)/Battle\.net/Battle\.net\.exe$")
BATTLENET_LAUNCH = re.compile(r'(?i)^--exec=\s*"?launch\s+([A-Za-z0-9_]{1,16})"?$')
def battlenet(argv, env=os.environ):
if env.get("BL_BATTLENET"):
return None
exe, code = False, None
for raw in argv:
arg = raw.strip().strip('"').strip("'")
if BATTLENET_EXE.search(arg.replace("\\", "/")):
exe = True
found = BATTLENET_LAUNCH.match(arg)
if found:
code = found.group(1)
if not exe:
return None
return [BATTLENET, "launch", code] if code else [BATTLENET, "open"]
def cloud_on(sidecar):
return str(sidecar.get("store")) in CLOUD_STORES and sidecar.get("cloud", True) is not False
def exited(argv, code=0):
found = find_store_game(argv)
launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
if found is not None and str(found[1].get("store")) == "epic" and os.environ.get(EPIC_HANDLED) != "1" \
and os.access(EPIC_CLIENT, os.X_OK):
try:
subprocess.run([EPIC_CLIENT, "exited", "--code", str(code)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, timeout=10)
except (OSError, subprocess.TimeoutExpired):
pass
if found is not None and launch_dir and str(found[1].get("store")) == "ea":
ask(Path(launch_dir) / "stores", {"op": "ea-exited", "store": "ea", "id": str(found[1].get("id"))}, timeout=1.0)
log("ea %s exited" % found[1].get("id"))
return 0
if found is None or not launch_dir or not cloud_on(found[1]):
return 0
store, game = str(found[1].get("store")), str(found[1].get("id"))
ask(Path(launch_dir) / "stores", {"op": "cloud-up", "store": store, "id": game}, timeout=1.0)
log("cloud %s %s up requested" % (store, game))
return 0
UBISOFT_DIR = "drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher"
UBISOFT_WINDOWS_DIR = "C:\\Program Files (x86)\\Ubisoft\\Ubisoft Game Launcher\\"
UBISOFT_INSTALLER_URL = "https://static3.cdn.ubi.com/orbit/launcher_installer/UbisoftConnectInstaller.exe"
UBISOFT_CACHE = Path("/root/.cache/droiddeck/ubisoft")
UBISOFT_LAUNCH = ".droiddeck-ubisoft.json"
UBISOFT_SERVICE = Path("/usr/local/lib/droiddeck-ubisoft/droiddeck-upc.exe")
UBISOFT_ASK_WAIT = 45.0
UBISOFT_EXTRACT_WAIT = 300
# Ubisoft's own game-side runtime from the Connect installer, unmodified: the API and aux DLLs the
# game's loader opens from InstallDir, the starter it re-executes into and its imports, and the overlay
# DLLs uplay_r1.dll loads even with the overlay off (its startup fails without them).
UBISOFT_RUNTIME = ("uplay_r1.dll", "uplay_r164.dll", "uplay_aux_r1.dll", "uplay_aux_r164.dll",
"uplay_r2.dll", "uplay_r264.dll", "ubiorbitapi_r2.dll", "ubiorbitapi_r264.dll",
"UbisoftGameLauncher.exe", "UbisoftGameLauncher64.exe", "discord-rpc.x86.dll",
"discord-rpc.x64.dll", "steam_api.dll", "steam_api64.dll", "overlay.dll", "overlay64.dll")
UBISOFT_VIEWS = ("[Software\\\\Ubisoft\\\\Launcher%s]", "[Software\\\\Wow6432Node\\\\Ubisoft\\\\Launcher%s]")
MIN_INSTALLER = 1 << 20
def ubisoft_runtime_dir(compat_data):
return Path(compat_data) / "pfx" / UBISOFT_DIR
def ubisoft_runtime_ready(compat_data):
folder = ubisoft_runtime_dir(compat_data)
return all((folder / name).is_file() for name in UBISOFT_RUNTIME)
def fetch_installer(url=UBISOFT_INSTALLER_URL, folder=UBISOFT_CACHE):
target = folder / "UbisoftConnectInstaller.exe"
if target.is_file() and target.stat().st_size >= MIN_INSTALLER:
return target
import urllib.request
folder.mkdir(parents=True, exist_ok=True)
part = target.with_name(target.name + ".part")
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=60) as reply, open(part, "wb") as out:
shutil.copyfileobj(reply, out)
if part.stat().st_size < MIN_INSTALLER:
part.unlink()
raise OSError("installer too small")
os.replace(part, target)
return target
def install_runtime(compat_data, wait=UBISOFT_EXTRACT_WAIT):
if ubisoft_runtime_ready(compat_data):
return "present"
if not (Path(compat_data) / "pfx").is_dir():
return "no-prefix"
try:
installer = fetch_installer()
except OSError as error:
return "download-failed-" + type(error).__name__
target = ubisoft_runtime_dir(compat_data)
target.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="droiddeck-ubisoft-", dir=str(UBISOFT_CACHE)) as tmp:
try:
subprocess.run(["7z", "e", "-y", "-o" + tmp, str(installer)] + list(UBISOFT_RUNTIME), stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=wait)
except subprocess.TimeoutExpired:
return "extract-timeout"
except OSError:
return "no-7z"
missing = [name for name in UBISOFT_RUNTIME if not (Path(tmp) / name).is_file()]
if missing:
return "extract-missing-" + ",".join(missing)
for name in UBISOFT_RUNTIME:
shutil.copyfile(Path(tmp) / name, target / name)
if not ubisoft_runtime_ready(compat_data):
return "copy-failed"
try:
installer.unlink()
except OSError:
pass
return "installed"
def ubisoft_product(sidecar):
extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
try:
return int(str(extra.get("productId") or ""))
except ValueError:
return None
def ubisoft_install_dir(folder):
return windows_path(str(folder)).replace("\\", "/").rstrip("/") + "/"
def register_ubisoft(folder, product, compat_data):
reg = Path(compat_data) / "pfx" / "system.reg"
if not reg.is_file():
return "no-registry"
if not wait_for_server(reg.parent):
return "server-running"
for view in UBISOFT_VIEWS:
set_value(reg, view % "", "InstallDir", UBISOFT_WINDOWS_DIR)
set_value(reg, view % ("\\\\Installs\\\\%d" % product), "InstallDir", ubisoft_install_dir(folder))
return "registered %d" % product
UBISOFT_HIVES = {"HKEY_LOCAL_MACHINE": "system.reg", "HKEY_CURRENT_USER": "user.reg"}
def manifest_value(values, folder, language):
"""The value Connect writes for a manifest registry entry: the install folder, the entry for the
language being played, or the only entry there is."""
entries = [v for v in values if isinstance(v, dict)]
if not entries:
return None
names = [str(v.get("name") or "") for v in entries]
if "$INSTALL_DIR" in names:
return windows_path(str(folder)).rstrip("\\") + "\\"
for v in entries:
if str(v.get("value") or "").lower() == language.lower():
return str(v.get("value"))
prefix = language.split("-")[0].lower()
for v in entries:
if str(v.get("value") or "").lower().split("-")[0] == prefix:
return str(v.get("value"))
return str(entries[0].get("value") or entries[0].get("name") or "")
def apply_manifest_registry(compat_data, registry, folder, language):
written = 0
for item in registry or []:
if not isinstance(item, dict):
continue
path = str(item.get("key") or "").replace("/", "\\")
hive, _, rest = path.partition("\\")
target = UBISOFT_HIVES.get(hive.upper())
if not target or "\\" not in rest:
continue
subkey, _, name = rest.rpartition("\\")
value = manifest_value(item.get("values"), folder, language)
if value is None or not name:
continue
reg = Path(compat_data) / "pfx" / target
parts = subkey.split("\\")
views = [parts]
if target == "system.reg" and len(parts) > 1 and parts[0].lower() == "software" and parts[1].lower() != "wow6432node":
views.append(parts[:1] + ["Wow6432Node"] + parts[1:])
for view in views:
key = "[" + "\\\\".join(view) + "]"
if set_value(reg, key, name, value):
written += 1
return written
def ubisoft_launch(folder, sidecar, product, compat_data, answer):
"""The launch file droiddeck-upc.exe reads: the game, the account and this launch's ticket."""
account = answer.get("account") if isinstance(answer.get("account"), dict) else {}
user = str(account.get("userId") or "user")
saves = Path(compat_data) / "pfx" / "drive_c/users/steamuser/Saved Games/Ubisoft" / user / str(product)
saves.mkdir(parents=True, exist_ok=True)
exe = str(sidecar.get("exe") or "").replace("\\", "/")
workdir = folder / str(sidecar.get("workdir")) if sidecar.get("workdir") else folder
UBISOFT_CACHE.mkdir(parents=True, exist_ok=True)
return {
"productId": product,
"exe": windows_path(str(folder / exe)),
"args": " ".join(str(a) for a in sidecar.get("args") or []),
"workdir": windows_path(str(workdir)),
"account": account,
"ticket": str(answer.get("ticket") or ""),
"owned": [int(i) for i in answer.get("owned") or [] if str(i).isdigit()],
"products": [p for p in answer.get("products") or [] if isinstance(p, dict) and isinstance(p.get("id"), int)],
"appId": str(answer.get("appId") or ""),
"chunks": [c for c in answer.get("chunks") or [] if isinstance(c, dict) and isinstance(c.get("id"), int)],
"saves": "C:/users/steamuser/Saved Games/Ubisoft/%s/%d/" % (user, product),
"language": str(answer.get("language") or "en-US"),
"bridge": windows_path(str(UBISOFT_CACHE / ("bridge-%d" % product))),
"log": windows_path(str(UBISOFT_CACHE / ("upc-%d.log" % product))),
}
def prepare_ubisoft(folder, sidecar, compat_data, launch_dir, proton="", ask_app=None):
launch = folder / UBISOFT_LAUNCH
try:
launch.unlink()
except OSError:
pass
if not compat_data:
return "no-prefix"
product = ubisoft_product(sidecar)
if product is None:
return "no-product"
if not UBISOFT_SERVICE.is_file():
return "no-service"
made = create_prefix(compat_data, proton) if not (Path(compat_data) / "pfx/user.reg").is_file() else "present"
if made not in ("present", "created"):
return "prefix " + made
runtime = install_runtime(compat_data)
if not ubisoft_runtime_ready(compat_data):
return "runtime " + runtime
registry = register_ubisoft(folder, product, compat_data)
if not launch_dir:
return "runtime %s, %s, no-channel" % (runtime, registry)
answer = (ask_app or ask)(Path(launch_dir) / "stores", {"op": "ubisoft-launch", "store": "ubisoft", "id": str(sidecar.get("id"))},
timeout=UBISOFT_ASK_WAIT)
if not isinstance(answer, dict) or not answer.get("ok") or not answer.get("ticket"):
reason = "timeout" if answer is None else str(answer.get("reason") or "refused")
return "runtime %s, %s, session %s" % (runtime, registry, reason)
written = 0
if answer.get("registry") and wait_for_server(Path(compat_data) / "pfx"):
written = apply_manifest_registry(compat_data, answer.get("registry"), folder, str(answer.get("language") or "en-US"))
tmp = launch.with_name(launch.name + ".tmp")
tmp.write_text(json.dumps(ubisoft_launch(folder, sidecar, product, compat_data, answer)))
os.replace(tmp, launch)
return "runtime %s, %s, %d manifest value(s), session ready" % (runtime, registry, written)
COMET_DIR = Path("/usr/local/lib/droiddeck-comet")
def provision_galaxy(compat_data, proton, timeout=90):
if not compat_data or not proton or not Path(proton).is_file():
return "no-proton"
data = Path(compat_data)
data.mkdir(parents=True, exist_ok=True)
marker = data / "pfx/.droiddeck-comet-service"
try:
version = (COMET_DIR / "version").read_text().strip()
tools = [COMET_DIR / "GalaxyCommunication.exe", COMET_DIR / "update-permissions.exe"]
if not all(p.is_file() for p in tools):
return "not-installed"
stamp = version + " " + " ".join(hashlib.sha256(p.read_bytes()).hexdigest() for p in tools)
if marker.is_file() and marker.read_text() == stamp and (data / "pfx/system.reg").is_file():
return "ready"
with tempfile.TemporaryDirectory(prefix="droiddeck-galaxy-") as tmp:
folder = Path(tmp)
result = folder / "result"
bat = folder / "install.bat"
src = windows_path(str(COMET_DIR))
answer = windows_path(str(result))
target = r"C:\ProgramData\GOG.com\Galaxy\redists"
lines = ["@echo off", "setlocal", 'set "DD_DIR=%s"' % target,
"sc query GalaxyCommunication >nul 2>&1",
'if not errorlevel 1 if not exist "%DD_DIR%\\droiddeck-comet" goto existing',
'if not exist "%DD_DIR%" mkdir "%DD_DIR%"',
'copy /y "%s\\GalaxyCommunication.exe" "%s\\GalaxyCommunication.exe" >nul' % (src, target),
'if errorlevel 1 exit /b 1',
'sc query GalaxyCommunication >nul 2>&1',
'if errorlevel 1 sc create GalaxyCommunication binpath= "%s\\GalaxyCommunication.exe"' % target,
'if errorlevel 1 exit /b 1',
'"%s\\update-permissions.exe"' % src,
'if errorlevel 1 exit /b 1',
'sc query GalaxyCommunication >nul 2>&1',
'if errorlevel 1 exit /b 1',
'echo managed>"%s\\droiddeck-comet"' % target,
':existing', 'echo ready>"%s"' % answer, 'exit /b 0']
bat.write_bytes(("\r\n".join(lines) + "\r\n").encode())
env = os.environ.copy()
env["BL_COMET_BOOTSTRAP"] = "1"
run = subprocess.run([proton, "run", "cmd.exe", "/d", "/c", windows_path(str(bat))],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
if run.returncode or not result.is_file() or result.read_text().strip() != "ready":
return "service-unavailable"
marker.write_text(stamp)
return "ready"
except subprocess.TimeoutExpired:
return "timeout"
except (OSError, ValueError):
return "service-unavailable"
def prepare_gog(sidecar, compat_data, launch_dir):
if not launch_dir:
log("gog achievements unavailable: no-channel")
return False
answer = ask(Path(launch_dir) / "stores", {"op": "gog-achievements", "store": "gog", "id": str(sidecar.get("id"))}, timeout=25.0)
if not isinstance(answer, dict) or not answer.get("ready"):
log("gog achievements: " + (str(answer.get("reason", "unavailable")) if isinstance(answer, dict) else "timeout"))
return False
state = provision_galaxy(compat_data, os.environ.get("BL_STORE_PROTON", ""))
log("gog achievements prefix: " + state)
return state == "ready"
GOG_SCRIPTS = "goggame-*.script"
GOG_REGISTRY_MARK = ".droiddeck-gog-registry"
GOG_SUPPORT_MARK = ".droiddeck-gog-support"
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"
# GOG completes some installs by running a support installer from the game folder (the
# goggame-<id>.script "Execute" action). Under ARM64 FEX that 32-bit self-extractor stops at
# "Path not found", so the values it would write are applied here instead. Only products GOG
# ships no setRegistry action for are listed; a script's own entries always win.
# The Witcher: Enhanced Edition (#694) exits during startup unless the installer's
# HKLM\Software\CD Projekt Red\The Witcher keys exist - the same ones The Witcher's own
# setup writes (InstallFolder, RegionVersion, IsDjinniInstalled) plus its language.
GOG_INSTALL_REGISTRY = {
"1207658924": [
("HKLM", "Software\\CD Projekt Red\\The Witcher", "InstallFolder", "string", "{app}\\"),
("HKLM", "Software\\CD Projekt Red\\The Witcher", "RegionVersion", "string", "US"),
("HKLM", "Software\\CD Projekt Red\\The Witcher", "IsDjinniInstalled", "dword", 1),
("HKLM", "Software\\CD Projekt Red\\The Witcher", "Language", "string", "{language}"),
("HKCU", "Software\\CD Projekt RED\\Witcher\\Settings", "TextLanguage", "string", "{language}"),
("HKCU", "Software\\CD Projekt RED\\Witcher\\Settings", "VoiceLanguage", "string", "{language}"),
],
}
# The Witcher's text and voice language IDs, from its installer's registry data.
GOG_LANGUAGE_IDS = {"en": 3, "fr": 11, "de": 10, "es": 12, "it": 13, "ru": 14, "cs": 15, "hu": 16,
"zh": 21, "pl": 5}
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_installed_products(folder):
products = {}
for info in sorted(Path(folder).glob("goggame-*.info")):
product = info.name[len("goggame-"):-len(".info")]
if not product.isdigit():
continue
try:
products[product] = json.loads(info.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
products[product] = {}
return products
def gog_language_id(info):
languages = info.get("languages") if isinstance(info, dict) else None
if isinstance(languages, str):
languages = [languages]
code = str(languages[0]).lower()[:2] if isinstance(languages, list) and languages else ""
return GOG_LANGUAGE_IDS.get(code, GOG_LANGUAGE_IDS["en"])
GOG_VALUE_HIVES = {"hklm": "local_machine", "hkey_local_machine": "local_machine",
"hkcu": "current_user", "hkey_current_user": "current_user",
"hkcr": "classes_root", "hkey_classes_root": "classes_root"}
def gog_value_key(root, section, name, view32=True):
"""A registry value's identity, keeping the root and the 32-bit view apart.
The fallback import always targets the 32-bit view (`/reg:32`), which Wine keeps under
`Software\\Wow6432Node` for HKLM: a native HKLM value must not pass for the 32-bit one.
HKCU\\Software is shared by both views, so its values read the same either way.
"""
text = section.replace("\\\\", "\\").strip("\\").lower()
hive = GOG_VALUE_HIVES.get(root.lower(), root.lower())
if text.startswith("software\\wow6432node\\"):
text = text[len("software\\wow6432node\\"):]
elif hive == "local_machine" and not view32 and text.startswith("software\\"):
text = "native\\" + text
elif text.startswith("software\\"):
text = text[len("software\\"):]
return "%s|%s|%s" % (hive, text, name.lower())
def gog_registry_values(pfx):
"""Values already in the prefix, so install defaults never overwrite a player's choice."""
values = set()
for file, root in (("system.reg", "hklm"), ("user.reg", "hkcu")):
try:
lines = (pfx / file).read_text(encoding="utf-8", errors="ignore").splitlines()
except OSError:
continue
section = ""
for line in lines:
if line.startswith("["):
end = line.find("]")
section = line[1:end] if end > 0 else line[1:]
continue
match = re.match(r'^"([^"]+)"=', line)
if match and section:
values.add(gog_value_key(root, section, match.group(1), view32=False))
return values
def gog_scripted_products(folder):
products = set()
for script in sorted(Path(folder).glob(GOG_SCRIPTS)):
try:
actions = json.loads(script.read_text(encoding="utf-8-sig")).get("actions")
except (OSError, ValueError, AttributeError):
continue
if isinstance(actions, list) and any(
isinstance(action, dict) and isinstance(action.get("install"), dict)
and action["install"].get("action") == "setRegistry" for action in actions):
products.add(script.name[len("goggame-"):-len(".script")])
return products
def gog_expected_values(folder):
"""The fallback values a launch must find, so the marker never hides one that was deleted."""
scripted = gog_scripted_products(folder)
return [gog_value_key(root, subkey, name) for product in gog_installed_products(folder)
if product in GOG_INSTALL_REGISTRY and product not in scripted
for root, subkey, name, _kind, _value in GOG_INSTALL_REGISTRY[product]]
def gog_registry(folder, existing=()):
entries, skipped, scripted = [], 0, set()
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)
scripted.add(product)
for product, info in gog_installed_products(folder).items():
if product in scripted or product not in GOG_INSTALL_REGISTRY:
continue
paths = dict(gog_paths(folder, product), language=str(gog_language_id(info)))
for root, subkey, name, kind, value in GOG_INSTALL_REGISTRY[product]:
if gog_value_key(root, subkey, name) in existing:
continue
entry = gog_entry({"root": root, "subkey": subkey, "valueName": name,
"valueType": kind, "valueData": value}, paths)
if entry is None:
skipped += 1
else:
entries.append(entry)
return entries, skipped
def apply_gog_registry(folder, compat_data, proton, timeout=90, installation=None):
pfx = Path(compat_data) / "pfx" if compat_data else None
existing = gog_registry_values(pfx) if pfx else set()
entries, skipped = gog_registry(folder, existing)
expected = gog_expected_values(folder)
if installation:
product = str(installation["id"])
if not product.isdigit():
raise ValueError("Invalid GOG product identifier")
values = {"PATH": windows_path(str(folder))}
identity = Path(folder) / ("goggame-%s.id" % product)
if identity.is_file():
actual = json.loads(identity.read_text(encoding="utf-8-sig")).get("buildId")
if actual and installation.get("build") and str(actual) != str(installation["build"]):
raise ValueError("GOG build metadata changed before patch setup")
if installation.get("build"):
values["BUILDID"] = installation["build"]
if installation.get("version"):
values["VER"] = installation["version"]
for key, value in values.items():
entry = gog_entry({"root": "HKLM", "subkey": "Software\\GOG.com\\Games\\" + product,
"valueName": key, "valueData": value, "valueType": "string"}, {})
if entry:
entries.append(entry)
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()
marker = pfx / GOG_REGISTRY_MARK
try:
if (marker.is_file() and marker.read_text() == stamp and (pfx / "system.reg").is_file()
and set(expected) <= existing):
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"))
executable = pfx / "drive_c/windows/system32/reg.exe"
run = subprocess.run([proton, "run", str(executable) if executable.is_file() else "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
EA_HARDWARE = ".droiddeck-ea-hw.json"
EA_CLIENT = "C:\\Program Files (x86)\\Origin\\Origin.exe"
EA_WMI = (
("boardManufacturer", "Win32_BaseBoard", "Manufacturer"),
("boardSerial", "Win32_BaseBoard", "SerialNumber"),
("biosManufacturer", "Win32_BIOS", "Manufacturer"),
("biosSerial", "Win32_BIOS", "SerialNumber"),
("osInstallDate", "Win32_OperatingSystem", "InstallDate"),
("osSerial", "Win32_OperatingSystem", "SerialNumber"),
("gpuPnpId", "Win32_VideoController", "PNPDeviceID"),
("cpuManufacturer", "Win32_Processor", "Manufacturer"),
("processorId", "Win32_Processor", "ProcessorId"),
("brand", "Win32_Processor", "Name"),
)
EA_ROOTS = {"HKLM": "HKEY_LOCAL_MACHINE", "HKCU": "HKEY_CURRENT_USER"}
def decode_output(data):
if data[:2] in (b"\xff\xfe", b"\xfe\xff") or (len(data) > 1 and data[1:2] == b"\x00"):
return data.decode("utf-16", "replace")
return data.decode("utf-8", "replace")
def wmi_value(text):
rows = [row.strip() for row in text.replace("\r", "\n").split("\n") if row.strip()]
return rows[1] if len(rows) > 1 else ""
CPUID_LEAVES = (0, 1, 0x80000002, 0x80000003, 0x80000004)
def cpuid_exe():
"""A 32-bit console program that writes the raw CPUID leaves an x86 game sees under the emulator."""
import struct
base, rva = 0x400000, 0x1000
buf, written = base + rva + 0x180, base + rva + 0x1D0
iat = base + rva + 0x138
code = b"\x53\x57\xbf" + struct.pack("<I", buf)
for leaf in CPUID_LEAVES:
code += b"\xb8" + struct.pack("<I", leaf) + b"\x31\xc9\x0f\xa2\x89\x07\x89\x5f\x04\x89\x4f\x08\x89\x57\x0c\x83\xc7\x10"
code += b"\x6a\xf5\xff\x15" + struct.pack("<I", iat)
code += b"\x6a\x00\x68" + struct.pack("<I", written) + b"\x6a\x50\x68" + struct.pack("<I", buf) + b"\x50\xff\x15" + struct.pack("<I", iat + 4)
code += b"\x6a\x00\xff\x15" + struct.pack("<I", iat + 8)
section = bytearray(0x200)
section[:len(code)] = code
names = [(0x148, b"GetStdHandle"), (0x158, b"WriteFile"), (0x164, b"ExitProcess")]
struct.pack_into("<IIIII", section, 0x100, rva + 0x128, 0, 0, rva + 0x172, rva + 0x138)
for i, (at, name) in enumerate(names):
struct.pack_into("<I", section, 0x128 + 4 * i, rva + at)
struct.pack_into("<I", section, 0x138 + 4 * i, rva + at)
section[at + 2:at + 2 + len(name)] = name
section[0x172:0x172 + 12] = b"KERNEL32.dll"
header = bytearray(0x200)
header[0:2] = b"MZ"
struct.pack_into("<I", header, 0x3C, 0x40)
header[0x40:0x44] = b"PE\0\0"
struct.pack_into("<HHIIIHH", header, 0x44, 0x14C, 1, 0, 0, 0, 0xE0, 0x0103)
opt = struct.pack("<HBBIIIIIIIIIHHHHHHIIIIHHIIIIII", 0x10B, 0, 0, 0x200, 0, 0, rva, rva, rva, base, 0x1000, 0x200,
4, 0, 0, 0, 4, 0, 0, 0x2000, 0x200, 0, 3, 0, 0x100000, 0x1000, 0x100000, 0x1000, 0, 16)
dirs = bytearray(128)
struct.pack_into("<II", dirs, 8, rva + 0x100, 40)
struct.pack_into("<II", dirs, 96, rva + 0x138, 16)
header[0x58:0x58 + 0xE0] = opt + bytes(dirs)
header[0x138:0x138 + 40] = struct.pack("<8sIIIIIIHHI", b".text", 0x1000, rva, 0x200, 0x200, 0, 0, 0, 0, 0xE0000060)
return bytes(header) + bytes(section)
EA_STUB_DIR = "Program Files (x86)/Origin"
EA_STUB = "C:\\Program Files (x86)\\Origin\\Origin.exe"
def origin_stub_exe():
"""A 32-bit program that only sleeps: the process older EA SDKs look for while a game runs."""
import struct
base, rva = 0x400000, 0x1000
iat = base + rva + 0x138
code = b"\x6a\xff\xff\x15" + struct.pack("<I", iat) + b"\xeb\xf6"
section = bytearray(0x200)
section[:len(code)] = code
struct.pack_into("<IIIII", section, 0x100, rva + 0x128, 0, 0, rva + 0x172, rva + 0x138)
struct.pack_into("<I", section, 0x128, rva + 0x148)
struct.pack_into("<I", section, 0x138, rva + 0x148)
section[0x14A:0x14A + 5] = b"Sleep"
section[0x172:0x172 + 12] = b"KERNEL32.dll"
header = bytearray(cpuid_exe()[:0x200])
struct.pack_into("<H", header, 0x58 + 68, 2)
return bytes(header) + bytes(section)
def install_origin_stub(compat_data):
if not compat_data:
return "no-prefix"
target = Path(compat_data) / "pfx/drive_c" / EA_STUB_DIR / "Origin.exe"
data = origin_stub_exe()
try:
if target.is_file() and target.read_bytes() == data:
return "ready"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
return "written"
except OSError:
return "failed"
def cpuid_values(raw):
import struct
if len(raw) < 80:
return {}
regs = [struct.unpack_from("<IIII", raw, 16 * i) for i in range(5)]
vendor = struct.pack("<III", regs[0][1], regs[0][3], regs[0][2]).split(b"\0")[0].decode("latin-1")
brand = b"".join(struct.pack("<IIII", *r) for r in regs[2:]).split(b"\0")[0].decode("latin-1")
return {"cpuVendor": vendor, "cpuEax": "%08X" % regs[1][0], "cpuEdx": "%08X" % regs[1][3],
"cpuEcx": "%08x" % regs[1][2], "cpuBrand": brand}
EA_PROBE_FIELDS = ("boardManufacturer", "biosManufacturer", "osInstallDate", "osSerial", "volumeSerial", "hostname",
"cpuVendor", "cpuEax", "cpuEdx", "cpuEcx")
def probe_complete(values):
return all(str(values.get(key) or "").strip() for key in EA_PROBE_FIELDS)
def ea_hardware(compat_data, proton, timeout=240):
pfx = Path(compat_data) / "pfx"
cache = pfx / EA_HARDWARE
try:
cached = json.loads(cache.read_text())
if isinstance(cached, dict) and probe_complete(cached):
return cached, "cached"
except (OSError, ValueError):
pass
if not proton or not Path(proton).is_file():
return {}, "no-proton"
values = {}
with tempfile.TemporaryDirectory(prefix="droiddeck-ea-") as tmp:
folder = Path(tmp)
lines = ["@echo off"]
for key, cls, prop in EA_WMI:
lines.append('wmic path %s get %s > "%s"' % (cls, prop, windows_path(str(folder / (key + ".txt")))))
lines.append('vol C: > "%s"' % windows_path(str(folder / "volume.txt")))
lines.append('hostname > "%s"' % windows_path(str(folder / "hostname.txt")))
(folder / "cpuid.exe").write_bytes(cpuid_exe())
lines.append('"%s" > "%s"' % (windows_path(str(folder / "cpuid.exe")), windows_path(str(folder / "cpuid.bin"))))
bat = folder / "probe.bat"
bat.write_bytes(("\r\n".join(lines) + "\r\n").encode())
try:
subprocess.run([proton, "run", "cmd.exe", "/d", "/c", windows_path(str(bat))], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
except subprocess.TimeoutExpired:
return {}, "timeout"
except OSError:
return {}, "failed"
for key, _, _ in EA_WMI:
try:
values[key] = wmi_value(decode_output((folder / (key + ".txt")).read_bytes()))
except OSError:
values[key] = ""
try:
found = re.search(r"([0-9A-Fa-f]{4})-([0-9A-Fa-f]{4})", decode_output((folder / "volume.txt").read_bytes()))
values["volumeSerial"] = (found.group(1) + found.group(2)).lower() if found else ""
except OSError:
values["volumeSerial"] = ""
try:
values["hostname"] = decode_output((folder / "hostname.txt").read_bytes()).strip().splitlines()[0].strip()
except (OSError, IndexError):
values["hostname"] = ""
try:
values.update(cpuid_values((folder / "cpuid.bin").read_bytes()))
except OSError:
pass
if probe_complete(values):
try:
cache.write_text(json.dumps(values))
except OSError:
pass
return values, "probed"
return values, "incomplete"
def ea_registry_entries(folder, sidecar):
install = windows_path(str(folder)).rstrip("\\") + "\\"
extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
title = str(extra.get("displayName") or sidecar.get("title") or folder.name).strip()
locale = str(extra.get("locale") or "en_US")
entries = [
("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\EA Desktop", "InstallSuccessful", "true"),
("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\Origin", "InstallSuccessful", "true"),
("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\Origin", "ClientPath", EA_CLIENT),
("HKEY_LOCAL_MACHINE\\SOFTWARE\\Origin", "ClientPath", EA_CLIENT),
]
for content in [c.strip() for c in str(extra.get("contentIds") or extra.get("contentId") or "").split(",")]:
if re.fullmatch(r"[A-Za-z0-9._-]{1,64}", content):
key = "HKEY_LOCAL_MACHINE\\SOFTWARE\\Origin Games\\" + content
entries += [(key, "DisplayName", title), (key, "Install Dir", install), (key, "Locale", locale)]
try:
keys = json.loads(extra.get("registry") or "[]")
except ValueError:
keys = []
for pair in keys if isinstance(keys, list) else []:
if not isinstance(pair, list) or len(pair) != 2:
continue
key, name = str(pair[0]), str(pair[1])
root, _, rest = key.partition("\\")
key = EA_ROOTS.get(root.upper(), root) + ("\\" + rest if rest else "")
if key.upper().startswith(("HKEY_LOCAL_MACHINE\\", "HKEY_CURRENT_USER\\")) and name and "\n" not in key + name:
entries.append((key, name, install))
if title:
entries.append((key, "DisplayName", title))
entries.append((key, "Locale", locale))
return entries
def ea_registry(folder, sidecar, compat_data, proton, timeout=90):
if not compat_data or not proton or not Path(proton).is_file():
return "no-proton"
pfx = Path(compat_data) / "pfx"
text = "Windows Registry Editor Version 5.00\r\n\r\n" + "".join(
'[%s]\r\n"%s"="%s"\r\n\r\n' % (key, reg_escape(name), reg_escape(value)) for key, name, value in ea_registry_entries(folder, sidecar))
stamp = hashlib.sha256(text.encode("utf-8")).hexdigest()
marker = pfx / (".droiddeck-ea-registry-" + re.sub(r"[^A-Za-z0-9._-]", "_", str(sidecar.get("id"))))
try:
if marker.is_file() and marker.read_text() == stamp and (pfx / "system.reg").is_file():
return "ready"
with tempfile.TemporaryDirectory(prefix="droiddeck-ea-") as tmp:
source = Path(tmp) / "registry.reg"
source.write_bytes(b"\xff\xfe" + text.encode("utf-16-le"))
executable = pfx / "drive_c/windows/system32/reg.exe"
for view in ("/reg:32", "/reg:64"):
run = subprocess.run([proton, "run", str(executable) if executable.is_file() else "reg.exe", "import", windows_path(str(source)), view],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
if run.returncode:
return "import-failed"
marker.write_text(stamp)
return "written"
except subprocess.TimeoutExpired:
return "timeout"
except OSError:
return "import-failed"
def find_case(folder, relative):
current = Path(folder)
for part in [p for p in relative.replace("\\", "/").split("/") if p and p != "."]:
exact = current / part
if exact.exists():
current = exact
continue
try:
current = next(c for c in current.iterdir() if c.name.lower() == part.lower())
except (OSError, StopIteration):
return None
return current if current != Path(folder) else None
def touchup_args(raw, locale, install):
raw = raw.replace("{locale}", locale)
raw = raw.replace('"{installLocation}"', '"%s"' % install).replace("{installLocation}", install)
try:
return shlex.split(raw.replace("\\", "\\\\"))
except ValueError:
return raw.split()
def ea_touchup(folder, sidecar, compat_data, proton, timeout=900):
extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
relative = str(extra.get("touchup") or "")
if not relative:
return "none"
exe = find_case(folder, relative)
if exe is None or not exe.is_file():
return "missing"
if not compat_data or not proton or not Path(proton).is_file():
return "no-proton"
pfx = Path(compat_data) / "pfx"
stamp = str(sidecar.get("installVersion") or "") + " " + str(sidecar.get("installedAt") or "")
marker = pfx / (".droiddeck-ea-touchup-" + re.sub(r"[^A-Za-z0-9._-]", "_", str(sidecar.get("id"))))
try:
if marker.is_file() and marker.read_text() == stamp:
return "done"
except OSError:
pass
args = touchup_args(str(extra.get("touchupArgs") or ""), str(extra.get("locale") or "en_US"), windows_path(str(folder)).rstrip("\\"))
try:
run = subprocess.run([proton, "run", str(exe)] + args, cwd=str(exe.parent), stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
result = "ok" if run.returncode == 0 else "exit-%d" % run.returncode
except subprocess.TimeoutExpired:
result = "timeout"
except OSError:
return "failed"
try:
marker.write_text(stamp)
except OSError:
pass
return result
def write_licenses(compat_data, licenses):
if not compat_data:
return 0
root = Path(compat_data) / "pfx/drive_c"
written = 0
for item in licenses or []:
relative = str(item.get("path") or "") if isinstance(item, dict) else ""
if not relative or relative.startswith("/") or ".." in relative.split("/"):
continue
target = root / relative
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(base64.b64decode(item.get("data") or ""))
written += 1
except (OSError, binascii.Error):
pass
return written
def prepare_ea(folder, sidecar, compat_data, launch_dir):
game = str(sidecar.get("id"))
proton = os.environ.get("BL_STORE_PROTON", "")
made = create_prefix(compat_data, proton)
ready = made in ("present", "created") or (compat_data and (Path(compat_data) / "pfx/user.reg").is_file())
stub = install_origin_stub(compat_data) if ready else "no-prefix"
hardware, probe = ea_hardware(compat_data, proton) if ready else ({}, "no-prefix")
answer = None
if launch_dir:
answer = ask(Path(launch_dir) / "stores", {"op": "ea-prepare", "store": "ea", "id": game, "hardware": hardware}, timeout=40.0)
sidecar = sidecar_at(Path(folder)) or sidecar
log("ea %s prefix=%s stub=%s hardware=%s registry=%s touchup=%s" % (
game, made, stub, probe, ea_registry(folder, sidecar, compat_data, proton), ea_touchup(folder, sidecar, compat_data, proton)))
if answer is None:
log("ea %s session=no reason=%s" % (game, "timeout" if launch_dir else "no-channel"))
return
written = write_licenses(compat_data, answer.get("licenses"))
log("ea %s session=%s reason=%s lsx=%s licence=%s files=%d" % (
game, "yes" if answer.get("ok") else "no", answer.get("reason", ""), answer.get("lsx"), answer.get("license", ""), written))
def gog_support_actions(folder):
found = []
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
for action in actions if isinstance(actions, list) else []:
install = action.get("install") if isinstance(action, dict) else None
if isinstance(install, dict) and install.get("action") == "supportData" \
and isinstance(install.get("arguments"), dict):
found.append((product, install["arguments"]))
return found
def gog_support_bases(folder, product, pfx):
user = Path(pfx) / "drive_c" / "users" / "steamuser"
support = Path(folder) / "support" / product
return {"app": Path(folder), "supportdir": support, "support": support,
"userdocs": user / "Documents", "localappdata": user / "AppData" / "Local"}
def gog_inside(path, base):
root = os.path.realpath(base)
real = os.path.realpath(path)
return real == root or real.startswith(root + os.sep)
def gog_support_path(text, bases):
match = re.fullmatch(r"\{(\w+)\}(.*)", text) if isinstance(text, str) else None
if not match or match.group(1).lower() not in bases:
return None
parts = [part for part in re.split(r"[\\/]+", match.group(2)) if part]
if any(part in (".", "..") for part in parts):
return None
base = bases[match.group(1).lower()]
path = base
for part in parts:
exact = path / part
if not exact.exists() and path.is_dir():
exact = next((child for child in path.iterdir() if child.name.lower() == part.lower()), exact)
path = exact
if path != base and not gog_inside(path.parent, base):
return None
return path, base
def gog_copy_file(source, target, root):
if not gog_inside(target.parent, root):
raise OSError("outside " + str(root))
target.parent.mkdir(parents=True, exist_ok=True)
if target.is_symlink():
target.unlink()
shutil.copy2(source, target, follow_symlinks=False)
def gog_support_data(folder, compat_data, proton):
actions = gog_support_actions(folder)
if not actions:
return "none"
pfx = Path(compat_data) / "pfx" if compat_data else None
if not pfx or not (pfx / "drive_c" / "users" / "steamuser").is_dir():
made = create_prefix(compat_data, proton)
if made not in ("present", "created") or not (pfx / "drive_c" / "users" / "steamuser").is_dir():
return "no-prefix"
stamp = hashlib.sha256(json.dumps(actions, sort_keys=True).encode("utf-8")).hexdigest()
marker = pfx / GOG_SUPPORT_MARK
try:
if marker.is_file() and marker.read_text() == stamp:
return "ready"
except OSError:
pass
copied = skipped = missing = 0
for product, arguments in actions:
bases = gog_support_bases(folder, product, pfx)
found = gog_support_path(arguments.get("target"), bases)
origin = gog_support_path(arguments["source"], bases) if "source" in arguments else None
if found is None or ("source" in arguments and origin is None):
skipped += 1
continue
target, root = found
source = origin[0] if origin else None
try:
if source is None:
target.mkdir(parents=True, exist_ok=True)
elif not source.exists():
missing += 1
elif source.is_dir() and not source.is_symlink():
for top, dirs, files in os.walk(source):
for name in sorted(files + [d for d in dirs if (Path(top) / d).is_symlink()]):
item = Path(top) / name
destination = target / item.relative_to(source)
if (destination.exists() or destination.is_symlink()) and not arguments.get("overwrite"):
continue
gog_copy_file(item, destination, root)
copied += 1
target.mkdir(parents=True, exist_ok=True)
elif (target.exists() or target.is_symlink()) and not arguments.get("overwrite"):
continue
else:
gog_copy_file(source, target, root)
copied += 1
except OSError:
skipped += 1
note = "".join(" %s=%d" % (name, count) for name, count in (("skipped", skipped), ("missing", missing)) if count)
if not skipped:
try:
marker.write_text(stamp)
except OSError:
pass
return "copied=%d%s" % (copied, note)
def main(argv):
if os.environ.get("DROIDDECK_GOG_MAINTENANCE") == "1":
return 0
if argv and argv[0] == "--exited":
rest, code = argv[1:], 0
if rest[:1] == ["--code"] and len(rest) > 1:
try:
code = int(rest[1])
except ValueError:
pass
rest = rest[2:]
return exited(rest, code)
command = battlenet(argv)
if command is not None:
log("battlenet " + " ".join(command[1:]))
return RAN if subprocess.call(command) == 0 else RAN_FAILED
found = find_store_game(argv)
if found is None:
return 0
folder, sidecar = found
store, game = str(sidecar.get("store")), str(sidecar.get("id"))
if store == "gog":
helper = Path(__file__).resolve().with_name("droiddeck-gog-maintenance")
if helper.is_file():
child = subprocess.Popen([sys.executable, str(helper), "lease", str(folder), str(os.getppid())],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
start_new_session=True)
ready = child.stdout.readline().decode().strip()
child.stdout.close()
threading.Thread(target=child.wait, daemon=True).start()
if ready != "ready":
log("gog: close or recover the maintenance operation before playing")
return RAN_FAILED
compat_data = os.environ.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
prefix_id = os.path.basename(compat_data) if compat_data else ""
launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
if launch_dir and cloud_on(sidecar):
made = create_prefix(compat_data, os.environ.get("BL_STORE_PROTON", ""))
if made != "present":
log("cloud %s %s prefix=%s" % (store, game, made))
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", "")))
log("gog support data: " + gog_support_data(folder, compat_data, os.environ.get("BL_STORE_PROTON", "")))
if store == "ubisoft":
try:
log("ubisoft %s: %s" % (game, prepare_ubisoft(folder, sidecar, compat_data, launch_dir, os.environ.get("BL_STORE_PROTON", ""))))
except OSError as e:
try:
(folder / UBISOFT_LAUNCH).unlink()
except OSError:
pass
log("ubisoft %s: not prepared (%s), starting directly" % (game, e))
if store == "epic":
handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file()
if not handled:
drop_stale(folder)
try:
(folder / EPIC_ARGS).unlink()
except OSError:
pass
choice = options(sidecar)
client = None
if handled:
client = "ok"
elif choice["eos"] and not choice["offline"] and client_wanted():
client = through_client(folder, sidecar)
log("epic launch id=%s client=%s" % (game, client))
if client == "ok":
try:
(folder / EPIC_CODE).unlink()
except OSError:
pass
log("epic launch id=%s through=client" % game)
elif not (choice["eos"] and not choice["offline"]):
try:
(folder / EPIC_CODE).unlink()
except OSError:
pass
log("epic launch id=%s code=no reason=%s" % (game, "offline" if choice["offline"] else "eos-off"))
elif not launch_dir:
log("epic launch id=%s code=no reason=no-channel" % game)
else:
answer = ask(Path(launch_dir) / "stores", {"op": "epic-code", "store": store, "id": game, "prefix": prefix_id})
if answer is None:
log("epic launch id=%s code=no reason=timeout" % game)
else:
got = bool(answer.get("code"))
log("epic launch id=%s code=%s reason=%s" % (game, "yes" if got else "no", answer.get("reason", "")))
log("epic prefix=%s %s" % (prefix_id or "?", provision_prefix(compat_data)))
if store == "ea":
prepare_ea(folder, sidecar, compat_data, launch_dir)
return STORE_GAME
if not cloud_on(sidecar):
if store in CLOUD_STORES:
log("cloud %s %s down result=skipped reason=off" % (store, game))
return 0
if launch_dir:
answer = ask(Path(launch_dir) / "stores", {"op": "cloud-down", "store": store, "id": game}, timeout=CLOUD_TIMEOUT)
if answer is None:
log("cloud %s %s down result=timeout" % (store, game))
else:
log("cloud %s %s down result=%s files=%s reason=%s" % (store, game, answer.get("result", "?"), answer.get("files", 0), answer.get("reason", "")))
else:
log("cloud %s %s down result=skipped reason=no-channel" % (store, game))
return STORE_GAME
if __name__ == "__main__":
try:
sys.exit(main(sys.argv[1:]))
except Exception as e:
log("skipped: %s" % type(e).__name__)
sys.exit(0)