mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
* 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
1399 lines
59 KiB
Python
Executable File
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)
|