Files
DroidDeck/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env
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

1407 lines
54 KiB
Python
Executable File

#!/usr/bin/python3
import hashlib
import json
import os
import re
import runpy
import signal
import subprocess
import sys
import tempfile
from pathlib import Path
GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG"
CHROMIUM_FILES = {"chrome-sandbox", "chrome_100_percent.pak", "v8_context_snapshot.bin"}
SYNC_KEYS = ("BL_SYNC", "BL_SYNC_FALLBACK", "BL_FSYNC_FIRST", "BL_SYNC_MIN_NOFILE", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC")
SEED_REDISTS = "/usr/local/bin/droiddeck-seed-redists"
PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC")
# Prefixes several games share; the game itself is the Steam app being launched.
SHARED_PREFIXES = {"2929927072"}
def app_number(value):
if not re.fullmatch(r"-?[0-9]+", value or ""):
return None
number = int(value)
if not -(2**31) <= number < 2**32 or number == 0:
return None
return str(number % 2**32)
def game_id(env):
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
if not prefix:
return None
found = app_number(prefix.rsplit("/", 1)[-1])
if found in SHARED_PREFIXES:
return app_number(env.get("SteamAppId", "")) or shortcut_number(env.get("SteamGameId", "")) or found
return found
def shortcut_number(value):
"""The shortcut appid inside Steam's 64-bit shortcut game id `(appid << 32) | 0x02000000`."""
if not re.fullmatch(r"[0-9]{1,20}", value or ""):
return None
number = int(value)
if number >= 2**64 or number & 0xFFFFFFFF != 0x02000000:
return None
return app_number(str(number >> 32))
DOTNET_FEX = {
"FEX_TSOENABLED": "1",
"FEX_VECTORTSOENABLED": "1",
"FEX_MEMCPYSETTSOENABLED": "1",
"FEX_HALFBARRIERTSOENABLED": "1",
"FEX_X87REDUCEDPRECISION": "0",
}
def game_files(exe):
names = set()
folder = os.path.dirname(exe)
try:
with os.scandir(folder) as top:
for entry in top:
names.add(entry.name.lower())
if entry.is_dir(follow_symlinks=False):
try:
with os.scandir(entry.path) as inner:
names.update(e.name.lower() for e in inner)
except OSError:
pass
except OSError:
pass
return names
class Game:
def __init__(self, exe, args):
self.exe = exe
self.args = args
self.stem = os.path.splitext(os.path.basename(exe))[0].lower()
self.files = game_files(exe)
def has_arg(self, name):
return any(a == name or a.startswith(name + "=") for a in self.args)
FIXES = []
def fix(function):
FIXES.append(function)
return function
def is_godot(game):
return game.stem + ".pck" in game.files or "godotsharp.dll" in game.files
@fix
def dotnet_memory_ordering(game):
if "coreclr.dll" in game.files:
return DOTNET_FEX, [], ".NET runtime: FEX with full TSO"
@fix
def unity_memory_ordering(game):
if {"unityplayer.so", "gameassembly.so", "unityplayer.dll"} & game.files:
return {"FEX_TSOENABLED": "1"}, [], "Unity: FEX with TSO"
@fix
def godot_vulkan(game):
if is_godot(game) and not game.has_arg("--rendering-driver"):
return {}, ["--rendering-driver", "vulkan"], "Godot: Vulkan renderer"
@fix
def godot_sentry(game):
sentry = sorted(n[:-4] for n in game.files if n.startswith("libsentry.windows.") and n.endswith(".dll"))
if is_godot(game) and sentry and ("coreclr.dll" in game.files or "godotsharp.dll" in game.files):
return {"WINEDLLOVERRIDES": ";".join(n + "=d" for n in sentry)}, [], "Godot: Sentry crash handler off"
@fix
def d3dgear_hook(game):
if "d3dgear64.dll" in game.files:
return {"WINEDLLOVERRIDES": "d3dgear64=d"}, [], "D3DGear capture hook off (it stops the game drawing)"
@fix
def supergiant_dotnet_reporter(game):
if "basecampbugreporter.native.dll" in game.files:
return {"WINEDLLOVERRIDES": "mscoree=n,b"}, [], "Supergiant: allow the builtin mscoree"
@fix
def deep_rock_gdk_raytracing(game):
if game.stem == "fsd-wingdk-shipping" and not any(game.has_arg(arg) for arg in ("-raytracing", "-noraytracing")):
return {}, ["-noraytracing", "-ini:Engine:[/Script/Engine.RendererSettings]:r.RayTracing=False"], "Deep Rock Galactic GDK: ray tracing off (D3D12 pipeline creation fails on Thor)"
@fix
def arkham_city_x87(game):
if "batmanac.exe" in game.files:
return {"FEX_X87REDUCEDPRECISION": "0"}, [], "Batman: Arkham City: FEX with full x87 precision"
@fix
def doom_2016_swapchain_images(game):
if "doomx64vk.exe" in game.files and game.stem in ("doomx64", "doomx64vk"):
images = {
"GAMESCOPE_WSI_MIN_IMAGE_COUNT": None,
"GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT": None,
"vk_x11_strict_image_count": "true",
}
return images, [], "DOOM (2016): the swapchain keeps the image count the game asks for"
@fix
def teardown_vsync(game):
if game.stem == "teardown":
return {"vblank_mode": "2"}, [], "Teardown: OpenGL keeps the game's own vsync"
@fix
def protonfixes_off(game):
if game.stem in ("blackops3", "darksoulsremastered"):
return {"PROTONFIXES_DISABLE": "1"}, [], "umu-protonfixes off (its ALSA audio and VC++ installer steps fail here)"
@fix
def little_nightmares_threads(game):
if game.stem == "littlenightmares":
return {"PROTONFIXES_DISABLE": "1"}, [], "Little Nightmares: umu-protonfixes off (its -onethread puts the game and renderer on one thread)"
def skip_red_launcher(command):
if len(command) > 2 and os.path.basename(command[2]).lower() == "redprelauncher.exe":
game = os.path.join(os.path.dirname(command[2]), "bin", "x64", "Cyberpunk2077.exe")
if os.path.isfile(game):
print("droiddeck-game-env: REDlauncher skipped", file=sys.stderr)
return command[:2] + [game] + command[3:]
return command
UBISOFT_LAUNCHER = "drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher"
UBISOFT_SETTINGS = "drive_c/users/steamuser/AppData/Local/Ubisoft Game Launcher/settings.yaml"
def overlay_off(text):
lines = text.splitlines()
try:
start = next(i for i, line in enumerate(lines) if re.fullmatch(r"overlay:\s*(#.*)?", line))
except StopIteration:
if lines and not lines[-1].strip():
lines.pop()
return "\n".join(lines + ["overlay:", " enabled: false", " warning_enabled: false"]) + "\n"
end = start + 1
for index in range(start + 1, len(lines)):
if lines[index].strip() and not lines[index].startswith(" "):
break
if lines[index].strip():
end = index + 1
block = lines[start + 1:end]
for key in ("enabled", "warning_enabled"):
entry = " %s: false" % key
found = [i for i, line in enumerate(block) if line.split(":", 1)[0] == " " + key]
if found:
block[found[0]] = entry
else:
block.append(entry)
return "\n".join(lines[:start + 1] + block + lines[end:]) + "\n"
def ubisoft_overlay_off(env):
prefix = Path(env.get("STEAM_COMPAT_DATA_PATH", "/nonexistent")) / "pfx"
if not (prefix / UBISOFT_LAUNCHER).is_dir():
return
settings = prefix / UBISOFT_SETTINGS
try:
text = settings.read_text(encoding="utf-8") if settings.exists() else ""
updated = overlay_off(text)
if updated == text:
return
settings.parent.mkdir(parents=True, exist_ok=True)
handle, staged = tempfile.mkstemp(prefix=".settings-", dir=settings.parent)
try:
with os.fdopen(handle, "w", encoding="utf-8") as target:
target.write(updated)
if settings.exists():
os.chmod(staged, settings.stat().st_mode & 0o7777)
os.replace(staged, settings)
except BaseException:
os.unlink(staged)
raise
print("droiddeck-game-env: Ubisoft Connect overlay turned off", file=sys.stderr)
except (OSError, UnicodeDecodeError) as error:
print("droiddeck-game-env: Ubisoft Connect overlay left as is (%s)" % error, file=sys.stderr)
def ubisoft_composition_off(proton, env):
prefix = Path(env.get("STEAM_COMPAT_DATA_PATH", "/nonexistent")) / "pfx"
if not (prefix / UBISOFT_LAUNCHER).is_dir() or not (prefix / "user.reg").is_file():
return
try:
for program in ("upc.exe", "UplayWebCore.exe"):
key = r"HKCU\Software\Wine\AppDefaults\%s\DllOverrides" % program
subprocess.run([proton, "runinprefix", "reg.exe", "add", key,
"/v", "dcomp", "/t", "REG_SZ", "/d", "", "/f"], env=env, check=True)
print("droiddeck-game-env: Ubisoft Connect uses CEF without DirectComposition", file=sys.stderr)
except (OSError, ValueError, subprocess.CalledProcessError) as error:
print("droiddeck-game-env: Ubisoft Connect composition configuration failed (%s)" % error, file=sys.stderr)
PZ_JVM_FLAGS = ("-XX:+UseG1GC", "-XX:TieredStopAtLevel=1")
def zomboid_vm_args(args):
updated = [arg for arg in args if not re.fullmatch(r"-XX:\+Use\w*GC", arg) or arg == "-XX:+UseG1GC"]
for flag in PZ_JVM_FLAGS:
if flag not in updated:
updated.append(flag)
return updated
def zomboid_rewrite(node):
changed = False
if isinstance(node, dict):
for key, value in node.items():
if key == "vmArgs" and isinstance(value, list) and all(isinstance(arg, str) for arg in value):
updated = zomboid_vm_args(value)
if updated != value:
node[key] = updated
changed = True
elif isinstance(value, dict):
changed |= zomboid_rewrite(value)
return changed
def project_zomboid_jvm(command):
if len(command) <= 2:
return
exe = Path(command[2])
if not exe.stem.lower().startswith("projectzomboid"):
return
settings = exe.with_suffix(".json")
try:
with settings.open(encoding="utf-8-sig") as source:
data = json.load(source)
if not zomboid_rewrite(data):
return
handle, staged = tempfile.mkstemp(prefix=".droiddeck-", dir=settings.parent)
try:
with os.fdopen(handle, "w", encoding="utf-8") as target:
json.dump(data, target, indent=4)
target.write("\n")
os.chmod(staged, settings.stat().st_mode & 0o7777)
os.replace(staged, settings)
except BaseException:
os.unlink(staged)
raise
print("droiddeck-game-env: Project Zomboid: G1 garbage collector and C1 JIT only (ZGC overheats and stalls here)",
file=sys.stderr)
except (OSError, ValueError, AttributeError) as error:
print("droiddeck-game-env: Project Zomboid JVM settings left as is (%s)" % error, file=sys.stderr)
def offline_without_anticheat(command, env):
if (env.get("DROIDDECK_ANTICHEAT_OFFLINE") != "1" or len(command) <= 2
or command[1] != "waitforexitandrun"
or not re.fullmatch(r"start_protected_game(?:_?x64|_?64)?\.exe",
os.path.basename(command[2]), re.IGNORECASE)):
return command
folder = os.path.dirname(command[2])
try:
with open(os.path.join(folder, "EasyAntiCheat", "settings.json"), encoding="utf-8-sig") as source:
executable = json.load(source).get("executable")
except (OSError, ValueError, AttributeError):
return command
if not isinstance(executable, str) or not executable:
return command
relative = os.path.normpath(executable.replace("\\", "/"))
game = os.path.join(folder, relative)
if os.path.isabs(relative) or relative.startswith("..") or not os.path.isfile(game):
return command
root = os.path.realpath(folder)
if os.path.commonpath((root, os.path.realpath(game))) != root:
return command
if os.path.normcase(os.path.abspath(game)) == os.path.normcase(os.path.abspath(command[2])):
return command
print("droiddeck-game-env: started without Easy Anti-Cheat (offline only)", file=sys.stderr)
return command[:2] + [game] + command[3:]
def engine_fixes(exe, args):
game = Game(exe, args)
env, extra, notes = {}, [], []
for check in FIXES:
found = check(game)
if found:
for name, value in found[0].items():
if name == "WINEDLLOVERRIDES" and env.get(name):
value = env[name] + ";" + value
env[name] = value
extra += found[1]
notes.append(found[2])
return env, extra, notes
def win_components(compat, app_id, proton=None, env=None):
if not compat:
return ""
try:
module = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wincomponents"))
names, notes = module["apply"](compat.rstrip("/"), app_id, proton=proton, env=env or os.environ)
for note in notes:
print("droiddeck-game-env: " + note, file=sys.stderr)
return module["overrides_value"](names)
except Exception as error:
print("droiddeck-game-env: Windows components not applied (%s)" % error, file=sys.stderr)
return ""
def chromium_app(folder):
root = os.path.realpath(folder)
for top, dirs, files in os.walk(root):
if CHROMIUM_FILES.intersection(files) or CHROMIUM_FILES.intersection(dirs):
return True
if os.path.relpath(top, root).count(os.sep) >= 2:
dirs[:] = []
return False
def game_gpu(env, keep=False):
if GAME_TU_DEBUG not in env:
return env
result = env.copy()
value = result.pop(GAME_TU_DEBUG)
if keep:
return result
if value:
result["TU_DEBUG"] = value
else:
result.pop("TU_DEBUG", None)
return result
def vram_cap(env):
value = env.get("BL_VRAM_MB", "")
if not re.fullmatch(r"[1-9][0-9]{0,6}", value):
return env
result = env.copy()
cap = "dxgi.maxDeviceMemory = %d" % int(value)
own = result.get("DXVK_CONFIG", "").strip().strip(";").strip()
result["DXVK_CONFIG"] = cap + "; " + own if own else cap
result["DROIDDECK_VRAM_CAP"] = "1"
result["DROIDDECK_VRAM_CAP_MB"] = value
return result
def parse_cores(text):
cores = set()
for part in text.replace(" ", "").split(","):
match = re.fullmatch(r"(\d{1,3})(?:-(\d{1,3}))?", part)
if not match:
return None
first, last = int(match.group(1)), int(match.group(2) or match.group(1))
if last < first:
return None
cores.update(range(first, last + 1))
return cores or None
def online_cores():
try:
with open("/sys/devices/system/cpu/online") as f:
cores = parse_cores(f.read().strip())
except OSError:
cores = None
return cores or os.sched_getaffinity(0) | set(range(os.cpu_count() or 0))
def game_cores(env):
text = env.get("DROIDDECK_GAME_CORES", "").strip()
if not text:
return
cores = parse_cores(text)
chosen = cores & online_cores() if cores else None
if not chosen:
print("droiddeck-game-env: ignoring game cores %r" % text, file=sys.stderr)
return
try:
os.sched_setaffinity(0, chosen)
print("droiddeck-game-env: game cores %s" % ",".join(map(str, sorted(chosen))), file=sys.stderr)
except OSError as error:
print("droiddeck-game-env: could not use game cores %r (%s)" % (text, error), file=sys.stderr)
def core_speeds(cores, root="/sys/devices/system/cpu"):
for name in ("cpu_capacity", "cpufreq/cpuinfo_max_freq"):
speeds = {}
for core in cores:
try:
with open("%s/cpu%d/%s" % (root, core, name)) as f:
speeds[core] = int(f.read().strip())
except (OSError, ValueError):
break
else:
return speeds
return None
def fastest_first(cores):
cores = sorted(cores)
speeds = core_speeds(cores)
return sorted(cores, key=lambda core: -speeds[core]) if speeds else cores
def proton_cpu_limit(command, env):
app_id = env.get("SteamGameId", "")
if not command or not app_id.isdigit():
return None
try:
if os.path.getsize(command[0]) > 4 * 1024 * 1024:
return None
with open(command[0], encoding="utf-8", errors="replace") as f:
text = f.read()
except OSError:
return None
table = re.search(r"^default_cpu_limit = \{(.*?)^\}", text, re.M | re.S)
match = table and re.search(r'^\s*"%s"\s*:\s*"?(\w+)' % app_id, table.group(1), re.M)
if not match:
return None
value = match.group(1)
return int(value) if value.isdigit() else value
def cpu_topology(env, command=()):
if "PROTON_CPU_TOPOLOGY" in env:
return
if "WINE_CPU_TOPOLOGY" in env:
if env["WINE_CPU_TOPOLOGY"]:
env["PROTON_CPU_TOPOLOGY"] = env["WINE_CPU_TOPOLOGY"]
return
picked = bool(env.get("DROIDDECK_GAME_CORES", "").strip() or env.get("BL_GAME_CPUS", "").strip())
fastest = env.get("DROIDDECK_FASTEST_CORES", "").strip() != "0" and game_id(env) is not None
if not (picked or fastest):
return
try:
cores = sorted(os.sched_getaffinity(0))
except OSError:
return
online = online_cores()
if not cores or cores[-1] >= len(online):
return
picked = picked and not set(cores) >= online
if fastest:
limit = None if picked else proton_cpu_limit(command, env)
if isinstance(limit, str):
return
cores = fastest_first(cores)[:limit or None]
elif set(cores) >= online:
return
if cores == sorted(online):
return
env["WINE_CPU_TOPOLOGY"] = env["PROTON_CPU_TOPOLOGY"] = "%d:%s" % (len(cores), ",".join(map(str, cores)))
print("droiddeck-game-env: Windows sees %d cores (%s)" % (len(cores), env["WINE_CPU_TOPOLOGY"]), file=sys.stderr)
def gl_vsync(env):
if "vblank_mode" in env or game_id(env) is None:
return False
env["vblank_mode"] = "0"
return True
def game_images(env):
if game_id(env) is None:
return False
env.setdefault("GAMESCOPE_WSI_MIN_IMAGE_COUNT", "8")
env.setdefault("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT", "1")
return True
def apply_config(env, config, app_id, fixes=None):
if config.get("version") != 1:
raise ValueError("unsupported environment configuration")
result = env.copy()
entries = dict(config.get("shared", {}))
for name, value in (fixes or {}).items():
if name == "WINEDLLOVERRIDES" and entries.get(name, env.get(name)):
value = entries.get(name, env.get(name)) + ";" + value
entries[name] = value
entries.update(config.get("games", {}).get(app_id, {}))
for name, value in entries.items():
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
raise ValueError("invalid environment name")
if value is None:
result.pop(name, None)
elif isinstance(value, str) and "\0" not in value and len(value) <= 8192:
result[name] = value
else:
raise ValueError("invalid environment value")
game_dxvk = config.get("dxvkConfigGames", {})
if not isinstance(game_dxvk, dict):
raise ValueError("invalid game dxvk configuration")
dxvk = game_dxvk.get(app_id, config.get("dxvkConfig"))
if dxvk is not None:
if not isinstance(dxvk, str) or "\0" in dxvk or len(dxvk) > 8192:
raise ValueError("invalid dxvk configuration")
if dxvk:
own = result.get("DXVK_CONFIG", "").strip().rstrip(";").strip()
result["DXVK_CONFIG"] = own + "; " + dxvk if own else dxvk
# An open driver pair (PanVK, RADV) on a GPU that is not an Adreno: vkd3d-proton reports the
# driver's own feature level and shader model. The app-wide 12_2 / 6_9 over-promised what PanVK
# has, and an Unreal DX12 game took paths that crashed. A game's own setting still wins.
if env.get("BL_OPEN_DRIVER") == "1":
game = config.get("games", {}).get(app_id, {}) or {}
for name in ("VKD3D_FEATURE_LEVEL", "VKD3D_SHADER_MODEL"):
if name not in game and name in result:
result.pop(name)
result["DROIDDECK_VKD3D_CAPS"] = "driver"
return result
def render_path_defaults(command, env):
if len(command) < 3 or command[1] != "waitforexitandrun" or Path(command[2]).name.lower() != "spintires.exe":
return None
own = env.get("DXVK_CONFIG", "")
if re.search(r"(?:^|;)\s*dxvk\.tilerMode\s*=", own) or env.get("DXVK_CONFIG_FILE"):
return None
if Path("dxvk.conf").is_file() or Path(command[2]).with_name("dxvk.conf").is_file():
return None
driver = env.get("VK_DRIVER_FILES", env.get("VK_ICD_FILENAMES", ""))
if Path(driver).parent.name != "DD-Turnip_v0.4.0":
return None
try:
if Path("/sys/class/kgsl/kgsl-3d0/gpu_model").read_text().strip() != "Adreno722":
return None
with open(driver, encoding="utf-8") as source:
library = json.load(source)["ICD"]["library_path"]
if Path(library).name != "libvulkan_freedreno.so":
return None
except (OSError, ValueError, KeyError, TypeError):
return None
own = own.strip().rstrip(";").strip()
env["DXVK_CONFIG"] = own + "; dxvk.tilerMode = False" if own else "dxvk.tilerMode = False"
return "Spintires: DXVK tiler heuristics off on Adreno 722"
def bridge_feature_level(env):
if env.get("BL_VKBRIDGE") != "1" or env.get("VKD3D_FEATURE_LEVEL") not in ("12_1", "12_2"):
return None
was = env["VKD3D_FEATURE_LEVEL"]
env["VKD3D_FEATURE_LEVEL"] = "12_0"
return "Mali bridge: VKD3D_FEATURE_LEVEL %s capped at 12_0 (no ray tracing or mesh shaders)" % was
GAME_DEBUG_CHANNELS = (
"timestamp", "pid", "tid", "seh", "unwind", "threadname", "debugstr", "loaddll", "mscoree",
)
WINE_DEBUG_SELECTOR = re.compile(r"(?:(trace|warn|err|fixme))?([+-])([A-Za-z_][A-Za-z0-9_]*)")
def game_debug_channels(*values):
selectors = {}
for value in values:
if not isinstance(value, str) or "\0" in value or len(value) > 8192:
continue
for part in value.split(","):
part = part.strip()
match = WINE_DEBUG_SELECTOR.fullmatch(part)
if match is None:
continue
key = (match[1] or "", match[3])
selectors.pop(key, None)
selectors[key] = part
for channel in GAME_DEBUG_CHANNELS:
key = ("", channel)
selectors.pop(key, None)
selectors[key] = "+" + channel
formatted = ",".join(selectors.values())
if len(formatted) > 8192:
formatted = ",".join("+" + channel for channel in GAME_DEBUG_CHANNELS)
return formatted
def apply_game_debug_logs(env, config, debug_dir):
if not isinstance(config, dict) or config.get("version") != 1 or config.get("gameDebugLogs") is not True:
return env
if (not isinstance(debug_dir, str) or "\0" in debug_dir or len(debug_dir) > 8192
or not os.path.isabs(debug_dir) or not os.path.isdir(debug_dir)):
print("droiddeck-game-env: game debug logs skipped; session log directory unavailable", file=sys.stderr)
return env
result = dict(env)
result.update({
"PROTON_LOG": "1",
"PROTON_LOG_DIR": debug_dir,
"PROTON_CRASH_REPORT_DIR": debug_dir,
"DXVK_LOG_PATH": debug_dir,
"DXVK_LOG_LEVEL": "debug",
"VKD3D_DEBUG": "info",
"WINEDEBUG": game_debug_channels(env.get("WINEDEBUG"), env.get("PROTON_LOG")),
})
return result
def sync_config(config, app_id):
games = config.get("games", {})
return {
"version": config.get("version"),
"shared": {name: value for name, value in config.get("shared", {}).items() if name in SYNC_KEYS},
"games": {app_id: {name: value for name, value in games.get(app_id, {}).items() if name in SYNC_KEYS}},
}
def load_sync():
try:
return runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-esync"))
except Exception as error:
print("droiddeck-game-env: sync selection failed (%s); stock launch" % error, file=sys.stderr)
return None
def select_sync(command, env, sync=None):
try:
sync = sync if sync is not None else load_sync()
if sync is None:
return command, env
chosen, chosen_env = sync["select"](command, env)
if chosen and all(isinstance(value, str) for value in chosen) and isinstance(chosen_env, dict):
return list(chosen), dict(chosen_env)
except Exception as error:
print("droiddeck-game-env: sync selection failed (%s); stock launch" % error, file=sys.stderr)
return command, env
def record_stock(sync, command, env):
try:
if sync is not None and "record" in sync:
sync["record"](command, env)
except Exception as error:
print("droiddeck-game-env: stock launch not recorded (%s)" % error, file=sys.stderr)
def without_pack(chosen_env, env):
result = dict(chosen_env)
for name in PACK_KEYS:
if name in env:
result[name] = env[name]
else:
result.pop(name, None)
return result
SSBS_LIB = "/usr/local/lib/libssbs.so"
def force_ssbs(command, env):
if env.get("DROIDDECK_FORCE_SSBS", "").lower() not in ("1", "on", "true", "yes"):
return env
proton = os.path.dirname(command[0]) if command else ""
if not os.path.isdir(os.path.join(proton, "files/lib/wine/aarch64-unix")):
print("droiddeck-game-env: force SSBS asked for, but this Proton is not an ARM64 one; left off", file=sys.stderr)
return env
if not os.path.isfile(SSBS_LIB):
print("droiddeck-game-env: force SSBS asked for, but %s is missing; left off" % SSBS_LIB, file=sys.stderr)
return env
parts = [p for p in re.split(r"[:\s]+", env.get("LD_PRELOAD", "")) if p]
if SSBS_LIB not in parts:
env = dict(env, LD_PRELOAD=":".join(parts + [SSBS_LIB]))
print("droiddeck-game-env: force SSBS on (libssbs.so preloaded)", file=sys.stderr)
return env
EVALUATOR_SCRIPT = re.compile(r"(?:^|[\\/])evaluatorscript_[0-9]+\.vdf$", re.IGNORECASE)
def mark_install_scripts(command, env):
if not any(arg.lower().replace("\\", "/").endswith("iscriptevaluator.exe") for arg in command):
return []
root = env.get("BL_X64_STEAM_ROOT") or env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or str(Path.home() / ".local/share/Steam")
scripts = [str(Path(root) / arg.replace("\\", "/")) for arg in command if EVALUATOR_SCRIPT.search(arg)]
scripts = [path for path in scripts if os.path.isfile(path)]
if not scripts:
return []
try:
subprocess.run([SEED_REDISTS, root], env=dict(env, DROIDDECK_SEED_SCRIPTS="\n".join(scripts)),
stdout=sys.stderr, timeout=60, check=False)
except (OSError, subprocess.SubprocessError) as error:
print("droiddeck-game-env: install scripts not marked (%s)" % error, file=sys.stderr)
return scripts
INSTALL_STAMP = ".droiddeck-install-scripts"
VDF_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])')
def read_vdf(text):
tokens = iter(VDF_TOKEN.findall(text))
def block():
items = []
for word, brace in tokens:
if brace == "}":
return items
if brace:
raise ValueError("a block without a name")
value, opens = next(tokens, ("", "}"))
if opens == "}":
raise ValueError("a name without a value")
items.append((re.sub(r"\\(.)", r"\1", word), block() if opens else re.sub(r"\\(.)", r"\1", value)))
return items
return block()
def run_keys(items, appid=""):
appid = next((value for name, value in items if name.lower() == "appid" and isinstance(value, str)), appid)
found = set()
for name, value in items:
if not isinstance(value, list):
continue
if any(isinstance(v, str) and re.fullmatch(r"process [0-9]+", n.lower()) for n, v in value):
key = next((v for n, v in value if n.lower() == "hasrunkey" and isinstance(v, str)),
"HKEY_LOCAL_MACHINE\\Software\\Valve\\Steam\\Apps\\" + appid)
found.add((key.lower(), name.lower()))
found |= run_keys(value, appid)
return found
def registry_values(path):
found, section = set(), None
with open(path, encoding="utf-8", errors="replace") as reg:
for line in reg:
if line.startswith("["):
section = line[1:line.find("]")].replace("\\\\", "\\").lower() if "]" in line else None
elif section and line.startswith('"') and '"=' in line:
found.add((section, line[1:line.index('"=')].replace("\\\\", "\\").lower()))
return found
def has_run(key, value, values):
path = key[len("hkey_local_machine\\"):] if key.startswith("hkey_local_machine\\") else None
if path is None:
return False
views = [path]
if path.startswith("software\\"):
views.append("software\\wow6432node\\" + path[len("software\\"):])
return any((view, value) in values for view in views)
def scripts_digest(scripts):
digest = hashlib.sha256()
for path in sorted(scripts):
digest.update(os.path.basename(path).encode() + b"\0")
digest.update(Path(path).read_bytes())
return digest.hexdigest()
def install_scripts_done(scripts, env):
prefix = Path(env.get("STEAM_COMPAT_DATA_PATH", "/nonexistent")) / "pfx"
try:
if (prefix / INSTALL_STAMP).read_text().strip() != scripts_digest(scripts):
return False
keys = set()
for path in scripts:
keys |= run_keys(read_vdf(Path(path).read_text(encoding="utf-8", errors="replace")))
values = registry_values(prefix / "system.reg")
except (OSError, ValueError):
return False
return bool(keys) and all(has_run(key, value, values) for key, value in keys)
def record_install_scripts(digest, env):
prefix = Path(env.get("STEAM_COMPAT_DATA_PATH", "/nonexistent")) / "pfx"
try:
if prefix.is_dir():
staged = prefix / (INSTALL_STAMP + ".tmp")
staged.write_text(digest + "\n")
staged.replace(prefix / INSTALL_STAMP)
except OSError as error:
print("droiddeck-game-env: install scripts not recorded (%s)" % error, file=sys.stderr)
def directaudio_settings(env):
if "directaudio" not in env.get("WINE_AUDIO_DRIVER", "").split(","):
return env
for name, value in list(env.items()):
if name.startswith("AUDIO_DIRECT_"):
env["BANNER_" + name] = value
return env
DIRT_GAMES = {"dirt3_game": "dirt3", "showdown": "dirtshowdown", "showdown_avx": "dirtshowdown"}
DIRT_WS2 = "/usr/local/lib/droiddeck-dirt/ws2_32.dll"
DIRT_MARKER = b"droiddeck-dirt-ws2_32"
DIRT_FIXED = "showdown_avx.exe".encode("utf-16-le")
DIRT_SAVED = ".droiddeck-original"
def dirt_telemetry_on(prefix, game):
try:
folders = [f for f in (Path(prefix) / "pfx/drive_c/users/steamuser/Documents/My Games").iterdir()
if f.name.lower().replace(" ", "") == game]
except OSError:
return False
for folder in folders:
try:
text = (folder / "hardwaresettings/hardware_settings_config.xml").read_text(encoding="latin-1")
except OSError:
continue
for tag in ("udp", "motion"):
element = re.search(r"<%s\s[^>]*>" % tag, text)
if element:
attrs = dict(re.findall(r'\s(\w+)="([^"]*)"', element.group(0)))
return attrs.get("enabled") == "true" and attrs.get("ip", "").lower() not in ("", "dbox")
return False
def proton_ws2(proton):
return Path(proton).parent / "files/lib/wine/i386-windows/ws2_32.dll"
def proton_has_dirt_fix(proton):
try:
return DIRT_FIXED in proton_ws2(proton).read_bytes()
except OSError:
return False
WINE_DLL_DIRS = ("/lib/wine/i386-windows", "/lib/wine/x86_64-windows", "/lib/wine/aarch64-windows", "/lib/wine",
"/lib/wine/fakedlls", "/lib64/wine", "/lib64/wine/fakedlls", "/lib64/wine/x86_64-windows")
FOREIGN_SUFFIX = ".foreign-arch"
PE = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wine-pe"))
pe_machine = PE["pe_machine"]
def repair_foreign_system_dlls(prefix, proton, env=None):
dist = Path(proton).parent / "files/lib/wine"
windows = Path(prefix) / "pfx/drive_c/windows"
machines_by_folder = PE["proton_pe_machines"](proton, env or {})
if machines_by_folder is None:
return []
repaired = []
for folder, (libdir, machines) in machines_by_folder.items():
builtins = dist / libdir
try:
builtin_names = {name.lower(): name for name in os.listdir(builtins)}
except OSError:
continue
try:
names = sorted(os.listdir(windows / folder))
except OSError:
continue
for name in names:
path = windows / folder / name
builtin_name = builtin_names.get(name.lower())
source = builtins / builtin_name if builtin_name else builtins / name
if path.is_symlink() or not name.lower().endswith(".dll") or not source.is_file() or not path.is_file():
continue
machine = pe_machine(path)
if machine is None or PE["machine_compatible"](path, machines, machine):
continue
link = windows / folder / (name + ".bl-new")
backup = windows / folder / (name + FOREIGN_SUFFIX)
serial = 1
while os.path.lexists(backup):
backup = windows / folder / (name + FOREIGN_SUFFIX + ".%d" % serial)
serial += 1
try:
os.symlink(os.path.relpath(source, path.parent), link)
os.replace(path, backup)
try:
os.replace(link, path)
except OSError:
if not os.path.lexists(path) and os.path.lexists(backup):
os.replace(backup, path)
raise
except OSError:
if link.is_symlink():
link.unlink()
continue
repaired.append("%s/%s (machine 0x%x)" % (folder, name, machine))
return repaired
def is_wine_dll_link(path):
try:
return os.path.islink(path) and os.path.dirname(os.readlink(path)).endswith(WINE_DLL_DIRS)
except OSError:
return False
def prefix_ws2(prefix):
return Path(prefix) / "pfx/drive_c/windows/syswow64/ws2_32.dll"
def is_dirt_stand_in(path):
try:
return not path.is_symlink() and DIRT_MARKER in path.read_bytes()
except OSError:
return False
def dirt_ws2_wanted(command, env):
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
if len(command) < 3 or not prefix or not os.path.isfile(DIRT_WS2):
return None
game = DIRT_GAMES.get(os.path.splitext(os.path.basename(command[2]))[0].lower())
if not game or not dirt_telemetry_on(prefix, game) or proton_has_dirt_fix(command[0]):
return None
return prefix if os.path.lexists(prefix_ws2(prefix)) else None
def swap_dirt_ws2(prefix):
target = prefix_ws2(prefix)
saved = target.with_name(target.name + DIRT_SAVED)
data = Path(DIRT_WS2).read_bytes()
if not is_dirt_stand_in(target):
if os.path.lexists(saved):
os.unlink(saved)
os.replace(target, saved)
staged = target.with_name(target.name + ".droiddeck-tmp")
staged.write_bytes(data)
os.replace(staged, target)
def restore_dirt_ws2(prefix, proton):
target = prefix_ws2(prefix)
saved = target.with_name(target.name + DIRT_SAVED)
own = proton_ws2(proton)
had_saved = os.path.lexists(saved)
if had_saved:
if os.path.lexists(target) and not is_dirt_stand_in(target):
os.unlink(saved)
return False
if not own.is_file() or not is_wine_dll_link(saved) or os.path.realpath(saved) == os.path.realpath(own):
os.replace(saved, target)
return True
os.unlink(saved)
if own.is_file() and (is_dirt_stand_in(target) or (had_saved and not os.path.lexists(target))):
link = target.with_name(target.name + ".droiddeck-tmp")
if os.path.lexists(link):
os.unlink(link)
os.symlink(own, link)
os.replace(link, target)
return True
return False
EGO_RESTRICTIONS = "system/hardware_settings_restrictions.xml"
EGO_CORE_RULE = re.compile(r'[ \t]*<restrict_if name="cores" op="(int_[a-z]+)" value="(\d+)">.*?</restrict_if>[ \t]*\r?\n', re.S)
EGO_OPS = {"int_eq": int.__eq__, "int_ne": int.__ne__, "int_ge": int.__ge__, "int_gt": int.__gt__,
"int_le": int.__le__, "int_lt": int.__lt__}
def windows_core_count(env):
count = (env.get("WINE_CPU_TOPOLOGY") or "").split(":", 1)[0]
return int(count) if count.isascii() and count.isdigit() and int(count) > 0 else None
def ego_core_plan(command, env):
count = windows_core_count(env)
if not count or len(command) < 3:
return None
folder = Path(command[2]).parent
path = folder / EGO_RESTRICTIONS
try:
raw = path.read_bytes()
except OSError:
return None
text = raw.decode("latin-1")
start, end = text.find("<cpu>"), text.find("</cpu>")
if start < 0 or end < start:
return None
rules = list(EGO_CORE_RULE.finditer(text, start, end))
if not rules or any(EGO_OPS.get(rule.group(1), lambda a, b: True)(count, int(rule.group(2))) for rule in rules):
return None
smaller = [rule for rule in rules if rule.group(1) == "int_eq" and int(rule.group(2)) < count]
if not smaller:
return None
source = max(smaller, key=lambda rule: int(rule.group(2)))
worker_map = re.search(r'workerMapFile="([^"]+)"', source.group(0))
if worker_map and not (folder / worker_map.group(1)).is_file():
return None
plan = source.group(0).replace('value="%s"' % source.group(2), 'value="%d"' % count, 1)
saved = path.with_name(path.name + DIRT_SAVED)
if not os.path.lexists(saved):
saved.write_bytes(raw)
handle, staged = tempfile.mkstemp(prefix=path.name + ".", suffix=".droiddeck-tmp", dir=str(path.parent))
try:
with os.fdopen(handle, "wb") as out:
out.write((text[:source.end()] + plan + text[source.end():]).encode("latin-1"))
os.replace(staged, path)
except BaseException:
if os.path.lexists(staged):
os.unlink(staged)
raise
return "%s had no thread plan for %d cores; added a copy of its %s-core plan" % (EGO_RESTRICTIONS, count, source.group(2))
def wait_forwarding_signals_raw(process):
def forward(signum, frame):
try:
process.send_signal(signum)
except OSError:
pass
previous = {signum: signal.signal(signum, forward) for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)}
try:
code = process.wait()
finally:
for signum, handler in previous.items():
signal.signal(signum, handler)
return code
def wait_forwarding_signals(process):
code = wait_forwarding_signals_raw(process)
return 128 - code if code < 0 else code
def diagnostic_game_event(event, app_id, values=None):
fields = ["event=" + event, "app_id=" + app_id]
fields.extend("%s=%s" % item for item in (values or {}).items())
print("DROIDDECK_DIAGNOSTICS " + " ".join(fields), file=sys.stderr, flush=True)
def diagnostic_game_exit(app_id, raw_code, source=None, wrapper_status=None):
normalized = 128 - raw_code if raw_code < 0 else raw_code
values = {
"raw_status": raw_code,
"normalized_status": normalized,
"wrapper_status": normalized if wrapper_status is None else wrapper_status,
"signal": -raw_code if raw_code < 0 else 0,
}
if source is not None:
values["source"] = source
diagnostic_game_event("game_exit", app_id, values)
def start(launch, launch_env, digest, after=None, diagnostic_app_id=None):
if len(launch) > 1 and launch[1] == "waitforexitandrun":
ubisoft_composition_off(launch[0], launch_env)
launch, launch_env = system_graphics_launch(launch, launch_env)
if after or diagnostic_app_id is not None:
process = subprocess.Popen(launch, env=launch_env)
if diagnostic_app_id is not None:
diagnostic_game_event("game_start", diagnostic_app_id)
try:
raw_code = wait_forwarding_signals_raw(process)
finally:
if after:
try:
after()
except OSError as error:
print("droiddeck-game-env: DiRT telemetry fix: prefix ws2_32 not put back (%s); the next launch does it" % error,
file=sys.stderr)
code = 128 - raw_code if raw_code < 0 else raw_code
if digest and after is None and code == 0:
record_install_scripts(digest, launch_env)
if diagnostic_app_id is not None:
diagnostic_game_exit(diagnostic_app_id, raw_code)
return code
if digest:
code = subprocess.run(launch, env=launch_env, check=False).returncode
if code == 0:
record_install_scripts(digest, launch_env)
return code
return os.execvpe(launch[0], launch, launch_env)
class SystemGraphicsError(ValueError):
pass
def system_graphics_launch(command, env):
"""Use the APK's PowerVR compatibility files only for actual Proton game launches."""
runtime = env.get("DROIDDECK_POWERVR_RUNTIME")
if not runtime or not game_id(env) or len(command) < 3 or command[1] != "waitforexitandrun":
return command, env
directory = Path(runtime)
if not directory.is_absolute():
raise SystemGraphicsError("system Vulkan runtime path is not absolute")
# run-proton.py picks the same payload by name; Xclipse needs a DXVK within 128 bytes of push data.
payload = env.get("DROIDDECK_HYBRIS_DXVK") or "dxvk"
if not payload.startswith("dxvk") or "/" in payload:
raise SystemGraphicsError("invalid DXVK payload name: %s" % payload)
required = (
"run-proton.py", "wsi/libVkLayer_window_system_integration.so",
"wsi/VkLayer_window_system_integration.json", "lib/libVkLayer_hybris_compat.so",
*("%s/%s/%s.dll" % (payload, arch, name) for arch in ("x32", "x64")
for name in ("d3d8", "d3d9", "d3d10core", "d3d11", "dxgi")),
)
try:
for name in required:
path = directory / name
if not path.is_file() or not path.stat().st_size:
raise SystemGraphicsError("missing system Vulkan component: %s" % name)
wrapper = directory / "run-proton.py"
if not os.access(wrapper, os.X_OK):
raise SystemGraphicsError("system Vulkan Proton wrapper is not executable")
except OSError as error:
raise SystemGraphicsError("system Vulkan component is unreadable: %s" % error) from error
result = dict(env)
layers = "VK_LAYER_window_system_integration:" + env.get("DROIDDECK_HYBRIS_LAYERS", "VK_LAYER_HYBRIS_compat")
result.update({
"HYBRIS_BC_TEXTURES": "dxvk",
"VK_LAYER_PATH": ":".join(filter(None, (str(directory / "wsi"), str(directory / "lib"),
env.get("DROIDDECK_HYBRIS_LAYER_PATH")))),
"VK_INSTANCE_LAYERS": layers,
"ENABLE_GAMESCOPE_WSI": "0",
"VK_LOADER_LAYERS_DISABLE": "*gamescope*",
"DISABLE_VK_LAYER_VALVE_steam_fossilize_1": "1",
})
for name in ("DISABLE_WSI_LAYER", "PROTON_USE_WINED3D", "ENABLE_VK_LAYER_VALVE_steam_fossilize_1"):
result.pop(name, None)
return [str(wrapper), *command], result
def game_logs(command, env, app_id, now=None):
"""Detailed graphics logs for one game launch, in the session's log folder: Proton's own log,
DXVK's and vkd3d-proton's, and the Vulkan driver's startup messages. The app turns this on
(BL_GAME_LOGS=1) on a GPU that is not an Adreno, or with Setup's "Detailed graphics logs"."""
if env.get("BL_GAME_LOGS") != "1" or not env.get("BL_DEBUG_DIR") or len(command) < 3 \
or command[1] != "waitforexitandrun":
return env
import time
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(now))
name = app_id or re.sub(r"[^A-Za-z0-9._-]+", "_", Path(command[2]).stem)[:40] or "game"
parent = Path(env["BL_DEBUG_DIR"]) / "games"
try:
parent.mkdir(parents=True, exist_ok=True)
folder = Path(tempfile.mkdtemp(prefix=stamp + "-", suffix="-" + name, dir=parent))
except OSError as error:
print("droiddeck-game-env: no game log folder (%s)" % error, file=sys.stderr)
return env
result = dict(env)
result.update({
"PROTON_LOG": "1",
"PROTON_LOG_DIR": str(folder),
"DXVK_LOG_PATH": str(folder),
"DXVK_LOG_LEVEL": env.get("DXVK_LOG_LEVEL", "info"),
"VKD3D_LOG_FILE": str(folder / "vkd3d.log"),
"VKD3D_DEBUG": env.get("VKD3D_DEBUG", "warn"),
"VKD3D_SHADER_DEBUG": env.get("VKD3D_SHADER_DEBUG", "none"),
"MESA_LOG_FILE": str(folder / "mesa.log"),
"PANVK_DEBUG": env.get("PANVK_DEBUG", "startup"),
})
try:
(folder / "launch.txt").write_text("\n".join([
"app: %s" % (app_id or "-"),
"exe: %s" % command[2],
"proton: %s" % command[0],
"started: %s" % time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)),
] + ["%s=%s" % (key, env[key]) for key in sorted(env)
if key.startswith(("VK_", "BL_VK", "BL_DRM", "BL_OPEN", "MESA_", "PANVK", "DXVK_", "VKD3D", "ZINK", "TU_", "DROIDDECK_VKD3D"))]) + "\n")
except OSError:
pass
# Whether the game's sound reaches PulseAudio: the streams 20 s and 60 s in, without waiting.
try:
subprocess.Popen(["sh", "-c", 'for t in 20 40; do sleep $t; { echo "== $(date +%T)"; '
'pactl list sink-inputs 2>&1 | head -80; } >> "$1/audio-streams.txt"; done', "audio", str(folder)],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
except OSError:
pass
return result
def exec_proton(command, env):
# Synchronization selection has already picked the actual Proton executable.
command, env = system_graphics_launch(command, env)
os.execvpe(command[0], command, env)
def main():
command = sys.argv[1:]
if not command:
return 2
env = game_gpu(os.environ.copy())
mic_enabled = env.get("BL_MIC_ENABLED")
debug_dir = env.get("BL_DEBUG_DIR", "")
prefix_path = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
app_id = game_id(env)
components = ""
waiting = bool(app_id and len(command) > 1 and command[1] == "waitforexitandrun")
config = {}
launch_config = None
launch_config_valid = True
effective_env = env
if waiting:
path = Path.home() / ".config/droiddeck/game-environment.json"
try:
with path.open(encoding="utf-8") as source:
config = json.load(source)
launch_config = config
except FileNotFoundError:
launch_config = {"version": 1}
except (OSError, ValueError, TypeError, AttributeError):
launch_config_valid = False
if launch_config_valid:
try:
effective_env = apply_config(env, launch_config, app_id)
except (OSError, ValueError, TypeError, AttributeError):
launch_config_valid = False
if not launch_config_valid:
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
if waiting:
components = win_components(prefix_path, app_id, command[0], effective_env)
if prefix_path and command:
try:
fixed = repair_foreign_system_dlls(prefix_path, command[0], effective_env)
except OSError as error:
fixed = []
print("droiddeck-game-env: prefix system DLL check skipped (%s)" % error, file=sys.stderr)
if fixed:
print("droiddeck-game-env: replaced foreign-architecture system DLLs with this Proton's own: %s" % ", ".join(fixed),
file=sys.stderr)
scripts = mark_install_scripts(command, env)
if scripts and install_scripts_done(scripts, env):
print("droiddeck-game-env: install scripts already run in this prefix; evaluator skipped", file=sys.stderr)
return 0
try:
digest = scripts_digest(scripts) if scripts else None
except OSError:
digest = None
diagnostic_app_id = app_id if waiting and os.environ.get("DROIDDECK_ENHANCED_DIAGNOSTICS") == "1" else None
dirt_prefix = None
if app_id:
gl_default = gl_vsync(env)
if waiting:
game_images(env)
fixes = {}
if waiting:
command = skip_red_launcher(command)
ubisoft_overlay_off(env)
project_zomboid_jvm(command)
fixes, extra, notes = engine_fixes(command[2], command[3:]) if len(command) > 2 else ({}, [], [])
if not gl_default:
fixes.pop("vblank_mode", None)
for note in notes:
print("droiddeck-game-env: " + note, file=sys.stderr)
command += extra
if components:
own = fixes.get("WINEDLLOVERRIDES")
fixes["WINEDLLOVERRIDES"] = components + ";" + own if own else components
dirt_prefix = dirt_ws2_wanted(command, env)
if dirt_prefix:
own = fixes.get("WINEDLLOVERRIDES")
fixes["WINEDLLOVERRIDES"] = own + ";ws2_32=n,b" if own else "ws2_32=n,b"
if waiting:
if launch_config_valid:
if "vblank_mode" in config.get("shared", {}):
fixes.pop("vblank_mode", None)
env = apply_config(env, launch_config, app_id, fixes)
else:
path = Path.home() / ".config/droiddeck/game-environment.json"
try:
with path.open(encoding="utf-8") as source:
config = json.load(source)
env = apply_config(env, sync_config(config, app_id), app_id)
except FileNotFoundError:
pass
except (OSError, ValueError, TypeError, AttributeError):
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
if waiting:
before_debug = env
env = apply_game_debug_logs(env, config, debug_dir)
if env is not before_debug:
print("droiddeck-game-env: game debug logs enabled for this launch", file=sys.stderr)
if gl_default and env.get("vblank_mode") == "0":
print("droiddeck-game-env: OpenGL does not wait for the screen refresh (vblank_mode=0)", file=sys.stderr)
if app_id and len(command) > 1 and command[1] == "waitforexitandrun":
command = offline_without_anticheat(command, env)
env = game_logs(command, env, app_id)
if mic_enabled == "0":
env["AUDIO_DIRECT_MIC"] = "0"
env = directaudio_settings(env)
env = vram_cap(env)
note = render_path_defaults(command, env) if app_id else None
if note:
print("droiddeck-game-env: " + note, file=sys.stderr)
note = bridge_feature_level(env)
if note:
print("droiddeck-game-env: " + note, file=sys.stderr)
env = force_ssbs(command, env)
game_cores(env)
cpu_topology(env, command)
if app_id and len(command) > 2 and command[1] == "waitforexitandrun":
try:
note = ego_core_plan(command, env)
except OSError as error:
note = "no thread plan added for this core count (%s)" % error
if note:
print("droiddeck-game-env: " + note, file=sys.stderr)
try:
xbox = runpy.run_path(str(Path(__file__).with_name("droiddeck-xbox")))
except Exception as error:
print("droiddeck-game-env: Xbox support unavailable (%s); stock launch" % error, file=sys.stderr)
xbox = {"launch": lambda command, env: None}
try:
xbox_result = xbox["launch"](command, env)
except (OSError, ValueError, KeyError, RuntimeError) as error:
print("droiddeck-game-env: Xbox launch could not start (%s)" % error, file=sys.stderr)
return 1
if xbox_result is not None:
if diagnostic_app_id is not None and isinstance(xbox_result, int):
diagnostic_game_exit(diagnostic_app_id, xbox_result, "xbox", xbox_result % 256)
return xbox_result
after = None
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
if app_id and prefix and len(command) > 1 and command[1] == "waitforexitandrun":
try:
if dirt_prefix:
after = lambda: restore_dirt_ws2(dirt_prefix, command[0])
swap_dirt_ws2(dirt_prefix)
print("droiddeck-game-env: DiRT telemetry fix: ws2_32 stand-in in the prefix until the game exits",
file=sys.stderr)
elif restore_dirt_ws2(prefix, command[0]):
print("droiddeck-game-env: DiRT telemetry fix: put the prefix's own ws2_32 back", file=sys.stderr)
except OSError as error:
print("droiddeck-game-env: DiRT telemetry fix not applied (%s)" % error, file=sys.stderr)
if after:
after = None
try:
restore_dirt_ws2(dirt_prefix, command[0])
except OSError as undo:
print("droiddeck-game-env: DiRT telemetry fix: prefix ws2_32 not put back (%s); Wine uses its own copy "
"and the next launch puts the file back" % undo, file=sys.stderr)
sync = load_sync()
launch, launch_env = select_sync(command, env, sync)
if launch[0] != command[0]:
try:
return start(launch, launch_env, digest, after, diagnostic_app_id)
except OSError as error:
print("droiddeck-game-env: %s could not start (%s); stock launch" % (launch[0], error), file=sys.stderr)
launch, launch_env = command, without_pack(launch_env, env)
record_stock(sync, launch, launch_env)
try:
return start(launch, launch_env, digest, after, diagnostic_app_id)
except OSError:
if after:
try:
after()
except OSError as error:
print("droiddeck-game-env: DiRT telemetry fix: prefix ws2_32 not put back (%s); the next launch does it" % error,
file=sys.stderr)
raise
if __name__ == "__main__":
try:
sys.exit(main())
except SystemGraphicsError as error:
print("droiddeck-game-env: PowerVR graphics runtime unavailable (%s)" % error, file=sys.stderr)
sys.exit(1)