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
1407 lines
54 KiB
Python
Executable File
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)
|