mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2d7ede3b | ||
|
|
a9cd982ec8 | ||
|
|
c2506e15d6 |
No files matched your search
@@ -35,7 +35,7 @@ object SessionFiles {
|
||||
"usr/local/bin/bannerlator-steam-compat" to "usr/local/bin/bannerlator-steam-compat",
|
||||
"usr/local/bin/bannerlator-steam-install" to "usr/local/bin/bannerlator-steam-install",
|
||||
"usr/local/bin/bannerlator-steam-library" to "usr/local/bin/bannerlator-steam-library",
|
||||
"usr/local/bin/bannerlator-seed-redists" to "usr/local/bin/bannerlator-seed-redists",
|
||||
"usr/local/bin/bannerlator-restore-redists" to "usr/local/bin/bannerlator-restore-redists",
|
||||
"usr/local/bin/bannerlator-proton-extra" to "usr/local/bin/bannerlator-proton-extra",
|
||||
"usr/local/bin/bannerlator-netmanager" to "usr/local/bin/bannerlator-netmanager",
|
||||
"usr/local/bin/bannerlator-steam-launch" to "usr/local/bin/bannerlator-steam-launch",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/python3
|
||||
"""Undo the old blanket HasRunKey seeder before Steam/Wine starts.
|
||||
|
||||
Steam must run native prerequisites and record their real result. Only prefixes carrying
|
||||
our old seeder stamp are migrated. Proton's intentional .NET/XNA defaults remain intact.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def restore_prefix(prefix):
|
||||
prefix = Path(prefix)
|
||||
stamp = prefix / '.bannerlator-redists'
|
||||
reg = prefix / 'system.reg'
|
||||
if not stamp.is_file() or stamp.is_symlink() or not reg.is_file() or reg.is_symlink():
|
||||
return False
|
||||
text = reg.read_text(encoding='utf-8')
|
||||
if not text.startswith('WINE REGISTRY Version 2'):
|
||||
raise ValueError('not a Wine registry')
|
||||
out = []
|
||||
clear = False
|
||||
for line in text.splitlines(keepends=True):
|
||||
match = re.match(r'^\[([^\]]+)\]', line)
|
||||
if match:
|
||||
key = match[1].replace('\\\\', '\\').lower()
|
||||
parts = key.split('\\')
|
||||
try:
|
||||
common = parts.index('commonredist')
|
||||
except ValueError:
|
||||
clear = False
|
||||
else:
|
||||
clear = (parts[:common] in (
|
||||
['software', 'valve', 'steam', 'apps'],
|
||||
['software', 'wow6432node', 'valve', 'steam', 'apps'])
|
||||
and len(parts) > common + 1
|
||||
and parts[common + 1] not in ('.net', 'xna'))
|
||||
if clear and re.fullmatch(r'"[^"\r\n]+"=dword:00000001\s*', line):
|
||||
continue
|
||||
out.append(line)
|
||||
updated = ''.join(out)
|
||||
if updated != text:
|
||||
stat = reg.stat()
|
||||
descriptor, temporary = tempfile.mkstemp(dir=prefix, prefix='.redists-')
|
||||
try:
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as f:
|
||||
f.write(updated)
|
||||
os.chmod(temporary, stat.st_mode & 0o777)
|
||||
# Preserve template dates: this migration already covers existing prefixes and
|
||||
# should not force every game through a Proton prefix upgrade.
|
||||
os.utime(temporary, ns=(stat.st_atime_ns, stat.st_mtime_ns))
|
||||
os.replace(temporary, reg)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
stamp.unlink()
|
||||
return updated != text
|
||||
|
||||
|
||||
def restore(steam_root, libraries=('/mnt/droiddeck-sd',)):
|
||||
roots = [Path(steam_root), *(Path(p) for p in libraries)]
|
||||
prefixes = []
|
||||
for root in roots:
|
||||
prefixes.extend(root.glob('steamapps/compatdata/*/pfx'))
|
||||
prefixes.extend(root.glob('steamapps/common/Proton*/files/share/default_pfx*'))
|
||||
prefixes.extend(Path(steam_root).glob('compatibilitytools.d/*/files/share/default_pfx*'))
|
||||
seen = set()
|
||||
changed = 0
|
||||
for prefix in prefixes:
|
||||
actual = prefix.resolve()
|
||||
if actual in seen:
|
||||
continue
|
||||
seen.add(actual)
|
||||
try:
|
||||
if restore_prefix(actual):
|
||||
changed += 1
|
||||
print('bannerlator-restore-redists: restored Steam prerequisite checks in %s' % prefix)
|
||||
except (OSError, UnicodeError, ValueError) as error:
|
||||
print('bannerlator-restore-redists: %s: %s' % (prefix, error), file=sys.stderr)
|
||||
return changed
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
restore(sys.argv[1])
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Marks Steam's shared redistributables as already run in every Proton prefix, and in the
|
||||
# template Proton makes new prefixes from.
|
||||
#
|
||||
# A game whose depot lists _CommonRedist makes the client run the real installer inside the
|
||||
# prefix on first launch. Those are x86 bundles, so they run under FEX, and the 2019 one leaves
|
||||
# a task behind every time: its main thread exits while a helper stays blocked reading a pipe it
|
||||
# also holds the write end of, so the process never reaps and the client keeps waiting on it.
|
||||
#
|
||||
# The client skips a "Run Process" block whose HasRunKey is already set, which is the same escape
|
||||
# WineUtils.seedVcRedistRegistryIfDllsPresent() takes for the app's own containers. The names and
|
||||
# versions come out of the install scripts rather than a list kept here, because Valve revises
|
||||
# them; a prefix already carrying the same set is left alone.
|
||||
set -u
|
||||
steam_root=${1:?steam root}
|
||||
|
||||
redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist"
|
||||
|
||||
# Every process an install script would run, as the registry key and value the client writes once
|
||||
# it has run it. Names sit one level inside "Run Process", the key they share a level further in.
|
||||
pairs=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk '
|
||||
FNR == 1 { depth = 0; name = ""; key = "" }
|
||||
/^[[:space:]]*\{/ { depth++; next }
|
||||
/^[[:space:]]*\}/ {
|
||||
if (depth == 3 && name != "" && key != "")
|
||||
printf "%s\t%s\n", key, name
|
||||
depth--
|
||||
next
|
||||
}
|
||||
{
|
||||
n = split($0, part, "\"")
|
||||
if (n >= 5 && tolower(part[2]) == "hasrunkey") { key = part[4]; next }
|
||||
if (n >= 3 && depth == 2) { name = part[2]; key = "" }
|
||||
}
|
||||
')
|
||||
# The client downloads the scripts with the first game that wants them, which is too late for that
|
||||
# game; until then the set they held when this was written stands in. (WinNative's list.)
|
||||
[ -n "$pairs" ] || pairs=$(sed 's/^\([^ ]*\) /HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\\1\t/' <<'KNOWN'
|
||||
DirectX\\Jun2010 dxsetup
|
||||
.NET\\4.8 4.8
|
||||
vcredist\\2008 x86 SP1
|
||||
vcredist\\2008 x64 SP1
|
||||
vcredist\\2010 x86
|
||||
vcredist\\2010 x64
|
||||
vcredist\\2013 x86 12.0.30501
|
||||
vcredist\\2013 x64 12.0.30501
|
||||
vcredist\\2015 x86 Update 3 14.0.24215.0
|
||||
vcredist\\2015 x64 Update 3 14.0.24215.0
|
||||
vcredist\\2017 x86 14.10.25008.0
|
||||
vcredist\\2017 x64 14.10.25008.0
|
||||
vcredist\\2019 x86 14.28.29334.0
|
||||
vcredist\\2019 x64 14.28.29334.0
|
||||
vcredist\\2022 x86 14.51.36247.0
|
||||
vcredist\\2022 x64 14.51.36247.0
|
||||
KNOWN
|
||||
)
|
||||
# The stamp is the set of names, taken before the block below dates its keys: stamped from the
|
||||
# dated block it never matched, and every prefix had the whole block appended again on every beat.
|
||||
stamp=$(printf '%s' "$pairs" | cksum)
|
||||
|
||||
block=$(printf '%s\n' "$pairs" | awk -F'\t' -v now="$(date +%s)" '
|
||||
{
|
||||
path = $1
|
||||
sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path)
|
||||
if (path == $1)
|
||||
next
|
||||
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2
|
||||
# The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node;
|
||||
# both spellings are seeded because the reader picks by its own word size.
|
||||
sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path)
|
||||
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2
|
||||
}
|
||||
')
|
||||
[ -n "$block" ] || exit 0
|
||||
|
||||
# Proton builds a new prefix from its template, and the client runs the install scripts in that
|
||||
# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a
|
||||
# changed one as a new template and upgrades every prefix made from the old.
|
||||
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
|
||||
"$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \
|
||||
"$steam_root"/steamapps/compatdata/*/pfx/system.reg \
|
||||
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do
|
||||
[ -f "$reg" ] || continue
|
||||
seeded=$(dirname "$reg")/.bannerlator-redists
|
||||
[ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue
|
||||
dated=$(mktemp) || continue
|
||||
touch -r "$reg" "$dated"
|
||||
if printf '%s\n' "$block" >>"$reg"; then
|
||||
printf '%s\n' "$stamp" >"$seeded"
|
||||
echo "== seeded redist markers into $reg"
|
||||
fi
|
||||
touch -r "$dated" "$reg"
|
||||
rm -f "$dated"
|
||||
done
|
||||
@@ -323,9 +323,9 @@ case $mode in
|
||||
step "adding the app's own games to the client's library"
|
||||
bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
|
||||
fi
|
||||
# Steam runs a game's shared redistributables inside its prefix on first launch; marking
|
||||
# them as already run keeps those x86 installers from starting under emulation at all.
|
||||
bannerlator-seed-redists "$steam_root" || true
|
||||
# Undo earlier blanket success markers while Wine is stopped. Steam now installs its
|
||||
# prerequisites normally; the Proton wrapper handles the old VC MSI bundles quietly.
|
||||
bannerlator-restore-redists "$steam_root" || true
|
||||
# The compatibility tool runs Valve's arm64 Proton, which the client fetches like any depot.
|
||||
# Asked for on the command line the first time, so a Windows title has it before it is needed.
|
||||
if [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then
|
||||
@@ -397,18 +397,6 @@ case $mode in
|
||||
# titles are installed: a title installed this session gets the client's own per-app Proton
|
||||
# choice, which it cannot start. Refresh the per-app entries every so often; the registrar is
|
||||
# idempotent, so a repeat costs nothing.
|
||||
# The seeder has the same problem and a worse consequence. It marks the shared redistributables
|
||||
# as already run in each prefix that exists, and a game's prefix is not created until the
|
||||
# client launches it - so a title played for the first time this session was never covered, and
|
||||
# its install scripts run for real. That is exactly the hang the seeder exists to prevent: Need
|
||||
# for Speed Payback sat on "Running install script" with vcredist_x86.exe blocked in pipe_read,
|
||||
# holding both ends of a pipe nobody would write to, while the client waited for it to exit.
|
||||
# Seeding again on the same beat covers a prefix the moment it appears.
|
||||
# The seeder runs on a shorter beat than the registrar because it is racing something. The
|
||||
# client creates a prefix when it launches a title and runs that title's install scripts
|
||||
# moments later, so the seeder has only that gap to mark the redistributables as already run.
|
||||
# It stamps each prefix it has covered and skips it thereafter, so the extra passes cost
|
||||
# almost nothing.
|
||||
# The drawer's "Stretch games to fill the screen" works live: the app writes its choice to
|
||||
# ~/.droiddeck-fill, and each change goes to gamescope as GAMESCOPE_FORCE_WINDOWS_FULLSCREEN on
|
||||
# the root window, which it watches. What gamescope was started with is where this begins.
|
||||
@@ -478,13 +466,8 @@ case $mode in
|
||||
esac
|
||||
done
|
||||
) &
|
||||
# The library beat. Both helpers are whole programs under proot (the compat one a python
|
||||
# start and a parse of the client's config), and nearly every beat has nothing new for them.
|
||||
# The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born
|
||||
# marked and every 15 s is soon enough for the rest. The compat pass runs when a directory it
|
||||
# reads from has changed since its last run - a new manifest, a new depot or tool, the client
|
||||
# rewriting config.vdf - which [ -nt ] answers without starting anything, and once a minute
|
||||
# regardless.
|
||||
# Refresh compatibility registrations when a library or client config changes, and once
|
||||
# a minute regardless. Prerequisite checks belong to Steam and are not changed on this beat.
|
||||
(
|
||||
beat=0
|
||||
compat_stamp=$HOME/.bl-compat-beat
|
||||
@@ -502,7 +485,6 @@ case $mode in
|
||||
nap 5
|
||||
beat=$((beat + 1))
|
||||
[ "$((beat % 3))" -eq 0 ] || continue
|
||||
bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true
|
||||
if compat_due; then
|
||||
: > "$compat_stamp"
|
||||
bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
# require_tool_appid. Proton takes its base directory from dirname(sys.argv[0]) without resolving
|
||||
# it, so it reads files/ through those symlinks and runs directly on this rootfs.
|
||||
import os
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
TOOL = "bannerlator-proton-arm64"
|
||||
# Valve's own ARM64 Proton, by the name the client registers it under. Used only to bootstrap a
|
||||
@@ -141,6 +144,18 @@ bl_game_cpus() {
|
||||
}
|
||||
"""
|
||||
|
||||
# Prepare Steam's transient evaluator input before Wine reads it. Recognized legacy VC bundles
|
||||
# use their actual MSI; a verified legacy PhysX bundle uses its supported quiet argument.
|
||||
BL_INSTALLSCRIPT_SETUP = """
|
||||
bl_installscript() {
|
||||
[ "${verb:-}" = run ] || return 0
|
||||
case "${1:-}" in */iscriptevaluator.exe)
|
||||
/usr/local/bin/bannerlator-steam-compat --installscript "${STEAM_COMPAT_CLIENT_INSTALL_PATH:-}" "${2:-}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
"""
|
||||
|
||||
# Quake III engine games (id Tech 3) set their own resolution and switch the display to it. Under
|
||||
# gamescope with the ARM64 Proton that switch is refused ("calling CDS: failed, bad mode") and the
|
||||
# game never gets OpenGL; left at its own size in a window, it draws small in a corner. So these
|
||||
@@ -272,6 +287,7 @@ if [ -n "$depot" ]; then
|
||||
bl_game_cpus
|
||||
bl_idtech3 "$@"
|
||||
bl_libpath
|
||||
bl_installscript "$@"
|
||||
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
||||
fi
|
||||
echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2
|
||||
@@ -282,7 +298,7 @@ exit 1
|
||||
# something else.
|
||||
LAUNCHER_SH = LAUNCHER_SH.replace(
|
||||
"verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
|
||||
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1)
|
||||
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_INSTALLSCRIPT_SETUP, 1)
|
||||
|
||||
# A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of
|
||||
# that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot
|
||||
@@ -319,6 +335,7 @@ bl_directaudio "$here"
|
||||
bl_game_cpus
|
||||
bl_idtech3 "$@"
|
||||
bl_libpath
|
||||
bl_installscript "$@"
|
||||
# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be
|
||||
# started from its own tree.
|
||||
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
||||
@@ -430,6 +447,188 @@ def write_changed(path, text, mode=None):
|
||||
return not same
|
||||
|
||||
|
||||
def vdf_value(token):
|
||||
return re.sub(r'\\(["\\])', r'\1', token[1:-1])
|
||||
|
||||
|
||||
def vdf_quote(value):
|
||||
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def direct_entries(tokens, start):
|
||||
"""Yield keys and their value/block indices, preserving repeated VDF keys."""
|
||||
i = start + 1
|
||||
while i < block_end(tokens, start):
|
||||
yield vdf_value(tokens[i]).lower(), i + 1
|
||||
i = block_end(tokens, i + 1) + 1 if tokens[i + 1] == '{' else i + 2
|
||||
|
||||
|
||||
def redist_msi(installer, cache_root):
|
||||
"""Extract a recognized old VC x86 MSI bundle with the runtime's libarchive tools.
|
||||
|
||||
Darksiders' VC2005 IExpress child exits its main thread under ARM64EC Wine, leaving another
|
||||
thread waiting on a pipe. Steam waits forever for that child. Running its actual MSI directly
|
||||
succeeds. Burn bundles and other installers keep their original launch path.
|
||||
"""
|
||||
def members(path):
|
||||
result = subprocess.run(['bsdtar', '-tf', path], check=True, capture_output=True,
|
||||
text=True, timeout=10)
|
||||
return {name.lower(): name for name in result.stdout.splitlines()
|
||||
if name and '/' not in name and '\\' not in name and name not in ('.', '..')}
|
||||
|
||||
def extract(source, names, destination):
|
||||
subprocess.run(['bsdtar', '-xf', source, '-C', destination,
|
||||
'--no-same-owner', '--no-same-permissions', '--', *names],
|
||||
check=True, capture_output=True, timeout=10)
|
||||
for name in names:
|
||||
path = os.path.join(destination, name)
|
||||
if os.path.islink(path) or not os.path.isfile(path):
|
||||
raise ValueError('redistributable member is not a regular file')
|
||||
|
||||
# These pre-Burn bundles are small. Avoid interpreting an arbitrary large executable archive.
|
||||
if os.path.getsize(installer) > 32 * 1024 * 1024:
|
||||
return None
|
||||
with open(installer, 'rb') as f:
|
||||
hasher = hashlib.sha256()
|
||||
for chunk in iter(lambda: f.read(65536), b''):
|
||||
hasher.update(chunk)
|
||||
digest = hasher.hexdigest()
|
||||
target = os.path.join(cache_root, digest)
|
||||
os.makedirs(cache_root, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=cache_root) as staging:
|
||||
source = installer
|
||||
entries = members(source)
|
||||
# VC2005 has two IExpress layers. Extract only the recognized flat child, never the
|
||||
# archive's full contents; libarchive's CAB support also handles these embedded cabinets.
|
||||
inner = [name for key, name in entries.items()
|
||||
if re.fullmatch(r'vcredi[^/\\]*\.exe', key)]
|
||||
if len(inner) == 1:
|
||||
extract(source, inner, staging)
|
||||
source = os.path.join(staging, inner[0])
|
||||
entries = members(source)
|
||||
for msi, cab in (('vcredist.msi', 'vcredis1.cab'), ('vc_red.msi', 'vc_red.cab')):
|
||||
if msi in entries and cab in entries:
|
||||
payload = os.path.join(staging, 'payload')
|
||||
os.mkdir(payload)
|
||||
names = [entries[msi], entries[cab]]
|
||||
extract(source, names, payload)
|
||||
with open(os.path.join(payload, names[0]), 'rb') as f:
|
||||
if f.read(8) != b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1':
|
||||
raise ValueError('redistributable MSI has an invalid header')
|
||||
with open(os.path.join(payload, names[1]), 'rb') as f:
|
||||
if f.read(4) != b'MSCF':
|
||||
raise ValueError('redistributable cabinet has an invalid header')
|
||||
# Reuse only a complete pair. A failed extraction never becomes the cache entry.
|
||||
if not all(os.path.isfile(os.path.join(target, n)) and
|
||||
not os.path.islink(os.path.join(target, n)) for n in names):
|
||||
if os.path.lexists(target):
|
||||
raise ValueError('incomplete redistributable cache')
|
||||
os.rename(payload, target)
|
||||
return os.path.join(target, names[0])
|
||||
return None
|
||||
|
||||
|
||||
# Mirror's Edge bundles Wise's PhysX 8.10.17 bootstrapper without arguments. /s is forwarded to
|
||||
# msiexec and still opens its license wizard; /qn installs the actual runtime silently. Match the
|
||||
# exact tested bundle, rather than guessing switches for other PhysX installer generations.
|
||||
QUIET_PHYSX_SHA256 = '32d27a5e64e099adc4d3d2c87e5702f237c3d1292406f166059837c4c827e22f'
|
||||
|
||||
|
||||
def quiet_physx(installer):
|
||||
if os.path.getsize(installer) > 64 * 1024 * 1024 or not os.path.isfile(installer):
|
||||
return False
|
||||
digest = hashlib.sha256()
|
||||
with open(installer, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(65536), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest() == QUIET_PHYSX_SHA256
|
||||
|
||||
|
||||
def prepare_installscript(steam_root, script):
|
||||
# The input is Steam's temporary evaluator file, not a game's installed metadata. Restrict
|
||||
# resolution to that one directory so a Windows path or a traversal cannot rewrite other files.
|
||||
parts = script.replace('\\', '/').split('/')
|
||||
if len(parts) != 2 or parts[0].lower() != 'legacycompat' or not re.fullmatch(
|
||||
r'evaluatorscript_[0-9]+\.vdf', parts[1], re.IGNORECASE):
|
||||
return False
|
||||
if not os.path.isabs(steam_root):
|
||||
return False
|
||||
path = os.path.join(steam_root, 'legacycompat', parts[1])
|
||||
with open(path, encoding='utf-8') as f:
|
||||
tokens = tokenize(f.read())
|
||||
root = find_block(tokens, ['evaluatorscript'])
|
||||
if root is None:
|
||||
return False
|
||||
changed = False
|
||||
for _, entry in direct_entries(tokens, root):
|
||||
if tokens[entry] != '{':
|
||||
continue
|
||||
values = dict(direct_entries(tokens, entry))
|
||||
install_index = values.get('install_path')
|
||||
compat = values.get('compat_installscript')
|
||||
if install_index is None or compat is None or tokens[compat] != '{':
|
||||
continue
|
||||
install_dir = vdf_value(tokens[install_index])
|
||||
run = dict(direct_entries(tokens, compat)).get('run process')
|
||||
if run is None or tokens[run] != '{' or not os.path.isabs(install_dir):
|
||||
continue
|
||||
for _, process in direct_entries(tokens, run):
|
||||
if tokens[process] != '{':
|
||||
continue
|
||||
fields = dict(direct_entries(tokens, process))
|
||||
exe_index, args_index = fields.get('process 1'), fields.get('command 1')
|
||||
if exe_index is None:
|
||||
continue
|
||||
match = re.fullmatch(r'%INSTALLDIR%[\\/]+(.+)',
|
||||
vdf_value(tokens[exe_index]), re.IGNORECASE)
|
||||
args = vdf_value(tokens[args_index]).split() if args_index is not None else []
|
||||
if not match:
|
||||
continue
|
||||
relative = re.split(r'[\\/]+', match[1])
|
||||
physx = relative[-1].lower() == 'physx_systemsoftware.exe' and not args
|
||||
vc = re.fullmatch(r'vcredist_x86[^\\/]*\.exe', relative[-1], re.IGNORECASE)
|
||||
if any(part in ('', '.', '..') for part in relative) or not (physx or (vc and args and
|
||||
all(arg.lower() in ('/q:a', '/q', '/qn', '/quiet', '/norestart') for arg in args))):
|
||||
continue
|
||||
try:
|
||||
installer = install_dir
|
||||
# Wine resolves filenames without case sensitivity, even on the private library.
|
||||
for part in relative:
|
||||
if not os.path.exists(os.path.join(installer, part)):
|
||||
matches = [n for n in os.listdir(installer) if n.lower() == part.lower()]
|
||||
if len(matches) != 1:
|
||||
raise ValueError('ambiguous or missing redistributable path')
|
||||
part = matches[0]
|
||||
installer = os.path.join(installer, part)
|
||||
if physx:
|
||||
if not quiet_physx(installer):
|
||||
continue
|
||||
if args_index is None:
|
||||
end = block_end(tokens, process)
|
||||
tokens[end:end] = [vdf_quote('command 1'), vdf_quote('/qn')]
|
||||
else:
|
||||
tokens[args_index] = vdf_quote('/qn')
|
||||
changed = True
|
||||
print('bannerlator-steam-compat: running verified PhysX 8.10.17 quietly',
|
||||
file=sys.stderr)
|
||||
continue
|
||||
msi = redist_msi(installer,
|
||||
os.path.join(steam_root, 'legacycompat', '.droiddeck-redists'))
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as e:
|
||||
print('bannerlator-steam-compat: redist preparation failed; original installer kept: %s' % e,
|
||||
file=sys.stderr)
|
||||
continue
|
||||
if msi is None:
|
||||
continue
|
||||
tokens[exe_index] = vdf_quote(r'C:\windows\syswow64\msiexec.exe')
|
||||
tokens[args_index] = vdf_quote('/i "Z:%s" /qn /norestart' % msi.replace('/', '\\'))
|
||||
changed = True
|
||||
print('bannerlator-steam-compat: running extracted MSI for %s' % match[1], file=sys.stderr)
|
||||
if changed:
|
||||
write_changed(path, serialize(tokens))
|
||||
return changed
|
||||
|
||||
|
||||
def first_key(path, section):
|
||||
"""The single key naming a block inside `section`, or None. Used to keep a third-party tool's
|
||||
own internal name, so a mapping the user already made against it keeps working."""
|
||||
@@ -503,7 +702,7 @@ def adopt_extras(tools):
|
||||
body = (EXTRA_WRAPPER_SH % entry_point).replace(
|
||||
'here=$(dirname "$0")',
|
||||
'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
|
||||
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1)
|
||||
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_INSTALLSCRIPT_SETUP, 1)
|
||||
write_changed(wrapper, body, 0o755)
|
||||
write_changed(manifest, EXTRA_MANIFEST_VDF)
|
||||
write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, entry))
|
||||
@@ -638,4 +837,10 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
if len(sys.argv) == 4 and sys.argv[1] == '--installscript':
|
||||
try:
|
||||
prepare_installscript(sys.argv[2], sys.argv[3])
|
||||
except (OSError, ValueError, IndexError) as e:
|
||||
print('bannerlator-steam-compat: original install script kept: %s' % e, file=sys.stderr)
|
||||
else:
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
RESTORE = runpy.run_path(str(Path(__file__).resolve().parents[1] /
|
||||
'linuxfs/overlay/usr/local/bin/bannerlator-restore-redists'))
|
||||
|
||||
|
||||
class RestoreRedistsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
|
||||
def prefix(self, path):
|
||||
path.mkdir(parents=True)
|
||||
reg = path / 'system.reg'
|
||||
reg.write_text('WINE REGISTRY Version 2\n\n' +
|
||||
'[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2019] 1\n'
|
||||
'"x86"=dword:00000001\n"x64"=dword:00000001\n"OtherData"="preserved"\n\n'
|
||||
'[Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\DirectX\\\\Jun2010] 1\n'
|
||||
'"dxsetup"=dword:00000001\n\n'
|
||||
'[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\.NET\\\\4.8] 1\n'
|
||||
'"4.8"=dword:00000001\n\n'
|
||||
'[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\XNA\\\\4.0] 1\n'
|
||||
'"4.0"=dword:00000001\n\n'
|
||||
'[Software\\\\Valve\\\\Steam\\\\Apps\\\\50620] 1\n'
|
||||
'"Accessories"=dword:00000001\n\n'
|
||||
'[Software\\\\Microsoft\\\\Installer\\\\Products\\\\NativeProduct] 1\n'
|
||||
'"Installed"=dword:00000001\n')
|
||||
(path / '.bannerlator-redists').write_text('old checksum')
|
||||
return reg
|
||||
|
||||
def test_migration_preserves_proton_defaults_native_products_and_game_checks(self):
|
||||
reg = self.prefix(self.root / 'pfx')
|
||||
stat = reg.stat()
|
||||
self.assertTrue(RESTORE['restore_prefix'](reg.parent))
|
||||
text = reg.read_text()
|
||||
for flag in ('"x86"', '"x64"', '"dxsetup"'):
|
||||
self.assertNotIn(flag + '=dword:00000001', text)
|
||||
for flag in ('"4.8"', '"4.0"', '"Accessories"', '"Installed"'):
|
||||
self.assertIn(flag + '=dword:00000001', text)
|
||||
self.assertIn('"OtherData"="preserved"', text)
|
||||
self.assertEqual(stat.st_mtime_ns, reg.stat().st_mtime_ns)
|
||||
self.assertEqual(stat.st_mode, reg.stat().st_mode)
|
||||
# Once Steam has really installed a prerequisite, a later startup keeps its result.
|
||||
with reg.open('a') as f:
|
||||
f.write('\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2022] 2\n'
|
||||
'"x86"=dword:00000001\n')
|
||||
installed = reg.read_bytes()
|
||||
self.assertFalse(RESTORE['restore_prefix'](reg.parent))
|
||||
self.assertEqual(installed, reg.read_bytes())
|
||||
|
||||
def test_unstamped_prefixes_and_registry_symlinks_are_untouched(self):
|
||||
reg = self.prefix(self.root / 'pfx')
|
||||
original = reg.read_bytes()
|
||||
(reg.parent / '.bannerlator-redists').unlink()
|
||||
self.assertFalse(RESTORE['restore_prefix'](reg.parent))
|
||||
self.assertEqual(original, reg.read_bytes())
|
||||
(reg.parent / '.bannerlator-redists').touch()
|
||||
outside = self.root / 'original.reg'
|
||||
reg.rename(outside)
|
||||
reg.symlink_to(outside)
|
||||
self.assertFalse(RESTORE['restore_prefix'](reg.parent))
|
||||
self.assertEqual(original, outside.read_bytes())
|
||||
|
||||
def test_primary_secondary_and_both_template_architectures_are_migrated(self):
|
||||
steam, secondary = self.root / 'Steam', self.root / 'SD'
|
||||
paths = [steam / 'steamapps/compatdata/1/pfx',
|
||||
secondary / 'steamapps/compatdata/2/pfx',
|
||||
steam / 'steamapps/common/Proton 11/files/share/default_pfx_arm64',
|
||||
secondary / 'steamapps/common/Proton Experimental/files/share/default_pfx',
|
||||
steam / 'compatibilitytools.d/GE/files/share/default_pfx_arm64']
|
||||
for path in paths:
|
||||
self.prefix(path)
|
||||
self.assertEqual(5, RESTORE['restore'](steam, [secondary]))
|
||||
self.assertEqual(0, RESTORE['restore'](steam, [secondary]))
|
||||
|
||||
def test_invalid_registry_keeps_migration_stamp_for_retry(self):
|
||||
reg = self.prefix(self.root / 'pfx')
|
||||
reg.write_text('incomplete registry')
|
||||
with self.assertRaises(ValueError):
|
||||
RESTORE['restore_prefix'](reg.parent)
|
||||
self.assertTrue((reg.parent / '.bannerlator-redists').is_file())
|
||||
self.assertEqual('incomplete registry', reg.read_text())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,182 @@
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import runpy
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
COMPAT = runpy.run_path(str(Path(__file__).resolve().parents[1] /
|
||||
'linuxfs/overlay/usr/local/bin/bannerlator-steam-compat'))
|
||||
PREPARE = COMPAT['prepare_installscript']
|
||||
|
||||
|
||||
class SteamRedistTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.steam = Path(self.tmp.name) / 'Steam root'
|
||||
(self.steam / 'legacycompat').mkdir(parents=True)
|
||||
self.path = self.steam / 'legacycompat/evaluatorscript_50620.vdf'
|
||||
self.msi = str(self.steam / 'legacycompat/cache/vcredist.msi')
|
||||
self.installer_dir = self.steam / 'steamapps/common/Darksiders'
|
||||
self.installer_dir.mkdir(parents=True)
|
||||
(self.installer_dir / 'vcredist_x86_sp1_atl_4053.exe').touch()
|
||||
self.original = '''"evaluatorscript" { "0" {
|
||||
"appid" "50620" "install_path" "%s"
|
||||
"compat_installscript" {
|
||||
"registry" { "unrelated" { "string" { "keep" "value" } } }
|
||||
"RuN PrOcEsS" {
|
||||
"VCREDIST" { "Process 1" "%%INSTALLDIR%%\\\\vcredist_x86_sp1_atl_4053.exe"
|
||||
"Command 1" "/q:a" "IgnoreExitCode" "1" "NoCleanUp" "1" }
|
||||
"Accessories" { "process 1" "%%INSTALLDIR%%\\\\DSInstaller.exe"
|
||||
"command 1" "" "description" "Comic and Soundtrack" }
|
||||
}
|
||||
}
|
||||
} }''' % (self.steam / 'steamapps/common/Darksiders')
|
||||
self.path.write_text(self.original)
|
||||
|
||||
def prepare(self):
|
||||
return PREPARE(str(self.steam), r'legacycompat\evaluatorscript_50620.vdf')
|
||||
|
||||
def test_msi_launch_preserves_other_install_steps_and_registry(self):
|
||||
with patch.dict(PREPARE.__globals__, redist_msi=lambda *args: self.msi):
|
||||
self.assertTrue(self.prepare())
|
||||
tokens = COMPAT['tokenize'](self.path.read_text())
|
||||
decoded = [COMPAT['vdf_value'](t) for t in tokens if t.startswith('"')]
|
||||
self.assertIn(r'C:\windows\syswow64\msiexec.exe', decoded)
|
||||
self.assertIn('/i "Z:%s" /qn /norestart' % self.msi.replace('/', '\\'), decoded)
|
||||
self.assertIn(r'%INSTALLDIR%\DSInstaller.exe', decoded)
|
||||
self.assertIn('Comic and Soundtrack', decoded)
|
||||
self.assertIn('unrelated', decoded)
|
||||
self.assertIn('IgnoreExitCode', decoded)
|
||||
# Once converted, another wrapper invocation leaves the file untouched.
|
||||
before = self.path.read_bytes()
|
||||
self.assertFalse(self.prepare())
|
||||
self.assertEqual(before, self.path.read_bytes())
|
||||
|
||||
def test_uninstall_custom_commands_and_other_architectures_are_untouched(self):
|
||||
for old, new in (('/q:a', '/uninstall /quiet'), ('/q:a', '/q:a /custom'),
|
||||
('vcredist_x86', 'vcredist_x64'),
|
||||
('vcredist_x86_sp1_atl_4053.exe', 'other.exe')):
|
||||
self.path.write_text(self.original.replace(old, new))
|
||||
before = self.path.read_bytes()
|
||||
with patch.dict(PREPARE.__globals__, redist_msi=lambda *args: self.fail('not a supported installer')):
|
||||
self.assertFalse(self.prepare())
|
||||
self.assertEqual(before, self.path.read_bytes())
|
||||
|
||||
def test_failed_or_unsupported_extraction_keeps_original(self):
|
||||
def fail(*args):
|
||||
raise OSError('missing archive tool')
|
||||
for resolver in (fail, lambda *args: None):
|
||||
with patch.dict(PREPARE.__globals__, redist_msi=resolver):
|
||||
self.assertFalse(self.prepare())
|
||||
self.assertEqual(self.original, self.path.read_text())
|
||||
|
||||
def test_only_transient_evaluator_paths_can_be_rewritten(self):
|
||||
for path in ('../legacycompat/evaluatorscript_50620.vdf',
|
||||
str(self.path), r'legacycompat\other.vdf',
|
||||
r'legacycompat\..\evaluatorscript_50620.vdf'):
|
||||
self.assertFalse(PREPARE(str(self.steam), path))
|
||||
self.assertEqual(self.original, self.path.read_text())
|
||||
|
||||
def test_subdirectory_and_case_sensitive_linux_files_are_resolved(self):
|
||||
support = self.installer_dir / 'Support'
|
||||
support.mkdir()
|
||||
installer = support / 'vcredist_x86.exe'
|
||||
installer.touch()
|
||||
self.path.write_text(self.original.replace('vcredist_x86_sp1_atl_4053.exe',
|
||||
r'support\\\\VCREDIST_X86.EXE'))
|
||||
def resolver(source, cache):
|
||||
self.assertTrue(Path(source).samefile(installer))
|
||||
return self.msi
|
||||
with patch.dict(PREPARE.__globals__, redist_msi=resolver):
|
||||
self.assertTrue(self.prepare())
|
||||
|
||||
def test_installer_traversal_is_untouched(self):
|
||||
self.path.write_text(self.original.replace('vcredist_x86_sp1_atl_4053.exe',
|
||||
r'..\\\\vcredist_x86.exe'))
|
||||
before = self.path.read_bytes()
|
||||
self.assertFalse(self.prepare())
|
||||
self.assertEqual(before, self.path.read_bytes())
|
||||
|
||||
def test_archive_extraction_is_limited_to_recognized_flat_members(self):
|
||||
installer = Path(self.tmp.name) / 'vcredist_x86.exe'
|
||||
installer.write_bytes(b'fixture')
|
||||
cache = Path(self.tmp.name) / 'cache'
|
||||
def archive_run(argv, **kwargs):
|
||||
if argv[1] == '-tf':
|
||||
return type('Result', (), {'stdout': 'vcredist.msi\nvcredis1.cab\n../../outside\n'})()
|
||||
self.assertEqual(argv[-3:], ['--', 'vcredist.msi', 'vcredis1.cab'])
|
||||
destination = Path(argv[argv.index('-C') + 1])
|
||||
(destination / 'vcredist.msi').write_bytes(b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')
|
||||
(destination / 'vcredis1.cab').write_bytes(b'MSCF')
|
||||
with patch.object(COMPAT['subprocess'], 'run', side_effect=archive_run):
|
||||
result = COMPAT['redist_msi'](str(installer), str(cache))
|
||||
self.assertTrue(Path(result).is_file())
|
||||
self.assertFalse((Path(self.tmp.name) / 'outside').exists())
|
||||
|
||||
def physx_fixture(self, command=None):
|
||||
payload = b'verified Wise PhysX bundle fixture'
|
||||
directory = self.installer_dir / 'PhysX'
|
||||
directory.mkdir(exist_ok=True)
|
||||
installer = directory / 'PhysX_SystemSoftware.exe'
|
||||
installer.write_bytes(payload)
|
||||
script = '''"evaluatorscript" { "0" {
|
||||
"install_path" "%s" "compat_installscript" { "run process" {
|
||||
"PhysX Version" {
|
||||
"process 1" "%%INSTALLDIR%%\\\\physx\\\\PHYSX_SYSTEMSOFTWARE.EXE"
|
||||
%s
|
||||
"HasRunKey" "HKEY_LOCAL_MACHINE\\\\SOFTWARE\\\\AGEIA Technologies"
|
||||
"MinimumHasRunValue" "81017" "NoCleanUp" "1"
|
||||
}
|
||||
"Other step" { "process 1" "%%INSTALLDIR%%\\\\other.exe" }
|
||||
} }
|
||||
} }''' % (self.installer_dir, '' if command is None else
|
||||
'"command 1" "%s"' % command)
|
||||
self.path.write_text(script)
|
||||
return installer, hashlib.sha256(payload).hexdigest()
|
||||
|
||||
def test_verified_physx_runs_quietly_and_preserves_steam_completion_checks(self):
|
||||
for command in (None, ''):
|
||||
installer, digest = self.physx_fixture(command)
|
||||
with patch.dict(PREPARE.__globals__, QUIET_PHYSX_SHA256=digest):
|
||||
self.assertTrue(self.prepare())
|
||||
tokens = COMPAT['tokenize'](self.path.read_text())
|
||||
decoded = [COMPAT['vdf_value'](t) for t in tokens if t.startswith('"')]
|
||||
self.assertIn('/qn', decoded)
|
||||
self.assertIn(r'%INSTALLDIR%\physx\PHYSX_SYSTEMSOFTWARE.EXE', decoded)
|
||||
self.assertIn('81017', decoded)
|
||||
self.assertIn(r'HKEY_LOCAL_MACHINE\SOFTWARE\AGEIA Technologies', decoded)
|
||||
self.assertIn('Other step', decoded)
|
||||
before = self.path.read_bytes()
|
||||
self.assertFalse(self.prepare())
|
||||
self.assertEqual(before, self.path.read_bytes())
|
||||
|
||||
def test_physx_other_versions_and_existing_commands_are_untouched(self):
|
||||
installer, digest = self.physx_fixture()
|
||||
before = self.path.read_bytes()
|
||||
self.assertFalse(self.prepare()) # Fixture is not the real bundle's hash.
|
||||
self.assertEqual(before, self.path.read_bytes())
|
||||
for command in ('/uninstall', '/custom', '/s', '/qn'):
|
||||
self.physx_fixture(command)
|
||||
before = self.path.read_bytes()
|
||||
with patch.dict(PREPARE.__globals__, QUIET_PHYSX_SHA256=digest):
|
||||
self.assertFalse(self.prepare())
|
||||
self.assertEqual(before, self.path.read_bytes())
|
||||
|
||||
def test_physx_missing_or_traversing_installer_keeps_original(self):
|
||||
installer, digest = self.physx_fixture()
|
||||
installer.unlink()
|
||||
before = self.path.read_bytes()
|
||||
self.assertFalse(self.prepare())
|
||||
self.assertEqual(before, self.path.read_bytes())
|
||||
self.physx_fixture()
|
||||
self.path.write_text(self.path.read_text().replace('physx\\\\', '..\\\\'))
|
||||
before = self.path.read_bytes()
|
||||
with patch.dict(PREPARE.__globals__, QUIET_PHYSX_SHA256=digest):
|
||||
self.assertFalse(self.prepare())
|
||||
self.assertEqual(before, self.path.read_bytes())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user