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
1750 lines
64 KiB
Python
Executable File
1750 lines
64 KiB
Python
Executable File
#!/usr/bin/python3
|
|
import json
|
|
import os
|
|
import re
|
|
import runpy
|
|
import shutil
|
|
import sys
|
|
|
|
TOOL = "droiddeck-proton-arm64"
|
|
TOOL_11 = "droiddeck-proton-11-arm64"
|
|
LEGACY_TOOL = "bannerlator-proton-arm64"
|
|
LABEL_FILE = "/etc/steam-compatibility-label"
|
|
PROTON_ARM64_APPID = "4427310"
|
|
LAUNCHER = "droiddeck-proton"
|
|
LEGACY_LAUNCHER = "bannerlator-proton"
|
|
LIBRARY = "/mnt/droiddeck"
|
|
SOURCES = ("Proton Experimental (ARM64)", "Proton 11.0 (ARM64)")
|
|
OWN_TOOLS = ((TOOL, SOURCES), (TOOL_11, ("Proton 11.0 (ARM64)",)))
|
|
VALVE_DEPOTS = {"proton_experimental": SOURCES[0], "proton-experimental-arm64": SOURCES[0],
|
|
"proton_11": SOURCES[1], "proton_11-arm64": SOURCES[1]}
|
|
FEX_TOOL = "droiddeck-fex"
|
|
FEX_LAUNCHER = "droiddeck-fex-tool"
|
|
FEX_FILES = ("toolmanifest.vdf", "compatibilitytool.vdf", FEX_LAUNCHER)
|
|
FEX_APPID = "3127680"
|
|
SNIPER_APPID = "1628350"
|
|
SLR4_APPID = "4183110"
|
|
FEX_DEPENDENCIES = (FEX_APPID, SNIPER_APPID, SLR4_APPID)
|
|
REQUIRED_TOOLS = (PROTON_ARM64_APPID,) + FEX_DEPENDENCIES
|
|
# Linux builds that bundle an arm64 binary their launcher starts; Steamworks offers no arm64
|
|
# launch-option architecture, so appinfo cannot say so (#1774).
|
|
FEX_DEFAULT_APPS = frozenset({"4385780"})
|
|
FULLY_INSTALLED = 4
|
|
UPDATE_REQUIRED = 2
|
|
REQUEST_HANDOFF_SECONDS = 30
|
|
PLAYABLE_TYPES = frozenset(("game", "demo", "application", "beta"))
|
|
COMPAT_DIR = os.path.join(os.environ.get("HOME", "/root"), ".local", "share", "droiddeck-compat")
|
|
OWN_FILES = ("toolmanifest.vdf", "compatibilitytool.vdf", LAUNCHER)
|
|
COMPAT_TOOL_VDF = """"compatibilitytools"
|
|
{
|
|
"compat_tools"
|
|
{
|
|
"%s"
|
|
{
|
|
"install_path" "."
|
|
"display_name" "%s"
|
|
"from_oslist" "windows"
|
|
"to_oslist" "linux"
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
TOOL_MANIFEST_VDF = """"manifest"
|
|
{
|
|
"version" "2"
|
|
"commandline" "/%s %%verb%%"
|
|
"use_sessions" "1"
|
|
"compatmanager_layer_name" "proton"
|
|
}
|
|
""" % LAUNCHER
|
|
|
|
|
|
BL_STORE_SETUP = """
|
|
bl_store_launch() {
|
|
[ "${verb:-}" = waitforexitandrun ] || return 0
|
|
if [ -x /usr/local/bin/droiddeck-open-url ]; then
|
|
mkdir -p /tmp/droiddeck-browser
|
|
ln -sfn /usr/local/bin/droiddeck-open-url /tmp/droiddeck-browser/xdg-open
|
|
[ -n "${BL_LAUNCH_DIR:-}" ] && printf '%s
|
|
' "$BL_LAUNCH_DIR" > /tmp/droiddeck-browser/launch-dir
|
|
PATH="/tmp/droiddeck-browser:$PATH"; export PATH
|
|
fi
|
|
if [ "${BL_DEBUG_BROWSER:-}" = 1 ]; then
|
|
WINEDEBUG="${WINEDEBUG:+$WINEDEBUG,}+shell,+winebrowser"; export WINEDEBUG
|
|
fi
|
|
[ -x /usr/local/bin/droiddeck-store-launch ] || return 0
|
|
/usr/local/bin/droiddeck-store-launch "$@"
|
|
rc=$?
|
|
[ $rc -eq 10 ] && BL_STORE_EXIT=1
|
|
[ $rc -eq 11 ] && exit 0
|
|
[ $rc -eq 12 ] && exit 1
|
|
return 0
|
|
}
|
|
|
|
bl_run() {
|
|
if [ -x /usr/local/bin/droiddeck-steamvr-flat ] && /usr/local/bin/droiddeck-steamvr-flat "${verb:-}"; then
|
|
set -- /usr/local/bin/droiddeck-steamvr-game "$@"
|
|
fi
|
|
if [ -n "${BL_STORE_EXIT:-}" ]; then
|
|
"$@"
|
|
rc=$?
|
|
/usr/local/bin/droiddeck-store-launch --exited --code "$rc" "${BL_STORE_ARGS[@]}" || true
|
|
exit $rc
|
|
fi
|
|
exec "$@"
|
|
}
|
|
"""
|
|
|
|
BL_DESKTOP_GPU_SETUP = """
|
|
bl_desktop_gpu() {
|
|
local name saved gpu=/usr/local/bin/droiddeck-gpu
|
|
local names=(LD_PRELOAD LD_LIBRARY_PATH ENABLE_VK_LAYER_VALVE_steam_overlay_1 ENABLE_VK_LAYER_VALVE_steam_fossilize_1)
|
|
if [ -n "${BL_DESKTOP_GPU_CHILD:-}" ]; then
|
|
for name in "${names[@]}"; do
|
|
saved=BL_GPU_SAVED_$name
|
|
[ -n "${!saved+x}" ] || continue
|
|
export "$name=${!saved}"
|
|
unset "$saved"
|
|
done
|
|
return 0
|
|
fi
|
|
[ "${BL_DESKTOP:-}" = 1 ] && [ "${verb:-}" = waitforexitandrun ] && [ -x "$gpu" ] || return 0
|
|
local choice=${BL_DESKTOP_GPU:-on}
|
|
case "${choice,,}" in off | 0 | no | false) return 0 ;; esac
|
|
case " $* " in *iscriptevaluator*) return 0 ;; esac
|
|
local clear=()
|
|
for name in "${names[@]}"; do
|
|
[ -n "${!name+x}" ] || continue
|
|
export "BL_GPU_SAVED_$name=${!name}"
|
|
clear+=(-u "$name")
|
|
done
|
|
echo "droiddeck-gpu: the desktop starts this game on the GPU" >&2
|
|
exec env "${clear[@]}" "$gpu" "$0" "$verb" "$@"
|
|
}
|
|
bl_desktop_gpu "$@"
|
|
"""
|
|
BL_COMPONENTS_SETUP = """
|
|
bl_components() {
|
|
base=$1
|
|
cm_dir="${HOME:-/root}/.local/share/droiddeck-components"
|
|
cm_list="$cm_dir/desired.tsv"
|
|
[ "${verb:-}" = waitforexitandrun ] || return 0
|
|
case "${STEAM_COMPAT_DATA_PATH:-}" in ''|*/compatdata/0|*/compatdata/0/|*/compatdata/0-*) return 0 ;; esac
|
|
[ -f "$cm_list" ] || return 0
|
|
cm_app=${STEAM_COMPAT_DATA_PATH}
|
|
while [[ "$cm_app" == */ ]]; do cm_app=${cm_app%/}; done
|
|
cm_app=${cm_app##*/}
|
|
if [[ "$cm_app" =~ ^(-?)([0-9]+)$ ]]; then
|
|
cm_sign=${BASH_REMATCH[1]}
|
|
cm_digits=${BASH_REMATCH[2]}
|
|
while [[ ${#cm_digits} -gt 1 && "$cm_digits" == 0* ]]; do cm_digits=${cm_digits#0}; done
|
|
if [[ ${#cm_digits} -le 10 ]]; then
|
|
cm_app=$((10#$cm_digits))
|
|
[ "$cm_sign" = "-" ] && cm_app=$((-cm_app))
|
|
if (( cm_app >= -2147483648 && cm_app <= 4294967295 && cm_app != 0 )); then
|
|
(( cm_app < 0 )) && cm_app=$((cm_app + 4294967296))
|
|
else
|
|
cm_app=""
|
|
fi
|
|
else
|
|
cm_app=""
|
|
fi
|
|
else
|
|
cm_app=""
|
|
fi
|
|
if [ "$cm_app" = 2929927072 ] && [[ "${SteamAppId:-}" =~ ^[0-9]{1,10}$ ]] && (( 10#$SteamAppId > 0 && 10#$SteamAppId <= 4294967295 )); then
|
|
cm_app=$((10#$SteamAppId))
|
|
elif [ "$cm_app" = 2929927072 ] && [[ "${SteamGameId:-}" =~ ^[1-9][0-9]{9,19}$ ]] &&
|
|
{ [ ${#SteamGameId} -lt 20 ] || [[ ! "$SteamGameId" > 18446744073709551615 ]]; } &&
|
|
(( (10#$SteamGameId & 0xFFFFFFFF) == 0x02000000 && ((10#$SteamGameId >> 32) & 0xFFFFFFFF) != 0 )); then
|
|
cm_app=$(( (10#$SteamGameId >> 32) & 0xFFFFFFFF ))
|
|
fi
|
|
if [ -z "$cm_app" ]; then
|
|
echo "droiddeck-proton: components: invalid game prefix, left as it is" >&2
|
|
return 0
|
|
fi
|
|
cm_tool=${base##*/}
|
|
cm_build=$(awk 'NR == 1 { print $2 }' "$base/version" 2>/dev/null)
|
|
cm_fixed=0
|
|
cm_checked=0
|
|
cm_selected=$(awk -F '\\t' -v app="$cm_app" -v tool="$cm_tool" -v build="$cm_build" '
|
|
$2 == tool && $3 == build {
|
|
rows[++count] = $0
|
|
scopes[count] = $1
|
|
comps[count] = $4
|
|
if ($1 == app) specific[$4] = 1
|
|
}
|
|
END {
|
|
for (i = 1; i <= count; i++)
|
|
if (scopes[i] == app || (scopes[i] == "*" && !specific[comps[i]])) print rows[i]
|
|
}
|
|
' "$cm_list")
|
|
local cm_rows=() cm_files=() row h p
|
|
local -A cm_sums=()
|
|
while IFS=$'\\t' read -r scope t want comp rel sum src; do
|
|
[ -n "$scope" ] || continue
|
|
# The selected system PowerVR runtime supplies DXVK at launch. Keep saved selections
|
|
# for a later manual-driver session without rewriting this Proton's bundled files now.
|
|
[ "$comp" = dxvk ] && [ -n "${DROIDDECK_POWERVR_RUNTIME:-}" ] && continue
|
|
cm_rows+=("$comp"$'\\t'"$rel"$'\\t'"$sum"$'\\t'"$src")
|
|
[ "$src" != "-" ] && [ -f "$base/$rel" ] && cm_files+=("$base/$rel")
|
|
done <<< "$cm_selected"
|
|
if [ ${#cm_files[@]} -gt 0 ]; then
|
|
while read -r h p; do cm_sums["$p"]=$h; done < <(sha256sum -- "${cm_files[@]}" 2>/dev/null)
|
|
fi
|
|
for row in "${cm_rows[@]}"; do
|
|
IFS=$'\\t' read -r comp rel sum src <<< "$row"
|
|
cm_checked=$((cm_checked + 1))
|
|
f="$base/$rel"
|
|
if [ "$src" = "-" ]; then
|
|
if [ -e "$f" ]; then rm -f "$f" && cm_fixed=$((cm_fixed + 1)); fi
|
|
continue
|
|
fi
|
|
if [ -f "$f" ] && [ "${cm_sums[$f]:-}" = "$sum" ]; then continue; fi
|
|
if [ ! -f "$src" ]; then
|
|
echo "droiddeck-proton: components: $comp source missing ($src), left as it is" >&2
|
|
continue
|
|
fi
|
|
mkdir -p "$(dirname "$f")"
|
|
cp -f "$src" "$f.bl-new" && mv -f "$f.bl-new" "$f" || continue
|
|
case "$rel" in *.so) chmod 755 "$f" ;; *) chmod 644 "$f" ;; esac
|
|
cm_fixed=$((cm_fixed + 1))
|
|
done
|
|
[ "$cm_checked" -gt 0 ] && echo "droiddeck-proton: components: checked $cm_checked file(s) in $cm_tool for app $cm_app" >&2
|
|
if [ "$cm_fixed" -gt 0 ]; then
|
|
echo "droiddeck-proton: components: re-applied $cm_fixed of $cm_checked file(s) in $cm_tool before this launch" >&2
|
|
printf '%s\\t%s\\t%s\\n' "$(date +%s)" "$cm_tool" "$cm_fixed" >> "$cm_dir/reapplied.log"
|
|
fi
|
|
}
|
|
"""
|
|
|
|
BL_GAME_CPUS_SETUP = """
|
|
bl_game_cpus() {
|
|
[ -n "${BL_GAME_CPUS:-}" ] || return 0
|
|
command -v taskset >/dev/null 2>&1 || return 0
|
|
BL_TASKSET="taskset -c $BL_GAME_CPUS"
|
|
echo "droiddeck-proton: game cores $BL_GAME_CPUS" >&2
|
|
}
|
|
"""
|
|
|
|
BL_LIBPATH_SETUP = """
|
|
bl_libpath() {
|
|
export PROTON_USE_PIPEWIRE=${PROTON_USE_PIPEWIRE:-0}
|
|
[ -n "${LD_LIBRARY_PATH:-}" ] || return 0
|
|
local kept= dir old_ifs=$IFS
|
|
IFS=:
|
|
for dir in $LD_LIBRARY_PATH; do
|
|
case $dir in
|
|
"" | */steamrtarm64 | */steamrtarm64/*) ;;
|
|
*) kept=${kept:+$kept:}$dir ;;
|
|
esac
|
|
done
|
|
IFS=$old_ifs
|
|
if [ -n "$kept" ]; then export LD_LIBRARY_PATH="$kept"; else unset LD_LIBRARY_PATH; fi
|
|
}
|
|
"""
|
|
|
|
BL_IDTECH3_SETUP = """
|
|
bl_idtech3() {
|
|
BL_EXTRA_ARGS=()
|
|
case "${SteamAppId:-${STEAM_COMPAT_APP_ID:-}}" in
|
|
2200|2350|9010|6020) ;;
|
|
*) return 0 ;;
|
|
esac
|
|
case " $* " in
|
|
*r_fullscreen*|*r_mode*|*r_customwidth*|*r_customheight*)
|
|
echo "droiddeck-proton: Quake-engine title; its launch options set the video mode, left as they are" >&2
|
|
return 0 ;;
|
|
esac
|
|
w=${BL_WIDTH:-1280}
|
|
h=${BL_HEIGHT:-720}
|
|
BL_EXTRA_ARGS=(+set r_fullscreen 0 +set r_mode -1 +set r_customwidth "$w" +set r_customheight "$h")
|
|
echo "droiddeck-proton: Quake-engine title: windowed at ${w}x${h}" >&2
|
|
}
|
|
"""
|
|
|
|
BL_VACDIAG_SETUP = """
|
|
bl_vacdiag() {
|
|
[ -f /root/.droiddeck-vac-diagnostics ] || return 0
|
|
[ "${verb:-}" = waitforexitandrun ] || return 0
|
|
local exe= a dir added=no app=${SteamAppId:-${STEAM_COMPAT_APP_ID:-0}}
|
|
for a in "$@"; do case $a in *.exe|*.EXE) exe=$a ;; esac; done
|
|
[ -n "$exe" ] || return 0
|
|
dir=$(dirname "$exe")
|
|
# Source: <mod>/gameinfo.txt beside the exe. Source 2: game/bin/win64/<exe> with game/<mod>/gameinfo.gi.
|
|
if ls "$dir"/*/gameinfo.txt >/dev/null 2>&1 || ls "$dir"/../../*/gameinfo.gi >/dev/null 2>&1; then
|
|
case " $* " in
|
|
*" -condebug "*) echo "droiddeck-proton: vac-diag: app $app already has -condebug" >&2 ;;
|
|
*) BL_EXTRA_ARGS+=(-condebug); added=yes
|
|
echo "droiddeck-proton: vac-diag: app $app is a Source title; launched with -condebug" >&2 ;;
|
|
esac
|
|
else
|
|
added=not-source
|
|
echo "droiddeck-proton: vac-diag: app $app is not a Source title; it keeps no console log to read" >&2
|
|
fi
|
|
case " $* " in
|
|
*" -insecure "*) echo "droiddeck-proton: vac-diag: app $app has -insecure in its launch options - it can never be VAC-secure this way" >&2 ;;
|
|
esac
|
|
printf '%s\tapp %s\tcondebug %s\t%s\n' "$(date +%s)" "$app" "$added" "${exe##*/}" >> /root/.droiddeck-vac-launches
|
|
}
|
|
"""
|
|
|
|
BL_DRIVER_QUERY_SKIP = """
|
|
case "$verb:${1##*/}:${STEAM_COMPAT_DATA_PATH%/}" in
|
|
run:d3ddriverquery64.exe:*/compatdata/0 | run:d3ddriverquery.exe:*/compatdata/0)
|
|
echo "droiddeck-proton: Steam's D3D driver query is not run (it fails here and would start Wine in compatdata/0)" >&2
|
|
exit 1 ;;
|
|
esac
|
|
"""
|
|
|
|
BL_DIRECTAUDIO_SETUP = """
|
|
bl_is_steam_installscript_target() {
|
|
local target=${1:-}
|
|
local steam_root=${STEAM_COMPAT_CLIENT_INSTALL_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/Steam}
|
|
local helper="$steam_root/legacycompat/iscriptevaluator.exe"
|
|
[ -f "$helper" ] && [[ -n "$target" && "$target" -ef "$helper" ]]
|
|
}
|
|
|
|
bl_directaudio() {
|
|
case "${verb:-}" in
|
|
run|waitforexitandrun|runinprefix) ;;
|
|
*) return 0 ;;
|
|
esac
|
|
base=$1
|
|
[ -n "${BL_DIRECTAUDIO:-}" ] || return 0
|
|
[ -d "$BL_DIRECTAUDIO" ] || return 0
|
|
if bl_is_steam_installscript_target "${2:-}"; then
|
|
echo "droiddeck-proton: DirectAudio skipped for Steam's install-script evaluator" >&2
|
|
return 0
|
|
fi
|
|
streamer=$(cat "${XDG_RUNTIME_DIR:-/tmp}/droiddeck-remote-play" 2>/dev/null)
|
|
if [ -n "$streamer" ] && kill -0 "$streamer" 2>/dev/null; then
|
|
WINE_AUDIO_DRIVER=pulse
|
|
export WINE_AUDIO_DRIVER
|
|
echo "droiddeck-proton: a Remote Play client is connected - game audio goes through PulseAudio so the stream carries it" >&2
|
|
return 0
|
|
fi
|
|
version=$("$base/files/bin-arm64/wine" --version 2>/dev/null)
|
|
major=${version#wine-}
|
|
major=${major%%.*}
|
|
if [ "$major" != "11" ]; then
|
|
echo "droiddeck-proton: DirectAudio left off - it is built for Wine 11 and this Proton is '${version:-unknown}'. Pairing them would be silent rather than broken, so it is not worth guessing." >&2
|
|
return 0
|
|
fi
|
|
winepulse="$base/files/lib/wine/aarch64-unix/winepulse.so"
|
|
if [ ! -f "$winepulse" ]; then
|
|
echo "droiddeck-proton: DirectAudio left off - no winepulse.so to read this Proton's audio interface from" >&2
|
|
return 0
|
|
fi
|
|
if grep -aq PsCreateSystemThread "$winepulse"; then set=linux-wine11-systhread; else set=linux-wine11; fi
|
|
da="$BL_DIRECTAUDIO/$set"
|
|
if [ ! -d "$da/lib/wine" ]; then
|
|
echo "droiddeck-proton: DirectAudio left off - no driver set for the $set interface is staged" >&2
|
|
return 0
|
|
fi
|
|
WINEDLLPATH="$da/lib/wine${WINEDLLPATH:+:$WINEDLLPATH}"
|
|
export WINEDLLPATH
|
|
for name in $(compgen -e); do
|
|
case $name in
|
|
AUDIO_DIRECT_*) export "BANNER_$name=${!name}" ;;
|
|
esac
|
|
done
|
|
WINE_AUDIO_DRIVER="directaudio,pulse"
|
|
export WINE_AUDIO_DRIVER
|
|
pfx="${STEAM_COMPAT_DATA_PATH:-}/pfx"
|
|
reg="$pfx/user.reg"
|
|
if [ -f "$reg" ]; then
|
|
for pair in system32:aarch64-windows syswow64:i386-windows; do
|
|
dir="$pfx/drive_c/windows/${pair%%:*}"
|
|
src="$da/lib/wine/${pair#*:}/winedirectaudio.drv"
|
|
if [ -d "$dir" ] && [ -f "$src" ] && [ "$(readlink "$dir/winedirectaudio.drv" 2>/dev/null)" != "$src" ]; then
|
|
ln -sfn "$src" "$dir/winedirectaudio.drv.bl-new" && mv -f "$dir/winedirectaudio.drv.bl-new" "$dir/winedirectaudio.drv"
|
|
fi
|
|
done
|
|
key='[Software\\\\Wine\\\\Drivers]'
|
|
if [ "$(readlink "$pfx/drive_c/windows/system32/winedirectaudio.drv" 2>/dev/null)" != "$da/lib/wine/aarch64-windows/winedirectaudio.drv" ]; then
|
|
echo "droiddeck-proton: DirectAudio could not be placed in the prefix; Proton's own audio stays" >&2
|
|
elif ! BL_KEY="$key" awk 'index($0, "[") == 1 { inside = index($0, ENVIRON["BL_KEY"] " ") == 1 } inside && index($0, "\\"Audio\\"=") == 1 { last = $0 } END { exit last != "\\"Audio\\"=\\"directaudio,pulse\\"" }' "$reg" 2>/dev/null; then
|
|
printf '\\n%s %s\\n"Audio"="directaudio,pulse"\\n' "$key" "$(date +%s)" >> "$reg"
|
|
echo "droiddeck-proton: DirectAudio selected in the prefix" >&2
|
|
srv="${BL_WINE_SERVER_BASE:-/tmp/.wine-$(id -u)}/server-$(printf '%x-%x' $(stat -c '%d %i' "$pfx" 2>/dev/null || echo 0 0))/socket"
|
|
if [ -S "$srv" ]; then
|
|
echo "droiddeck-proton: a wineserver is already running for this prefix; DirectAudio may only engage on the next launch" >&2
|
|
fi
|
|
fi
|
|
else
|
|
echo "droiddeck-proton: no prefix yet - DirectAudio takes effect the next time this game starts" >&2
|
|
fi
|
|
echo "droiddeck-proton: DirectAudio ready (Wine $major, $set)" >&2
|
|
}
|
|
"""
|
|
|
|
LAUNCHER_SH = """#!/bin/bash
|
|
verb=$1; shift
|
|
steam_root=${STEAM_COMPAT_CLIENT_INSTALL_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/Steam}
|
|
for fake in /usr/local/lib/libfakeinput.so /usr/local/lib/libblsession.so; do
|
|
if [ -f "$fake" ]; then
|
|
case ":${LD_PRELOAD:-}:" in
|
|
*":$fake:"*) ;;
|
|
*) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;;
|
|
esac
|
|
fi
|
|
done
|
|
audit=/usr/local/lib/libblaudit.so
|
|
if [ -n "${PROOT_FP_KEY:-}" ] && [ -f "$audit" ]; then
|
|
case ":${LD_AUDIT:-}:" in
|
|
*":$audit:"*) ;;
|
|
*) LD_AUDIT="$audit${LD_AUDIT:+:$LD_AUDIT}"; export LD_AUDIT ;;
|
|
esac
|
|
fi
|
|
depot=
|
|
libraries=$(printf '%%s\\n' "$steam_root" /mnt/droiddeck-sd; sed -n 's/^[[:space:]]*"path"[[:space:]]*"\\(.*\\)"[[:space:]]*$/\\1/p' "$steam_root/steamapps/libraryfolders.vdf" 2>/dev/null)
|
|
for name in %s; do
|
|
best=0
|
|
while IFS= read -r library; do
|
|
candidate="$library/steamapps/common/$name"
|
|
[ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue
|
|
build=0
|
|
read -r build _ 2>/dev/null < "$candidate/version"
|
|
case $build in "" | *[!0-9]*) build=0 ;; esac
|
|
if [ -z "$depot" ] || [ "$build" -gt "$best" ]; then
|
|
depot=$candidate
|
|
best=$build
|
|
fi
|
|
done <<< "$libraries"
|
|
[ -n "$depot" ] && break
|
|
done
|
|
if [ -n "$depot" ]; then
|
|
bl_components "$depot"
|
|
bl_directaudio "$depot" "$@"
|
|
bl_game_cpus
|
|
bl_idtech3 "$@"
|
|
bl_vacdiag "$@"
|
|
bl_libpath
|
|
BL_STORE_PROTON="$depot/proton"; export BL_STORE_PROTON
|
|
bl_store_launch "$@"
|
|
BL_STORE_ARGS=("$@")
|
|
bl_run ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
|
fi
|
|
echo "droiddeck-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2
|
|
exit 1
|
|
"""
|
|
LAUNCHER_TEMPLATE = LAUNCHER_SH
|
|
|
|
|
|
def launcher_sh(sources=SOURCES):
|
|
return (LAUNCHER_TEMPLATE % " ".join('"%s"' % n for n in sources)).replace(
|
|
"verb=$1; shift", "verb=$1; shift\n" + BL_DRIVER_QUERY_SKIP + BL_DESKTOP_GPU_SETUP + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
|
|
+ BL_VACDIAG_SETUP + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_STORE_SETUP, 1)
|
|
|
|
|
|
LAUNCHER_SH = launcher_sh()
|
|
|
|
EXTRA_WRAPPER = "droiddeck-proton-wrap"
|
|
LEGACY_EXTRA_WRAPPER = "bannerlator-proton-wrap"
|
|
ORIGINAL_SUFFIX = ".droiddeck-orig"
|
|
LEGACY_ORIGINAL_SUFFIX = ".bannerlator-orig"
|
|
EXTRA_MANIFEST_VDF = """"manifest"
|
|
{
|
|
"version" "2"
|
|
"commandline" "/%s %%verb%%"
|
|
"use_sessions" "1"
|
|
"compatmanager_layer_name" "proton"
|
|
}
|
|
""" % EXTRA_WRAPPER
|
|
EXTRA_WRAPPER_SH = """#!/bin/bash
|
|
verb=$1; shift
|
|
here=$(dirname "$0")
|
|
for fake in /usr/local/lib/libfakeinput.so /usr/local/lib/libblsession.so; do
|
|
if [ -f "$fake" ]; then
|
|
case ":${LD_PRELOAD:-}:" in
|
|
*":$fake:"*) ;;
|
|
*) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;;
|
|
esac
|
|
fi
|
|
done
|
|
audit=/usr/local/lib/libblaudit.so
|
|
if [ -n "${PROOT_FP_KEY:-}" ] && [ -f "$audit" ]; then
|
|
case ":${LD_AUDIT:-}:" in
|
|
*":$audit:"*) ;;
|
|
*) LD_AUDIT="$audit${LD_AUDIT:+:$LD_AUDIT}"; export LD_AUDIT ;;
|
|
esac
|
|
fi
|
|
bl_components "$here"
|
|
bl_directaudio "$here" "$@"
|
|
bl_game_cpus
|
|
bl_idtech3 "$@"
|
|
bl_vacdiag "$@"
|
|
bl_libpath
|
|
BL_STORE_PROTON="$here/%s"; export BL_STORE_PROTON
|
|
bl_store_launch "$@"
|
|
BL_STORE_ARGS=("$@")
|
|
bl_run ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env "$BL_STORE_PROTON" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
|
"""
|
|
|
|
|
|
def extra_wrapper_sh(entry_point):
|
|
return (EXTRA_WRAPPER_SH % entry_point).replace(
|
|
'here=$(dirname "$0")',
|
|
'here=$(dirname "$0")\n' + BL_DRIVER_QUERY_SKIP + BL_DESKTOP_GPU_SETUP + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP
|
|
+ BL_IDTECH3_SETUP + BL_VACDIAG_SETUP + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_STORE_SETUP, 1)
|
|
|
|
|
|
EXTRA_COMPAT_TOOL_VDF = """"compatibilitytools"
|
|
{
|
|
"compat_tools"
|
|
{
|
|
"%s"
|
|
{
|
|
"install_path" "."
|
|
"display_name" "%s"
|
|
"from_oslist" "windows"
|
|
"to_oslist" "linux"
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
FEX_COMPAT_TOOL_VDF = """"compatibilitytools"
|
|
{
|
|
"compat_tools"
|
|
{
|
|
"%s"
|
|
{
|
|
"install_path" "."
|
|
"display_name" "%s"
|
|
"from_oslist" "linux"
|
|
"to_oslist" "linux"
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
FEX_MANIFEST_VDF = """"manifest"
|
|
{
|
|
"version" "2"
|
|
"commandline" "/%s %%verb%%"
|
|
"use_tool_subprocess_reaper" "1"
|
|
}
|
|
""" % FEX_LAUNCHER
|
|
FEX_LAUNCHER_SH = """#!/bin/bash
|
|
verb=$1; shift
|
|
case $verb in
|
|
getcompatpath | getnativepath) printf '%s\\n' "$1"; exit 0 ;;
|
|
esac
|
|
""" + BL_DESKTOP_GPU_SETUP + BL_GAME_CPUS_SETUP + """
|
|
bl_game_cpus
|
|
if [ -x /usr/local/bin/droiddeck-steamvr-flat ] && /usr/local/bin/droiddeck-steamvr-flat "$verb"; then
|
|
exec /usr/local/bin/droiddeck-steamvr-game ${BL_TASKSET:-} /usr/local/bin/droiddeck-fex steam "$verb" "$@"
|
|
fi
|
|
exec ${BL_TASKSET:-} /usr/local/bin/droiddeck-fex steam "$verb" "$@"
|
|
"""
|
|
|
|
|
|
TOKEN = re.compile(r'\s*(?:"((?:[^"\\]|\\.)*)"|([{}])|//[^\n]*)')
|
|
|
|
|
|
def tokenize(text):
|
|
tokens = []
|
|
pos = 0
|
|
while pos < len(text):
|
|
m = TOKEN.match(text, pos)
|
|
if not m or m.end() == pos:
|
|
break
|
|
pos = m.end()
|
|
if m.group(2):
|
|
tokens.append(m.group(2))
|
|
elif m.group(1) is not None:
|
|
tokens.append('"' + m.group(1) + '"')
|
|
return tokens
|
|
|
|
|
|
def find_block(tokens, path):
|
|
depth, want, i = 0, 0, 0
|
|
while i < len(tokens):
|
|
t = tokens[i]
|
|
if t == "{":
|
|
depth += 1
|
|
elif t == "}":
|
|
depth -= 1
|
|
if depth < want:
|
|
return None
|
|
elif depth == want and i + 1 < len(tokens) and tokens[i + 1] == "{":
|
|
if t.lower() == '"%s"' % path[want].lower():
|
|
if want == len(path) - 1:
|
|
return i + 1
|
|
want += 1
|
|
i += 1
|
|
return None
|
|
|
|
|
|
def block_end(tokens, start):
|
|
depth = 0
|
|
for i in range(start, len(tokens)):
|
|
if tokens[i] == "{":
|
|
depth += 1
|
|
elif tokens[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return i
|
|
return len(tokens)
|
|
|
|
|
|
def serialize(tokens):
|
|
out, depth, i = [], 0, 0
|
|
while i < len(tokens):
|
|
t = tokens[i]
|
|
if t == "}":
|
|
depth -= 1
|
|
out.append("\t" * depth + "}")
|
|
i += 1
|
|
elif i + 1 < len(tokens) and tokens[i + 1] == "{":
|
|
out.append("\t" * depth + t)
|
|
out.append("\t" * depth + "{")
|
|
depth += 1
|
|
i += 2
|
|
else:
|
|
out.append("\t" * depth + t + "\t\t" + tokens[i + 1])
|
|
i += 2
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
def write_changed(path, text, mode=None):
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
same = f.read() == text
|
|
except OSError:
|
|
same = False
|
|
if not same:
|
|
tmp = path + ".droiddeck"
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
f.write(text)
|
|
if mode is not None:
|
|
os.chmod(tmp, mode)
|
|
os.replace(tmp, path)
|
|
elif mode is not None and (os.stat(path).st_mode & 0o7777) != mode:
|
|
os.chmod(path, mode)
|
|
return not same
|
|
|
|
|
|
def display_name(base):
|
|
try:
|
|
with open(LABEL_FILE, encoding="utf-8") as f:
|
|
label = f.read().strip().replace('"', "'").replace("\\", "")
|
|
except OSError:
|
|
label = ""
|
|
return "%s (%s)" % (base, label) if label else base
|
|
|
|
|
|
def first_key(path, section):
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
tokens = tokenize(f.read())
|
|
except OSError:
|
|
return None
|
|
block = find_block(tokens, section)
|
|
if block is None:
|
|
return None
|
|
for i in range(block + 1, block_end(tokens, block)):
|
|
if tokens[i] == "}":
|
|
break
|
|
if i + 1 < len(tokens) and tokens[i + 1] == "{":
|
|
return tokens[i].strip('"')
|
|
return None
|
|
|
|
|
|
def manifest_entry_point(path):
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
tokens = tokenize(f.read())
|
|
except OSError:
|
|
return None
|
|
for i in range(len(tokens) - 1):
|
|
if tokens[i].lower() == '"commandline"':
|
|
command = tokens[i + 1].strip('"').split()
|
|
if command:
|
|
return command[0].lstrip("/")
|
|
return None
|
|
|
|
|
|
def adopt_extras(tools):
|
|
adopted = {}
|
|
try:
|
|
entries = sorted(os.listdir(tools))
|
|
except OSError:
|
|
return adopted
|
|
for entry in entries:
|
|
if entry in dict(OWN_TOOLS) or entry == FEX_TOOL or entry.startswith("."):
|
|
continue
|
|
tool_dir = os.path.join(tools, entry)
|
|
manifest = os.path.join(tool_dir, "toolmanifest.vdf")
|
|
if not os.path.isdir(tool_dir) or not os.path.isfile(manifest):
|
|
continue
|
|
original = manifest + ORIGINAL_SUFFIX
|
|
legacy = manifest + LEGACY_ORIGINAL_SUFFIX
|
|
if not os.path.isfile(original) and os.path.isfile(legacy):
|
|
try:
|
|
os.replace(legacy, original)
|
|
except OSError:
|
|
continue
|
|
if not os.path.isfile(original):
|
|
try:
|
|
shutil.copy2(manifest, original)
|
|
except OSError:
|
|
continue
|
|
entry_point = manifest_entry_point(original)
|
|
if not entry_point or entry_point in (EXTRA_WRAPPER, LEGACY_EXTRA_WRAPPER):
|
|
entry_point = "proton"
|
|
if not os.path.exists(os.path.join(tool_dir, entry_point)):
|
|
print("steam-compatibility: %s has no %s; left alone" % (entry, entry_point))
|
|
continue
|
|
compat_vdf = os.path.join(tool_dir, "compatibilitytool.vdf")
|
|
name = first_key(compat_vdf, ["compatibilitytools", "compat_tools"]) or entry
|
|
wrapper = os.path.join(tool_dir, EXTRA_WRAPPER)
|
|
try:
|
|
write_changed(wrapper, extra_wrapper_sh(entry_point), 0o755)
|
|
write_changed(manifest, EXTRA_MANIFEST_VDF)
|
|
write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, display_name(entry)))
|
|
legacy_wrapper = os.path.join(tool_dir, LEGACY_EXTRA_WRAPPER)
|
|
if os.path.isfile(legacy_wrapper):
|
|
os.unlink(legacy_wrapper)
|
|
except OSError as e:
|
|
print("steam-compatibility: could not adopt %s (%s)" % (entry, e))
|
|
continue
|
|
adopted[name] = entry
|
|
return adopted
|
|
|
|
|
|
NOT_GAMES = frozenset((
|
|
"228980",
|
|
"1070560",
|
|
"1391110",
|
|
"1628350",
|
|
"1493710",
|
|
"3127680",
|
|
"4183110",
|
|
"4185400",
|
|
"4427310",
|
|
"4628740",
|
|
))
|
|
|
|
|
|
def libraries(steam_root):
|
|
found = [steam_root]
|
|
try:
|
|
with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
|
|
found += [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())]
|
|
except OSError:
|
|
pass
|
|
found.append(LIBRARY + "-sd")
|
|
return list(dict.fromkeys(found))
|
|
|
|
|
|
def installed_apps(steam_root):
|
|
entries = appinfo_entries(os.path.join(steam_root, "appcache", "appinfo.vdf"))
|
|
apps = set()
|
|
for library in libraries(steam_root):
|
|
steamapps = os.path.join(library, "steamapps")
|
|
try:
|
|
names = os.listdir(steamapps)
|
|
except OSError:
|
|
continue
|
|
for name in names:
|
|
m = re.fullmatch(r"appmanifest_(\d+)\.acf", name)
|
|
if m and m.group(1) not in NOT_GAMES and entries.get(m.group(1), {}).get("type") != "tool":
|
|
apps.add(m.group(1))
|
|
return sorted(apps, key=int)
|
|
|
|
|
|
APPINFO_CACHE = {}
|
|
STEAM_PLAY_MANIFESTS = 891390
|
|
|
|
|
|
def launch_programs(config):
|
|
launch = config.get("launch") if isinstance(config, dict) else None
|
|
found = []
|
|
for option in launch.values() if isinstance(launch, dict) else ():
|
|
if not isinstance(option, dict) or not option.get("executable"):
|
|
continue
|
|
settings = option.get("config") if isinstance(option.get("config"), dict) else {}
|
|
oslist = str(settings.get("oslist", "")).lower()
|
|
if oslist and "windows" not in oslist and "linux" not in oslist:
|
|
continue
|
|
found.append(os.path.normpath(str(option["executable"]).replace("\\", "/")).lstrip("/").lower())
|
|
return found
|
|
|
|
|
|
def launch_options(config):
|
|
launch = config.get("launch") if isinstance(config, dict) else None
|
|
found = []
|
|
for key, option in launch.items() if isinstance(launch, dict) else ():
|
|
if not isinstance(option, dict) or not option.get("executable"):
|
|
continue
|
|
settings = option.get("config") if isinstance(option.get("config"), dict) else {}
|
|
found.append({
|
|
"key": str(key),
|
|
"executable": str(option["executable"]),
|
|
"arguments": str(option.get("arguments", "")),
|
|
"workingdir": str(option.get("workingdir", "")),
|
|
"type": str(option.get("type", "")).lower(),
|
|
"oslist": str(settings.get("oslist", "")).lower(),
|
|
"betakey": str(settings.get("betakey", "")),
|
|
})
|
|
return found
|
|
|
|
|
|
def appinfo_entries(path, wanted=None):
|
|
"""Read metadata for all apps, or only the requested app-id strings."""
|
|
import struct
|
|
if wanted is not None:
|
|
wanted = set(wanted)
|
|
if not wanted:
|
|
return {}
|
|
try:
|
|
stamp = os.stat(path).st_mtime_ns
|
|
except OSError:
|
|
return {}
|
|
cached = APPINFO_CACHE.get(path)
|
|
if cached and cached[0] == stamp:
|
|
covered = cached[2]
|
|
if covered is None or (wanted is not None and wanted <= covered):
|
|
return cached[1] if wanted is None else {app: cached[1][app] for app in wanted if app in cached[1]}
|
|
data = cached[3]
|
|
needed = None if wanted is None else wanted - covered
|
|
else:
|
|
cached = None
|
|
needed = wanted
|
|
try:
|
|
with open(path, "rb") as f:
|
|
data = f.read()
|
|
except OSError:
|
|
return {}
|
|
if len(data) < 8:
|
|
return {}
|
|
magic = struct.unpack_from("<I", data, 0)[0]
|
|
if magic not in (0x07564427, 0x07564428, 0x07564429):
|
|
return {}
|
|
offset = 8
|
|
strings = None
|
|
try:
|
|
if magic == 0x07564429:
|
|
table = struct.unpack_from("<q", data, offset)[0]
|
|
offset += 8
|
|
count = struct.unpack_from("<I", data, table)[0]
|
|
position = table + 4
|
|
strings = []
|
|
for _ in range(count):
|
|
end = data.index(b"\0", position)
|
|
strings.append(data[position:end].decode("utf-8", "replace"))
|
|
position = end + 1
|
|
except (ValueError, IndexError, struct.error):
|
|
return {}
|
|
header = 4 + 4 + 8 + 20 + 4 + (20 if magic >= 0x07564428 else 0)
|
|
|
|
def section(position):
|
|
result = {}
|
|
while True:
|
|
kind = data[position]
|
|
position += 1
|
|
if kind == 8:
|
|
return result, position
|
|
if strings is not None:
|
|
key = strings[struct.unpack_from("<I", data, position)[0]]
|
|
position += 4
|
|
else:
|
|
end = data.index(b"\0", position)
|
|
key = data[position:end].decode("utf-8", "replace")
|
|
position = end + 1
|
|
if kind == 0:
|
|
result[key], position = section(position)
|
|
elif kind == 1:
|
|
end = data.index(b"\0", position)
|
|
result[key] = data[position:end].decode("utf-8", "replace")
|
|
position = end + 1
|
|
elif kind == 2:
|
|
result[key] = struct.unpack_from("<i", data, position)[0]
|
|
position += 4
|
|
elif kind == 7:
|
|
result[key] = struct.unpack_from("<Q", data, position)[0]
|
|
position += 8
|
|
else:
|
|
raise ValueError("unknown appinfo field type %d" % kind)
|
|
|
|
found = {}
|
|
while offset + 8 <= len(data):
|
|
try:
|
|
app, size = struct.unpack_from("<II", data, offset)
|
|
except struct.error:
|
|
break
|
|
if app == 0:
|
|
break
|
|
start = offset + 8
|
|
offset = start + size
|
|
if needed is not None and str(app) not in needed:
|
|
continue
|
|
try:
|
|
info = section(start + header)[0].get("appinfo", {})
|
|
except (ValueError, IndexError, struct.error):
|
|
continue
|
|
if not isinstance(info, dict):
|
|
continue
|
|
common = info.get("common", {}) if isinstance(info.get("common"), dict) else {}
|
|
deck = common.get("steam_deck_compatibility")
|
|
configuration = deck.get("configuration") if isinstance(deck, dict) else None
|
|
recommended = configuration.get("recommended_runtime", "") if isinstance(configuration, dict) else ""
|
|
entry = {
|
|
"oslist": {part.strip().lower() for part in str(common.get("oslist", "")).split(",") if part.strip()},
|
|
"osarch": str(common.get("osarch", "")).lower(),
|
|
"type": str(common.get("type", "")).lower(),
|
|
"recommended": str(recommended),
|
|
"launch": launch_programs(info.get("config")),
|
|
"launch_options": launch_options(info.get("config")),
|
|
}
|
|
if app == STEAM_PLAY_MANIFESTS:
|
|
extended = info.get("extended", {})
|
|
mappings = extended.get("app_mappings", {}) if isinstance(extended, dict) else {}
|
|
entry["app_mappings"] = {str(m.get("appid", key)): str(m.get("tool", "")) for key, m in mappings.items()
|
|
if isinstance(m, dict)} if isinstance(mappings, dict) else {}
|
|
found[str(app)] = entry
|
|
covered = wanted
|
|
if cached is not None and wanted is not None:
|
|
cached[1].update(found)
|
|
found = cached[1]
|
|
covered = cached[2] | wanted
|
|
# None coverage is complete; partial entries keep one immutable file snapshot.
|
|
APPINFO_CACHE[path] = (stamp, found, covered, data if covered is not None else None)
|
|
return found if wanted is None else {app: found[app] for app in wanted if app in found}
|
|
|
|
|
|
def appinfo_platforms(path, wanted):
|
|
entries = appinfo_entries(path)
|
|
return {app: (entries[app]["oslist"], entries[app]["osarch"], entries[app]["type"]) for app in wanted if app in entries}
|
|
|
|
|
|
def valve_picks(steam_root):
|
|
entries = appinfo_entries(os.path.join(steam_root, "appcache", "appinfo.vdf"))
|
|
mapped = {app for app, tool in entries.get(str(STEAM_PLAY_MANIFESTS), {}).get("app_mappings", {}).items() if tool}
|
|
picks = set()
|
|
for app, entry in entries.items():
|
|
if app in NOT_GAMES or entry["type"] not in PLAYABLE_TYPES:
|
|
continue
|
|
if entry["oslist"] and "windows" not in entry["oslist"]:
|
|
continue
|
|
if entry["recommended"] or app in mapped:
|
|
picks.add(app)
|
|
return sorted(picks, key=int)
|
|
|
|
|
|
def tool_platforms(steam_root):
|
|
entries = appinfo_entries(os.path.join(steam_root, "appcache", "appinfo.vdf"))
|
|
return {app: entry["oslist"] for app, entry in entries.items()
|
|
if entry["type"] == "tool" and entry["oslist"] & {"linux", "windows"}}
|
|
|
|
|
|
def native_targets(steam_root, apps):
|
|
wanted = {str(app) for app in apps if str(app) not in NOT_GAMES}
|
|
if not wanted:
|
|
return {}
|
|
targets = {}
|
|
for app, (oslist, osarch, kind) in appinfo_platforms(os.path.join(steam_root, "appcache", "appinfo.vdf"), wanted).items():
|
|
if kind not in PLAYABLE_TYPES or "linux" not in oslist:
|
|
continue
|
|
if "arm64" in osarch or "aarch64" in osarch:
|
|
targets[app] = None
|
|
continue
|
|
if "windows" in oslist and app not in FEX_DEFAULT_APPS:
|
|
targets[app] = NATIVE_CHOICE
|
|
continue
|
|
targets[app] = FEX_TOOL
|
|
return targets
|
|
|
|
|
|
def app_manifest(steam_root, app):
|
|
for library in libraries(steam_root):
|
|
try:
|
|
with open(os.path.join(library, "steamapps", "appmanifest_%s.acf" % app), encoding="utf-8", errors="replace") as f:
|
|
return tokenize(f.read())
|
|
except OSError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def manifest_flags(tokens):
|
|
for i in range(len(tokens) - 1):
|
|
if tokens[i].lower() == '"stateflags"':
|
|
value = tokens[i + 1].strip('"')
|
|
return int(value) if value.isdigit() else 0
|
|
return 0
|
|
|
|
|
|
def tool_downloads(steam_root):
|
|
wanted = []
|
|
for app in REQUIRED_TOOLS:
|
|
tokens = app_manifest(steam_root, app)
|
|
if tokens is None:
|
|
wanted.append((app, "install"))
|
|
elif not manifest_flags(tokens) & FULLY_INSTALLED or manifest_flags(tokens) & UPDATE_REQUIRED:
|
|
wanted.append((app, "update"))
|
|
return wanted
|
|
|
|
|
|
def fex_missing(steam_root):
|
|
missing = []
|
|
for app in FEX_DEPENDENCIES:
|
|
if not any(os.path.isfile(os.path.join(library, "steamapps", "appmanifest_%s.acf" % app))
|
|
for library in libraries(steam_root)):
|
|
missing.append(app)
|
|
return missing
|
|
|
|
|
|
def fex_wanted_marker():
|
|
return os.path.join(os.environ.get("HOME", "/root"), ".config", "droiddeck", "fex-wanted")
|
|
|
|
|
|
def request_app(steam_root, app):
|
|
import subprocess
|
|
client = os.path.join(steam_root, "steamrtarm64", "steam")
|
|
if not os.access(client, os.X_OK):
|
|
return []
|
|
try:
|
|
process = subprocess.Popen([client, "steam://install/%s" % app], stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL, start_new_session=True)
|
|
except OSError:
|
|
return []
|
|
try:
|
|
process.wait(timeout=REQUEST_HANDOFF_SECONDS)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
return [app]
|
|
|
|
|
|
def request_fex(steam_root, asked):
|
|
client = os.path.join(steam_root, "steamrtarm64", "steam")
|
|
missing = fex_missing(steam_root)
|
|
if not os.access(client, os.X_OK) or any(app in asked for app in missing):
|
|
return []
|
|
for app in missing:
|
|
asked.add(app)
|
|
requested = request_app(steam_root, app)
|
|
if requested:
|
|
return requested
|
|
return []
|
|
|
|
|
|
def read_json(path):
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
value = json.load(f)
|
|
except (OSError, ValueError):
|
|
return {}
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def write_json(path, value):
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
return write_changed(path, json.dumps(value, indent=1, sort_keys=True) + "\n")
|
|
|
|
|
|
class locked:
|
|
def __init__(self, directory=None):
|
|
self.path = os.path.join(directory or COMPAT_DIR, "lock")
|
|
|
|
def __enter__(self):
|
|
import fcntl
|
|
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
self.fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
fcntl.flock(self.fd, fcntl.LOCK_EX)
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
os.close(self.fd)
|
|
|
|
|
|
def state_path(name, directory=None):
|
|
return os.path.join(directory or COMPAT_DIR, name)
|
|
|
|
|
|
def unrunnable(name, runnable):
|
|
low = name.lower()
|
|
return low not in runnable and (low.startswith("proton") or low == LEGACY_TOOL or low.startswith("droiddeck-proton"))
|
|
|
|
|
|
def app_order(app):
|
|
return (len(app), app)
|
|
|
|
|
|
DEFERRED_GAME_TOOL = object()
|
|
NATIVE_CHOICE = object()
|
|
|
|
|
|
def plan_mapping(current, installed, runnable, default, auto, native=None, picks=(), overrides=None, managed=None,
|
|
native_choices=(), tools=None):
|
|
tools = tools or {}
|
|
runnable = {n.lower() for n in runnable}
|
|
installed = set(installed)
|
|
picks = set(picks) | {app for app, platforms in tools.items() if "linux" not in platforms}
|
|
native = native or {}
|
|
overrides = overrides or {}
|
|
managed = set(auto) if managed is None else set(managed)
|
|
native_choices = set(native_choices)
|
|
changes, kept = {}, {}
|
|
if current.get("0", "") != default:
|
|
changes["0"] = default
|
|
for app in sorted((set(current) | installed | picks | set(auto) | set(overrides) | native_choices | set(tools)) - {"0"}, key=app_order):
|
|
name = current.get(app, "")
|
|
if app in tools and "linux" in tools[app] and "windows" not in tools[app]:
|
|
if name and (name.lower() in runnable or "proton" in name.lower()):
|
|
changes[app] = None
|
|
continue
|
|
if app in overrides:
|
|
target = overrides[app]
|
|
if target is DEFERRED_GAME_TOOL:
|
|
continue
|
|
target = target or default
|
|
if name != target:
|
|
changes[app] = target
|
|
if overrides[app] is None:
|
|
kept[app] = target
|
|
continue
|
|
mine = auto.get(app)
|
|
target = native.get(app, default)
|
|
if not name and (app in native_choices or (app in current and app in FEX_DEFAULT_APPS)):
|
|
target = NATIVE_CHOICE
|
|
stale = unrunnable(name, runnable)
|
|
if name and not stale and (mine is None or name != mine):
|
|
continue
|
|
if target is NATIVE_CHOICE:
|
|
if not name and (app in native_choices or app in current or app in managed):
|
|
kept[app] = NATIVE_CHOICE
|
|
continue
|
|
target = default
|
|
if target is None:
|
|
if stale:
|
|
target = default
|
|
if name != target:
|
|
changes[app] = target
|
|
continue
|
|
else:
|
|
if name and app in managed and name == mine:
|
|
changes[app] = None
|
|
continue
|
|
if not name and app not in installed and app not in picks:
|
|
continue
|
|
if name != target:
|
|
changes[app] = target
|
|
kept[app] = target
|
|
return changes, kept
|
|
|
|
|
|
def picked_apps(state):
|
|
picked = state.get("picked", ())
|
|
return {app for app in picked if isinstance(app, str)} if isinstance(picked, list) else set()
|
|
|
|
|
|
def picked_after(current, changes):
|
|
return sorted(({app for app, tool in current.items() if tool} - set(changes) |
|
|
{app for app, tool in changes.items() if tool}) - {"0"}, key=app_order)
|
|
|
|
|
|
def followers(state, current):
|
|
if "auto" in state:
|
|
return state["auto"]
|
|
return {app: name for app, name in current.items() if app != "0" and name == TOOL}
|
|
|
|
|
|
def tool_entry(name, display, path, valve):
|
|
return {"name": name, "display": display, "dir": os.path.basename(path) if path else "", "valve": valve}
|
|
|
|
|
|
def tool_catalog(steam_root, extras=None):
|
|
tools = os.path.join(steam_root, "compatibilitytools.d")
|
|
catalog = []
|
|
for name, sources in OWN_TOOLS:
|
|
depot = find_source(steam_root, sources)
|
|
if depot is None and name != TOOL:
|
|
continue
|
|
catalog.append(tool_entry(name, display_name(
|
|
os.path.basename(depot).replace(" (ARM64)", " ARM64") if depot else "Proton ARM64"), depot, True))
|
|
if extras is None:
|
|
extras = {}
|
|
try:
|
|
entries = sorted(os.listdir(tools))
|
|
except OSError:
|
|
entries = []
|
|
for entry in entries:
|
|
tool_dir = os.path.join(tools, entry)
|
|
if entry in dict(OWN_TOOLS) or not any(os.path.isfile(os.path.join(tool_dir, w)) for w in (EXTRA_WRAPPER, LEGACY_EXTRA_WRAPPER)):
|
|
continue
|
|
extras[first_key(os.path.join(tool_dir, "compatibilitytool.vdf"), ["compatibilitytools", "compat_tools"]) or entry] = entry
|
|
for name, entry in sorted(extras.items()):
|
|
catalog.append(tool_entry(name, display_name(entry), os.path.join(tools, entry), False))
|
|
return catalog
|
|
|
|
|
|
def built_from(name, catalog, runnable=None):
|
|
depot = VALVE_DEPOTS.get(name)
|
|
tools = [t["name"] for t in catalog if depot and t["valve"] and t["dir"] == depot and
|
|
(runnable is None or t["name"] in runnable)]
|
|
return tools[-1] if tools else None
|
|
|
|
|
|
def rebuilt_picks(current, catalog, known, runnable=None):
|
|
picks = {app: built_from(name, catalog, runnable) for app, name in current.items() if app in known}
|
|
return {app: tool for app, tool in picks.items() if tool}
|
|
|
|
|
|
def requested_tool(request, catalog):
|
|
name = request.get("name")
|
|
if isinstance(name, str) and any(t["name"] == name for t in catalog):
|
|
return name
|
|
for tool in catalog:
|
|
if tool["dir"] and tool["dir"] == request.get("dir") and tool["valve"] == bool(request.get("valve")):
|
|
return tool["name"]
|
|
return None
|
|
|
|
|
|
def game_requests(directory=None):
|
|
return read_game_requests(directory)[0]
|
|
|
|
|
|
def read_game_requests(directory=None):
|
|
try:
|
|
with open(state_path("games.json", directory), encoding="utf-8") as f:
|
|
text = f.read()
|
|
value = json.loads(text)
|
|
except (OSError, ValueError):
|
|
return {}, None
|
|
if not isinstance(value, dict) or value.get("version") != 1 or not isinstance(value.get("games"), dict):
|
|
return {}, text
|
|
return value["games"], text
|
|
|
|
|
|
def write_game_requests(requests, original, directory=None):
|
|
path = state_path("games.json", directory)
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
if f.read() != original:
|
|
return False
|
|
except OSError:
|
|
if original is not None:
|
|
return False
|
|
return write_json(path, {"version": 1, "games": requests})
|
|
|
|
|
|
def valid_game_request(app, request):
|
|
return (isinstance(app, str) and re.fullmatch(r"[1-9][0-9]*", app) is not None and int(app) <= 0xFFFFFFFF and
|
|
(request is None or isinstance(request, dict) and isinstance(request.get("dir"), str) and
|
|
request["dir"] != "" and isinstance(request.get("valve"), bool)))
|
|
|
|
|
|
def settle_game_requests(requests, state, current, catalog, missing_is_cleared=False):
|
|
recorded = state.get("games_applied")
|
|
if isinstance(recorded, dict):
|
|
applied = dict(recorded)
|
|
else:
|
|
applied = dict(requests) if "tools" in state else {}
|
|
by_name = {t["name"]: t for t in catalog}
|
|
out = dict(requests)
|
|
for app, request in requests.items():
|
|
if not valid_game_request(app, request) or app not in applied or applied[app] != request:
|
|
continue
|
|
if request is None:
|
|
del out[app]
|
|
continue
|
|
wanted = requested_tool(request, catalog)
|
|
if wanted is None:
|
|
continue
|
|
if app in current:
|
|
picked = current[app]
|
|
elif missing_is_cleared:
|
|
picked = ""
|
|
else:
|
|
continue
|
|
if picked == wanted:
|
|
continue
|
|
tool = by_name.get(picked)
|
|
if tool and tool["dir"]:
|
|
out[app] = {"valve": tool["valve"], "dir": tool["dir"]}
|
|
else:
|
|
del out[app]
|
|
return out, out != requests
|
|
|
|
|
|
def settled_game_requests(state, current, catalog, missing_is_cleared=False, directory=None):
|
|
for _ in range(2):
|
|
requests, text = read_game_requests(directory)
|
|
settled, moved = settle_game_requests(requests, state, current, catalog, missing_is_cleared)
|
|
if not moved or write_game_requests(settled, text, directory):
|
|
return settled
|
|
return None
|
|
|
|
|
|
def applied_game_requests(requests, overrides, state):
|
|
recorded = state.get("games_applied")
|
|
before = recorded if isinstance(recorded, dict) else {}
|
|
if requests is None:
|
|
return dict(before)
|
|
return {app: request if app in overrides and overrides[app] is not DEFERRED_GAME_TOOL else before[app]
|
|
for app, request in requests.items()
|
|
if app in overrides and overrides[app] is not DEFERRED_GAME_TOOL or app in before}
|
|
|
|
|
|
def requested_games(requests, catalog, runnable=None):
|
|
runnable = None if runnable is None else set(runnable)
|
|
resolved = {}
|
|
for app, request in requests.items():
|
|
if not isinstance(app, str) or not re.fullmatch(r"[1-9][0-9]*", app) or int(app) > 0xFFFFFFFF:
|
|
continue
|
|
if request is None:
|
|
resolved[app] = None
|
|
continue
|
|
if (not isinstance(request, dict) or not isinstance(request.get("dir"), str) or
|
|
not request["dir"] or not isinstance(request.get("valve"), bool)):
|
|
continue
|
|
tool = requested_tool(request, catalog)
|
|
if tool is None or runnable is not None and tool not in runnable:
|
|
resolved[app] = DEFERRED_GAME_TOOL
|
|
else:
|
|
resolved[app] = tool
|
|
return resolved
|
|
|
|
|
|
OWN_PROTON = "DroidDeck-Proton"
|
|
ARCH_WORDS = re.compile(r"(?i)aarch64|arm64|x86_64|x86-64|amd64")
|
|
|
|
|
|
def own_builds(tools):
|
|
return [t for t in tools if isinstance(t, dict) and not t.get("valve") and
|
|
isinstance(t.get("dir"), str) and t["dir"].startswith(OWN_PROTON) and t.get("name")]
|
|
|
|
|
|
def build_key(name):
|
|
return [int(n) for n in re.findall(r"\d+", ARCH_WORDS.sub(" ", name))]
|
|
|
|
|
|
def own_proton_moves(state, catalog):
|
|
names = {t["name"] for t in catalog}
|
|
before = state.get("tools") if isinstance(state.get("tools"), list) else ()
|
|
seen = state.get("own_builds") if isinstance(state.get("own_builds"), list) else []
|
|
known = {n for n in seen if isinstance(n, str)} | {t["name"] for t in own_builds(before)} | {t["name"] for t in own_builds(catalog)}
|
|
state["own_builds"] = sorted(known)
|
|
builds = own_builds(catalog)
|
|
if not builds:
|
|
return None, set()
|
|
newest = max(builds, key=lambda t: (build_key(t["dir"]), t["dir"]))["name"]
|
|
return newest, known - names
|
|
|
|
|
|
def now_ms():
|
|
import time
|
|
return int(time.time() * 1000)
|
|
|
|
|
|
def choose_default(steam_default, state, request, catalog, adopt_steam=True, apply_request=True, now=None, listed=None):
|
|
state = dict(state)
|
|
names = [t["name"] for t in catalog]
|
|
listed = names if listed is None else listed
|
|
if "written" not in state and state.get("source") == "steam":
|
|
state["source"] = "fallback"
|
|
hidden = state.get("default") in names and state.get("default") not in listed
|
|
seq = request.get("seq")
|
|
seq = seq if isinstance(seq, int) else 0
|
|
if apply_request and seq > int(state.get("applied", 0) or 0):
|
|
wanted = requested_tool(request, catalog)
|
|
state["applied"] = seq
|
|
if wanted:
|
|
if wanted != state.get("default"):
|
|
state.update(default=wanted, seq=now or now_ms(), source="app")
|
|
else:
|
|
print("steam-compatibility: the app asked for %s, which is not installed; kept %s" % (
|
|
request.get("name") or request.get("dir"), state.get("default") or TOOL))
|
|
elif adopt_steam and steam_default in names and not hidden and steam_default not in (
|
|
state.get("default"), state.get("written", state.get("default") or TOOL)):
|
|
state.update(default=steam_default, seq=now or now_ms(), source="steam")
|
|
if state.get("default") not in names and state.get("default") != TOOL:
|
|
state.update(default=TOOL, source="fallback")
|
|
return state["default"] if state["default"] in listed else TOOL, state
|
|
|
|
|
|
def mapping_block(tokens):
|
|
steam = find_block(tokens, ["InstallConfigStore", "Software", "Valve", "Steam"])
|
|
if steam is None:
|
|
return None
|
|
mapping = find_block(tokens[steam + 1:], ["CompatToolMapping"])
|
|
if mapping is None:
|
|
end = block_end(tokens, steam)
|
|
tokens[end:end] = ['"CompatToolMapping"', "{", "}"]
|
|
return end + 1
|
|
return mapping + steam + 1
|
|
|
|
|
|
def read_mapping(tokens, mapping):
|
|
current = {}
|
|
end = block_end(tokens, mapping)
|
|
i = mapping + 1
|
|
while i < end:
|
|
if i + 1 < end and tokens[i + 1] == "{":
|
|
close = block_end(tokens, i + 1)
|
|
name = ""
|
|
for j in range(i + 2, close - 1):
|
|
if tokens[j] == '"name"':
|
|
name = tokens[j + 1].strip('"')
|
|
break
|
|
current[tokens[i].strip('"')] = name
|
|
i = close + 1
|
|
else:
|
|
i += 2
|
|
return current
|
|
|
|
|
|
def read_config_mapping(config_path):
|
|
try:
|
|
with open(config_path, encoding="utf-8", errors="replace") as f:
|
|
tokens = tokenize(f.read())
|
|
except OSError:
|
|
return None
|
|
depth = 0
|
|
for token in tokens:
|
|
if token == "{":
|
|
depth += 1
|
|
elif token == "}":
|
|
depth -= 1
|
|
if depth < 0:
|
|
return None
|
|
if depth != 0:
|
|
return None
|
|
steam = find_block(tokens, ["InstallConfigStore", "Software", "Valve", "Steam"])
|
|
if steam is None:
|
|
return None
|
|
mapping = find_block(tokens[steam + 1:], ["CompatToolMapping"])
|
|
return read_mapping(tokens, mapping + steam + 1) if mapping is not None else {}
|
|
|
|
|
|
def mapping_entry(app, tool):
|
|
return ['"%s"' % app, "{", '"name"', '"%s"' % tool, '"config"', '""',
|
|
'"priority"', '"75"' if app == "0" else '"250"', "}"]
|
|
|
|
|
|
def set_mapping_tool(tokens, block, tool):
|
|
end = block_end(tokens, block)
|
|
for i in range(block + 1, end):
|
|
if tokens[i] == '"name"':
|
|
tokens[i + 1] = '"%s"' % tool
|
|
return
|
|
tokens[block + 1:block + 1] = ['"name"', '"%s"' % tool]
|
|
|
|
|
|
def set_mapping(tokens, mapping, app, tool):
|
|
block = find_block(tokens[mapping + 1:], [app])
|
|
if tool is None:
|
|
if block is not None:
|
|
block += mapping + 1
|
|
del tokens[block - 1:block_end(tokens, block) + 1]
|
|
return
|
|
if block is None:
|
|
tokens[mapping + 1:mapping + 1] = mapping_entry(app, tool)
|
|
return
|
|
set_mapping_tool(tokens, block + mapping + 1, tool)
|
|
|
|
|
|
def set_mappings(tokens, mapping, changes):
|
|
"""Apply a batch with one traversal of the affected mapping entries."""
|
|
pending = {('"%s"' % app).lower(): (app, tool) for app, tool in changes.items()}
|
|
# Non-string or aliased keys retain the single writer's sequential semantics.
|
|
if len(changes) < 2 or len(pending) != len(changes) or any(not isinstance(app, str) for app in changes):
|
|
for app, tool in changes.items():
|
|
set_mapping(tokens, mapping, app, tool)
|
|
return
|
|
updated = []
|
|
i = mapping + 1
|
|
while pending and i < len(tokens) and tokens[i] != "}":
|
|
if i + 1 < len(tokens) and tokens[i + 1] == "{":
|
|
end = block_end(tokens, i + 1)
|
|
change = pending.pop(tokens[i].lower(), None)
|
|
if change is None:
|
|
updated.extend(tokens[i:end + 1])
|
|
elif change[1] is not None:
|
|
entry = tokens[i:end + 1]
|
|
set_mapping_tool(entry, 1, change[1])
|
|
updated.extend(entry)
|
|
i = end + 1
|
|
else:
|
|
updated.append(tokens[i])
|
|
i += 1
|
|
added = []
|
|
for app, tool in reversed(list(pending.values())):
|
|
if tool is not None:
|
|
added.extend(mapping_entry(app, tool))
|
|
tokens[mapping + 1:i] = added + updated
|
|
|
|
|
|
def steam_running():
|
|
try:
|
|
pids = [p for p in os.listdir("/proc") if p.isdigit()]
|
|
except OSError:
|
|
return False
|
|
for pid in pids:
|
|
try:
|
|
with open("/proc/%s/cmdline" % pid, "rb") as f:
|
|
argv0 = f.read().split(b"\0", 1)[0]
|
|
except OSError:
|
|
continue
|
|
if argv0.endswith(b"steamrtarm64/steam"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def game_running():
|
|
try:
|
|
pids = [p for p in os.listdir("/proc") if p.isdigit()]
|
|
except OSError:
|
|
return False
|
|
for pid in pids:
|
|
try:
|
|
with open("/proc/%s/cmdline" % pid, "rb") as f:
|
|
if b"\0SteamLaunch\0AppId=" in f.read():
|
|
return True
|
|
except OSError:
|
|
continue
|
|
return False
|
|
|
|
|
|
def live_helper_running(directory=None):
|
|
try:
|
|
with open(state_path("live.pid", directory)) as f:
|
|
pid = int(f.read().split()[0])
|
|
os.kill(pid, 0)
|
|
return True
|
|
except (OSError, ValueError, IndexError):
|
|
return False
|
|
|
|
|
|
def register_default(config_path, apps=(), protect=(), default=None, catalog=None, directory=None, settle=False, picks=(),
|
|
linux_tool=FEX_TOOL, owned=None):
|
|
with locked(directory):
|
|
state = read_json(state_path("state.json", directory))
|
|
with open(config_path, encoding="utf-8", errors="replace") as f:
|
|
original = f.read()
|
|
tokens = tokenize(original)
|
|
mapping = mapping_block(tokens)
|
|
if mapping is None:
|
|
return state.get("default") or TOOL
|
|
current = read_mapping(tokens, mapping)
|
|
if catalog is None:
|
|
catalog = tool_catalog(os.path.dirname(os.path.dirname(os.path.abspath(config_path))),
|
|
dict(protect) if isinstance(protect, dict) else {name: name for name in protect})
|
|
names = [t["name"] for t in catalog]
|
|
newest, gone = own_proton_moves(state, catalog)
|
|
previous = state.get("default")
|
|
if owned is not None:
|
|
auto = followers(state, current)
|
|
known = {app for app in state.get("owned", ()) if isinstance(app, str)} if isinstance(state.get("owned"), list) else set()
|
|
pinned = {app: current[app] for app in set(owned) - known
|
|
if current.get(app) in {TOOL, previous} | gone and app not in auto}
|
|
if pinned and isinstance(auto, dict):
|
|
state["auto"] = dict(auto, **pinned)
|
|
state["owned"] = sorted(set(owned), key=app_order)
|
|
if default is None:
|
|
if settle:
|
|
default, state = choose_default(built_from(current.get("0", ""), catalog) or current.get("0", ""), state,
|
|
read_json(state_path("request.json", directory)), catalog)
|
|
else:
|
|
default = state.get("default") if state.get("default") in names else TOOL
|
|
if settle and newest and default == TOOL and state.get("source") not in ("app", "steam"):
|
|
default = newest
|
|
state.update(default=newest, seq=now_ms(), source="update")
|
|
state.pop("own_proton", None)
|
|
if newest:
|
|
replacements = {app: newest for app, name in current.items() if app != "0" and name in gone}
|
|
set_mappings(tokens, mapping, replacements)
|
|
current.update(replacements)
|
|
if isinstance(state.get("auto"), dict):
|
|
state["auto"] = {app: newest if name in gone else name for app, name in state["auto"].items()}
|
|
following = followers(state, current)
|
|
following = following if isinstance(following, dict) else {}
|
|
moved = rebuilt_picks(current, catalog, set(following) | picked_apps(state))
|
|
if moved:
|
|
state["auto"] = {app: name for app, name in following.items() if app not in moved}
|
|
set_mappings(tokens, mapping, moved)
|
|
current.update(moved)
|
|
steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path)))
|
|
requests = settled_game_requests(state, current, catalog, not steam_running(), directory)
|
|
overrides = requested_games(requests or {}, catalog)
|
|
managed = state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else ()
|
|
native_choices = state.get("native_choices", ())
|
|
native = native_targets(steam_root, set(apps) | set(picks) | set(managed) | set(native_choices))
|
|
if linux_tool != FEX_TOOL:
|
|
native = {app: linux_tool if tool == FEX_TOOL else tool for app, tool in native.items()}
|
|
for app in picked_apps(state) & set(native):
|
|
current.setdefault(app, "")
|
|
changes, auto = plan_mapping(
|
|
current, apps, names, default, followers(state, current), native, picks,
|
|
overrides, managed, native_choices, tool_platforms(steam_root))
|
|
set_mappings(tokens, mapping, changes)
|
|
text = serialize(tokens)
|
|
if text != serialize(tokenize(original)):
|
|
write_changed(config_path, text)
|
|
state["auto"] = {app: tool for app, tool in auto.items() if tool is not NATIVE_CHOICE}
|
|
state["native_choices"] = sorted(app for app, tool in auto.items() if tool is NATIVE_CHOICE)
|
|
state["picked"] = picked_after(current, changes)
|
|
state["games_applied"] = applied_game_requests(requests, overrides, state)
|
|
state.pop("native", None)
|
|
state["tools"] = catalog
|
|
state.setdefault("default", default)
|
|
if settle:
|
|
state["written"] = default
|
|
write_json(state_path("state.json", directory), state)
|
|
return default
|
|
|
|
|
|
def depot_build(depot):
|
|
try:
|
|
with open(os.path.join(depot, "version"), encoding="utf-8", errors="replace") as f:
|
|
first = f.read().split(None, 1)
|
|
except OSError:
|
|
return 0
|
|
return int(first[0]) if first and first[0].isdigit() else 0
|
|
|
|
|
|
def find_source(steam_root, sources=SOURCES, runnable=False):
|
|
for name in sources:
|
|
found = [os.path.join(root, "steamapps", "common", name) for root in libraries(steam_root)
|
|
if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64"))
|
|
and (not runnable or os.access(os.path.join(root, "steamapps", "common", name, "proton"), os.X_OK))]
|
|
if found:
|
|
return max(found, key=depot_build)
|
|
return None
|
|
|
|
|
|
def build_tool(tool_dir, depot, sources=SOURCES, name=TOOL):
|
|
if os.path.islink(tool_dir):
|
|
os.unlink(tool_dir)
|
|
elif os.path.isdir(tool_dir) and set(os.listdir(tool_dir)) - set(OWN_FILES):
|
|
shutil.rmtree(tool_dir)
|
|
os.makedirs(tool_dir, exist_ok=True)
|
|
write_changed(os.path.join(tool_dir, "toolmanifest.vdf"), TOOL_MANIFEST_VDF)
|
|
write_changed(os.path.join(tool_dir, "compatibilitytool.vdf"), COMPAT_TOOL_VDF % (name, display_name(
|
|
os.path.basename(depot).replace(" (ARM64)", " ARM64") if depot else "Proton ARM64")))
|
|
write_changed(os.path.join(tool_dir, LAUNCHER), launcher_sh(sources), 0o755)
|
|
|
|
|
|
def build_fex_tool(tool_dir):
|
|
if os.path.islink(tool_dir):
|
|
os.unlink(tool_dir)
|
|
elif os.path.isdir(tool_dir) and set(os.listdir(tool_dir)) - set(FEX_FILES):
|
|
shutil.rmtree(tool_dir)
|
|
os.makedirs(tool_dir, exist_ok=True)
|
|
write_changed(os.path.join(tool_dir, "toolmanifest.vdf"), FEX_MANIFEST_VDF)
|
|
write_changed(os.path.join(tool_dir, "compatibilitytool.vdf"), FEX_COMPAT_TOOL_VDF % (FEX_TOOL, display_name("Linux x86 (FEX)")))
|
|
write_changed(os.path.join(tool_dir, FEX_LAUNCHER), FEX_LAUNCHER_SH, 0o755)
|
|
|
|
|
|
def remove_tool(tool_dir):
|
|
if os.path.isdir(tool_dir) and not os.path.islink(tool_dir) and not set(os.listdir(tool_dir)) - set(OWN_FILES) - {LEGACY_LAUNCHER}:
|
|
shutil.rmtree(tool_dir)
|
|
|
|
|
|
def local_tools(steam_root):
|
|
names = set()
|
|
tools = os.path.join(steam_root, "compatibilitytools.d")
|
|
try:
|
|
entries = os.listdir(tools)
|
|
except OSError:
|
|
return names
|
|
for entry in entries:
|
|
try:
|
|
with open(os.path.join(tools, entry, "compatibilitytool.vdf"), encoding="utf-8", errors="replace") as f:
|
|
m = re.search(r'"compat_tools"\s*\{\s*"([^"]+)"', f.read())
|
|
except OSError:
|
|
continue
|
|
if m:
|
|
names.add(m.group(1).lower())
|
|
return names
|
|
|
|
|
|
def x64_repoint(steam_root, tool, live=False):
|
|
config = os.path.join(steam_root, "config", "config.vdf")
|
|
if not tool or not os.path.isfile(config):
|
|
return []
|
|
with open(config, encoding="utf-8", errors="replace") as f:
|
|
original = f.read()
|
|
tokens = tokenize(original)
|
|
mapping = mapping_block(tokens)
|
|
if mapping is None:
|
|
return []
|
|
keep = local_tools(steam_root)
|
|
moved = []
|
|
current = read_mapping(tokens, mapping)
|
|
changes = {} if "0" in current else {"0": tool}
|
|
for app, name in current.items():
|
|
named = name.lower()
|
|
legacy = named == "droiddeck-proton-arm64"
|
|
if (named.startswith("proton") or legacy) and named != tool.lower() \
|
|
and (legacy or named not in keep):
|
|
changes[app] = tool
|
|
if app != "0":
|
|
moved.append(app)
|
|
set_mappings(tokens, mapping, changes)
|
|
text = serialize(tokens)
|
|
if text != serialize(tokenize(original)):
|
|
write_changed(config, text)
|
|
print("steam-compatibility: x86-64 client's Proton entries point at %s" % tool)
|
|
if moved and live:
|
|
with open(os.path.join(os.path.expanduser("~"), ".bl-compat-pending"), "a") as f:
|
|
f.write("".join("%s\n" % a for a in moved))
|
|
print("steam-compatibility: repointed while the client runs: %s" % " ".join(moved))
|
|
return moved
|
|
|
|
|
|
def main():
|
|
steam_root = sys.argv[1]
|
|
if "--x64" in sys.argv[2:]:
|
|
x64_repoint(steam_root, os.environ.get("BL_COMPAT_TOOL"), "--live" in sys.argv[2:])
|
|
return
|
|
tools = os.path.join(steam_root, "compatibilitytools.d")
|
|
os.makedirs(tools, exist_ok=True)
|
|
legacy = os.path.join(tools, LEGACY_TOOL)
|
|
if os.path.islink(legacy):
|
|
os.unlink(legacy)
|
|
elif os.path.isdir(legacy):
|
|
shutil.rmtree(legacy)
|
|
for name, sources in OWN_TOOLS:
|
|
depot = find_source(steam_root, sources)
|
|
if depot is None and name != TOOL:
|
|
remove_tool(os.path.join(tools, name))
|
|
continue
|
|
build_tool(os.path.join(tools, name), depot, sources, name)
|
|
print("steam-compatibility: registered %s (%s)" % (
|
|
name, "depot %s present" % os.path.basename(depot) if depot else
|
|
"no ARM64 Proton depot yet; the session asks the client to fetch it"))
|
|
build_fex_tool(os.path.join(tools, FEX_TOOL))
|
|
print("steam-compatibility: registered %s" % FEX_TOOL)
|
|
extras = adopt_extras(tools)
|
|
for name, directory in sorted(extras.items()):
|
|
print("steam-compatibility: adopted %s as %s" % (directory, name))
|
|
catalog = tool_catalog(steam_root, extras)
|
|
config = os.path.join(steam_root, "config", "config.vdf")
|
|
if live_helper_running():
|
|
with locked():
|
|
state = read_json(state_path("state.json"))
|
|
state["tools"] = catalog
|
|
write_json(state_path("state.json"), state)
|
|
print("steam-compatibility: the live helper keeps the client's mapping; tools listed")
|
|
elif os.path.isfile(config):
|
|
apps = installed_apps(steam_root)
|
|
picks = valve_picks(steam_root)
|
|
default = register_default(config, apps, protect=extras, catalog=catalog, settle=not steam_running(), picks=picks)
|
|
print("steam-compatibility: default, %d installed title(s) and %d title(s) with a Valve pick set to %s" % (len(apps), len(picks), default))
|
|
sync = os.path.join(os.path.dirname(os.path.realpath(__file__)), "droiddeck-esync")
|
|
if os.path.isfile(sync):
|
|
try:
|
|
summary = runpy.run_path(sync)["reconcile"](steam_root, os.environ)
|
|
except Exception as e:
|
|
summary = "droiddeck-esync: reconcile failed (%s)" % e
|
|
if summary:
|
|
print("steam-compatibility: %s" % summary)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|